/* * 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 "document_text.hh" #include #include #include namespace ricochet::tui::detail { std::string control_display_text( const Control& control, const TextService& text ) { return std::visit( [&control, &text]( const auto& value ) -> std::string { using Value = std::decay_t; if constexpr ( std::is_same_v ) { if ( value.kind == TextControlKind::Password ) { return std::string( text.analyze( value.value, true, false ).graphemes.size(), '*' ); } return value.value; } else if constexpr ( std::is_same_v || std::is_same_v || std::is_same_v ) { return control.label; } else if constexpr ( std::is_same_v ) { std::string result; for ( const auto& option : value.options ) { if ( std::ranges::find( value.selected, option.id ) != value.selected.end() ) { if ( !result.empty() ) { result += ", "; } result += option.label; } } return result; } else { return value.description; } }, control.content ); } DocumentText::DocumentText( const Document& document, const TextService& text ) : service_( text ) { for ( const auto& control : document.data().controls ) { definitions_.emplace( control.id, &control ); } append_blocks( document.data().blocks, 0 ); } void DocumentText::append_text( const Text& text ) { const auto start = text_.size(); text_ += text.utf8; blocks_.emplace( text.id, ByteRange { start, text_.size() } ); } void DocumentText::append_control( ControlId id ) { const auto& control = *definitions_.at( id ); const auto start = text_.size(); // State is semantic content when selection crosses the object's opening boundary. if ( const auto* checkbox = std::get_if( &control.content ) ) { text_ += checkbox->checked ? "checked " : "unchecked "; } else if ( const auto* radio = std::get_if( &control.content ) ) { text_ += radio->checked ? "selected " : "unselected "; } const auto display = text_.size(); text_ += control_display_text( control, service_ ); controls_.emplace( id, ControlSpan { { start, text_.size() }, { display, text_.size() } } ); } void DocumentText::append_inline( std::span content ) { for ( const auto& item : content ) { std::visit( [this]( const auto& value ) { using Value = std::decay_t; if constexpr ( std::is_same_v ) { append_text( value ); } else { append_control( value.control ); } }, item ); } } void DocumentText::append_blocks( std::span blocks, std::size_t depth ) { for ( std::size_t index = 0; index < blocks.size(); ++index ) { if ( index != 0 ) { text_ += "\n\n"; } append_block( blocks[index], depth ); } } void DocumentText::append_block( const Block& block, std::size_t depth ) { const auto start = text_.size(); std::visit( [this, depth]( const auto& value ) { using Value = std::decay_t; if constexpr ( std::is_same_v || std::is_same_v ) { append_inline( value.content ); } else if constexpr ( std::is_same_v ) { append_text( value.content ); } else if constexpr ( std::is_same_v ) { append_text( value.description ); } else if constexpr ( std::is_same_v ) { append_control( value.control ); } else if constexpr ( std::is_same_v ) { auto number = value.first_number; for ( std::size_t index = 0; index < value.items.size(); ++index ) { if ( index != 0 ) { text_ += '\n'; } const auto& item = value.items[index]; const auto prefix = text_.size(); text_.append( depth * 2, ' ' ); text_ += item.marker.value_or( value.kind == ListKind::Ordered ? std::to_string( number ) + "." : "-" ); text_ += ' '; prefixes_.push_back( { prefix, text_.size() } ); append_blocks( item.content, depth + 1 ); // Match layout's saturating counter at the model's largest list number. if ( number < std::numeric_limits::max() ) { ++number; } } } else if constexpr ( std::is_same_v ) { for ( std::size_t row = 0; row < value.rows.size(); ++row ) { if ( row != 0 ) { text_ += '\n'; } const auto& cells = value.rows[row].cells; for ( std::size_t cell = 0; cell < cells.size(); ++cell ) { if ( cell != 0 ) { text_ += '\t'; } append_blocks( cells[cell].content, depth ); } } } // Separators carry positions but no copied decoration. }, block.content ); blocks_.emplace( block.id, ByteRange { start, text_.size() } ); } std::size_t DocumentText::offset( const DocumentPoint& point ) const { return std::visit( [this]( const auto& value ) -> std::size_t { using Value = std::decay_t; if constexpr ( std::is_same_v ) { return blocks_.at( value.block ).start + value.byte_offset; } else if constexpr ( std::is_same_v ) { return controls_.at( value.control ).display.start + value.byte_offset; } else if constexpr ( std::is_same_v ) { const auto span = std::visit( [this]( const auto& id ) -> ByteRange { using Id = std::decay_t; if constexpr ( std::is_same_v ) { return blocks_.at( id ); } else { return controls_.at( id ).object; } }, value.object ); return value.side == BoundarySide::Before ? span.start : span.end; } else { return 0; } }, point ); } ByteRange DocumentText::offsets( const DocumentRange& range ) const { return { offset( range.start ), offset( range.end ) }; } SelectionSnapshot DocumentText::select( const DocumentRange& anchor, const DocumentRange& active ) const { const bool backward = offset( active.start ) < offset( anchor.start ); // A zero-width boundary can coincide with a nonempty endpoint unit. Keep the // whole anchored unit even when the active endpoint comes back to that boundary. return { .range = { backward ? active.start : anchor.start, offset( active.end ) > offset( anchor.end ) ? active.end : anchor.end }, .direction = backward ? SelectionDirection::Backward : SelectionDirection::Forward, .anchor = anchor, .active = active }; } std::string DocumentText::extract( const DocumentRange& range ) const { auto bytes = offsets( range ); if ( bytes.start == bytes.end ) { return {}; } // Include a semantic marker only when selection includes the item's start. // Inner prefixes expand first, including a surrounding item with a nested list. for ( auto prefix = prefixes_.rbegin(); prefix != prefixes_.rend(); ++prefix ) { if ( prefix->end == bytes.start ) { bytes.start = prefix->start; } } auto result = text_.substr( bytes.start, bytes.end - bytes.start ); // CRLF is one hard-break grapheme; copied hard breaks use one LF. for ( auto position = result.find( "\r\n" ); position != std::string::npos; position = result.find( "\r\n", position + 1 ) ) { result.erase( position, 1 ); } return result; } } // namespace ricochet::tui::detail