/*
* Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
* Copyright (C) 2026 Richard Qin
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
#include "text.hh"
#include
#include
#include
#include
#include
#include
#include
#include
namespace ricochet::tui::detail {
namespace {
using UnicodeText = std::unique_ptr;
using Breaks = std::unique_ptr;
Breaks breaks_for( UBreakIteratorType type, UText& text )
{
UErrorCode status = U_ZERO_ERROR;
Breaks breaks( ubrk_open( type, "root", nullptr, 0, &status ), &ubrk_close );
if ( U_FAILURE( status ) ) {
throw std::runtime_error( u_errorName( status ) );
}
ubrk_setUText( breaks.get(), &text, &status );
if ( U_FAILURE( status ) ) {
throw std::runtime_error( u_errorName( status ) );
}
return breaks;
}
WordKind word_kind( UChar32 codepoint )
{
if ( u_isUWhiteSpace( codepoint ) ) {
return WordKind::Space;
}
if ( u_hasBinaryProperty( codepoint, UCHAR_IDEOGRAPHIC ) ) {
return WordKind::Ideograph;
}
if ( codepoint == '_' || u_isalnum( codepoint ) ) {
return WordKind::Word;
}
return WordKind::Punctuation;
}
} // namespace
TextService::TextService( std::size_t glyph_capacity ) : glyph_capacity_( glyph_capacity ) {}
TextAnalysis TextService::analyze( std::string_view utf8, bool line_breaks, bool tabs ) const
{
// ICU BreakIterator exposes int32_t native offsets even with a UTF-8 UText.
if ( utf8.size() > INT32_MAX ) {
throw std::length_error( "Text exceeds ICU break-iterator offset capacity" );
}
UErrorCode status = U_ZERO_ERROR;
UnicodeText text(
utext_openUTF8( nullptr, utf8.empty() ? "" : utf8.data(), static_cast( utf8.size() ), &status ),
&utext_close );
if ( U_FAILURE( status ) ) {
throw std::runtime_error( u_errorName( status ) );
}
auto characters = breaks_for( UBRK_CHARACTER, *text );
auto lines = breaks_for( UBRK_LINE, *text );
TextAnalysis result;
int32_t begin = ubrk_first( characters.get() );
int32_t line_end = ubrk_next( lines.get() );
WordKind previous = WordKind::Space;
for ( int32_t end = ubrk_next( characters.get() ); end != UBRK_DONE;
begin = end, end = ubrk_next( characters.get() ) ) {
while ( line_end != UBRK_DONE && line_end < end ) {
line_end = ubrk_next( lines.get() );
}
Grapheme glyph { .bytes = { static_cast( begin ), static_cast( end ) },
.break_after = line_end == end };
utext_setNativeIndex( text.get(), begin );
const auto first = utext_next32( text.get() );
glyph.word = word_kind( first );
const auto source = utf8.substr( glyph.bytes.start, glyph.bytes.end - glyph.bytes.start );
if ( line_breaks && ( source == "\n" || source == "\r\n" ) ) {
glyph.kind = TextUnitKind::LineBreak;
glyph.columns = 0;
} else if ( tabs && source == "\t" ) {
glyph.kind = TextUnitKind::Tab;
glyph.display = L" ";
} else {
const int width = ::wcwidth( static_cast( first ) );
glyph.display.push_back( static_cast( first ) );
bool representable = width > 0 && !u_hasBinaryProperty( first, UCHAR_DEFAULT_IGNORABLE_CODE_POINT )
&& !u_hasBinaryProperty( first, UCHAR_EMOJI_PRESENTATION );
while ( utext_getNativeIndex( text.get() ) < end ) {
const auto codepoint = utext_next32( text.get() );
glyph.display.push_back( static_cast( codepoint ) );
// ncurses accepts one spacing character followed by nonspacing marks.
// Joiners, selectors and extra spacing characters use one visible fallback.
representable = representable && ::wcwidth( static_cast( codepoint ) ) == 0
&& u_charType( codepoint ) == U_NON_SPACING_MARK
&& !u_hasBinaryProperty( codepoint, UCHAR_DEFAULT_IGNORABLE_CODE_POINT );
}
if ( !representable || glyph.display.size() > glyph_capacity_ ) {
glyph.display = L"?";
glyph.columns = 1;
} else {
glyph.columns = width;
}
}
if ( glyph.word != WordKind::Space ) {
if ( glyph.word == previous && glyph.word != WordKind::Ideograph ) {
result.words.back().end = glyph.bytes.end;
} else {
result.words.push_back( glyph.bytes );
}
}
previous = glyph.word;
result.graphemes.push_back( std::move( glyph ) );
}
return result;
}
} // namespace ricochet::tui::detail