/* * 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 "layout.hh" #include "document_text.hh" #include #include #include #include #include #include #include #include #include #include #include namespace ricochet::tui::detail { namespace { constexpr int tab_columns = 8; constexpr std::int64_t cell_gap = 3; constexpr std::int64_t minimum_cell_columns = 4; TextStyle overlay( TextStyle base, TextStyle extra ) { return { base.emphasis || extra.emphasis, base.strong || extra.strong, base.code || extra.code, base.subdued || extra.subdued, base.warning || extra.warning }; } struct Atom { Grapheme glyph {}; DocumentRange source {}; TextStyle style {}; std::optional target {}; bool decoration { false }; }; struct TextSpan { const Text* leaf; ByteRange bytes; }; DocumentPoint text_point( std::span spans, std::size_t offset, bool end ) { for ( const auto& span : spans ) { // At an inline seam an end belongs to the preceding leaf, a start to the next. if ( offset < span.bytes.end || ( end && offset == span.bytes.end ) ) { return TextPosition { span.leaf->id, offset - span.bytes.start }; } } const auto& last = spans.back(); return TextPosition { last.leaf->id, last.leaf->utf8.size() }; } LayoutGlyph anchor( DocumentPoint point, LayoutPoint location, std::int64_t width ) { return { .position = location, .source = { point, point }, .clip_right = width }; } void append_box( LayoutData& destination, LayoutData source, std::size_t row, std::int64_t column ) { destination.rows = std::max( destination.rows, row + source.rows ); destination.extent = std::max( destination.extent, column + source.extent ); for ( auto& glyph : source.glyphs ) { glyph.position.row += row; glyph.position.column += column; glyph.line_start += column; for ( auto& window : glyph.scrolls ) { window.left += column; } glyph.clip_left += column; glyph.clip_right += column; destination.glyphs.push_back( std::move( glyph ) ); } } class LayoutBuilder { public: LayoutBuilder( const DocumentData& document, const TextService& text, std::span scrolls ) : document_( document ), text_( text ), scrolls_( scrolls ) { for ( const auto& control : document.controls ) { controls_.emplace( control.id, &control ); } } LayoutData blocks( std::span content, std::int64_t width, TextStyle style = {} ) const { LayoutData result; std::size_t row = 0; for ( const auto& block : content ) { auto box = block_box( block, width, style ); const auto height = box.rows; append_box( result, std::move( box ), row, 0 ); row += height; } return result; } private: void text_run( std::vector& atoms, std::span spans, const std::string& utf8, bool tabs, TextStyle base ) const { const auto analysis = text_.analyze( utf8, true, tabs ); for ( const auto& glyph : analysis.graphemes ) { Atom atom { .glyph = glyph, .source = { text_point( spans, glyph.bytes.start, false ), text_point( spans, glyph.bytes.end, true ) }, .style = base }; for ( const auto& span : spans ) { // Only source portions intersecting this grapheme can contribute style/link. if ( span.bytes.start >= glyph.bytes.end || span.bytes.end <= glyph.bytes.start ) { continue; } const ByteRange local { std::max( span.bytes.start, glyph.bytes.start ) - span.bytes.start, std::min( span.bytes.end, glyph.bytes.end ) - span.bytes.start }; for ( const auto& styled : span.leaf->styles ) { if ( styled.bytes.start < local.end && local.start < styled.bytes.end ) { atom.style = overlay( atom.style, styled.style ); } } for ( const auto& link : document_.links ) { for ( const auto& range : link.ranges ) { if ( range.block == span.leaf->id && range.bytes.start < local.end && local.start < range.bytes.end ) { const HitTarget target = link.id; // One grapheme cannot activate two links, including across leaf seams. if ( atom.target && atom.target != target ) { throw std::invalid_argument( "Overlapping links address the same grapheme" ); } atom.target = target; atom.style.emphasis = true; } } } } atoms.push_back( std::move( atom ) ); } // Empty text still has a real byte boundary; no fabricated display character. if ( utf8.empty() ) { const auto point = text_point( spans, 0, false ); atoms.push_back( { .glyph = { .columns = 0 }, .source = { point, point }, .style = base } ); } } void control_atoms( std::vector& atoms, ControlId id, TextStyle base ) const { // A missing ID is a malformed document reference; map::at reports it directly. const auto& control = *controls_.at( id ); const auto* input = std::get_if( &control.content ); const bool multiline = input && input->kind == TextControlKind::MultiLine; const auto before = ObjectPosition { id, BoundarySide::Before }; const auto after = ObjectPosition { id, BoundarySide::After }; const auto style = overlay( base, { .subdued = control.disabled, .warning = control.error.has_value() } ); std::wstring_view opening = L"["; std::wstring_view closing = L"]"; if ( const auto* checkbox = std::get_if( &control.content ) ) { opening = checkbox->checked ? L"[x] " : L"[ ] "; closing = L""; } else if ( const auto* radio = std::get_if( &control.content ) ) { opening = radio->checked ? L"(*) " : L"( ) "; closing = L""; } for ( const auto character : opening ) { atoms.push_back( { .glyph = { .display = std::wstring( 1, character ) }, .source = { before, before }, .style = style, .target = id } ); } const auto display = control_display_text( control, text_ ); const auto analysis = text_.analyze( display, multiline, false ); for ( const auto& glyph : analysis.graphemes ) { atoms.push_back( { .glyph = glyph, .source = { ControlTextPosition { id, {}, glyph.bytes.start }, ControlTextPosition { id, {}, glyph.bytes.end } }, .style = style, .target = id } ); } if ( display.empty() ) { const auto point = ControlTextPosition { id, {}, 0 }; atoms.push_back( { .glyph = { .display = L" " }, .source = { point, point }, .style = style, .target = id } ); } for ( const auto character : closing ) { atoms.push_back( { .glyph = { .display = std::wstring( 1, character ), .break_after = true }, .source = { after, after }, .style = style, .target = id } ); } // Checkbox/radio controls have no closing bracket but still have an after boundary. atoms.push_back( { .glyph = { .columns = 0, .break_after = true }, .source = { after, after }, .style = style, .target = id } ); } std::vector inline_atoms( std::span content, TextStyle style ) const { std::vector result; std::vector spans; std::string utf8; const auto flush = [&] { if ( !spans.empty() ) { text_run( result, spans, utf8, false, style ); spans.clear(); utf8.clear(); } }; for ( const auto& item : content ) { if ( const auto* text = std::get_if( &item ) ) { const auto start = utf8.size(); utf8 += text->utf8; spans.push_back( { text, { start, utf8.size() } } ); } else { flush(); control_atoms( result, std::get( item ).control, style ); } } flush(); return result; } LayoutData flow( std::span atoms, std::int64_t width, bool wrap ) const { LayoutData result; std::size_t start = 0; std::size_t row = 0; while ( start < atoms.size() ) { std::size_t end = start; std::size_t opportunity = start; std::int64_t column = 0; while ( end < atoms.size() && atoms[end].glyph.kind != TextUnitKind::LineBreak ) { const auto& glyph = atoms[end].glyph; const int cells = glyph.kind == TextUnitKind::Tab ? tab_columns - column % tab_columns : glyph.columns; if ( wrap && column + cells > width && end > start ) { break; } column += cells; ++end; if ( glyph.break_after ) { opportunity = end; } } // Prefer a Unicode line boundary; an overlong word breaks at a grapheme. if ( end < atoms.size() && atoms[end].glyph.kind != TextUnitKind::LineBreak && opportunity > start ) { end = opportunity; } column = 0; for ( auto index = start; index < end; ++index ) { const auto& atom = atoms[index]; const bool tab = atom.glyph.kind == TextUnitKind::Tab; const int cells = tab ? tab_columns - column % tab_columns : atom.glyph.columns; result.glyphs.push_back( { .position = { row, column }, .columns = cells, .text = atom.glyph.display, .style = atom.style, .source = atom.source, .target = atom.target, .clip_right = wrap ? width : std::numeric_limits::max(), .decoration = atom.decoration, .fill = tab, .word_start = atom.glyph.word_start, .word_end = atom.glyph.word_end } ); column += cells; } result.extent = std::max( result.extent, column ); if ( end < atoms.size() && atoms[end].glyph.kind == TextUnitKind::LineBreak ) { const auto& atom = atoms[end++]; result.glyphs.push_back( { .position = { row, column }, .source = atom.source, .target = atom.target, .clip_right = width, .line_break = true } ); // The endpoint of a hard LF belongs to the next line, including a // trailing empty line; it must also be reachable by hit testing. auto boundary = anchor( atom.source.end, { row + 1, 0 }, width ); boundary.target = atom.target; result.glyphs.push_back( std::move( boundary ) ); result.rows = row + 2; } else { result.rows = row + 1; } start = end; ++row; } return result; } void horizontal( LayoutData& box, BlockId block, std::int64_t width ) const { const auto scroll = std::ranges::find( scrolls_, block, &HorizontalScroll::block ); const auto requested = scroll == scrolls_.end() ? 0 : scroll->columns; const auto maximum = std::max( 0, box.extent - width ); const auto shift = static_cast( std::min( requested, maximum ) ); for ( auto& glyph : box.glyphs ) { for ( auto& window : glyph.scrolls ) { window.left -= shift; } glyph.scrolls.push_back( { block, 0, width, shift, maximum } ); glyph.position.column -= shift; glyph.line_start -= shift; glyph.clip_left = std::max( 0, glyph.clip_left - shift ); glyph.clip_right = std::min( width, glyph.clip_right - shift ); } } LayoutData list_box( const List& list, std::int64_t width, TextStyle style ) const { LayoutData result; std::size_t row = 0; auto number = list.first_number; for ( const auto& item : list.items ) { const auto marker = item.marker.value_or( list.kind == ListKind::Ordered ? std::to_string( number ) + "." : "-" ); const auto analysis = text_.analyze( marker, false, false ); const auto marker_width = std::accumulate( analysis.graphemes.begin(), analysis.graphemes.end(), std::int64_t { 0 }, []( auto total, const auto& glyph ) { return total + glyph.columns; } ); // Deep nesting must leave at least one body cell; clipped markers stay decoration. const auto indent = std::min( marker_width + 1, width - 1 ); auto body = blocks( item.content, width - indent, style ); const auto height = body.rows; append_box( result, std::move( body ), row, indent ); std::int64_t column = 0; for ( const auto& glyph : analysis.graphemes ) { result.glyphs.push_back( { .position = { row, column }, .columns = glyph.columns, .text = glyph.display, .style = style, .clip_right = indent, .decoration = true } ); column += glyph.columns; } row += height; // Ordered counters can start at the model's largest int64 value. if ( number < std::numeric_limits::max() ) { ++number; } } return result; } LayoutData table_box( const Table& table, BlockId id, std::int64_t width, TextStyle style ) const { if ( table.columns == 0 ) { return {}; } std::vector widths( table.columns, 1 ); std::vector desired( table.columns, 1 ); for ( std::size_t row = 0; row < table.rows.size(); ++row ) { for ( const auto& cell : table.rows[row].cells ) { // Spans index the supplied grid. Reject zero/out-of-grid spans before indexing. if ( cell.column >= table.columns || cell.column_span == 0 || cell.row_span == 0 || cell.column_span > table.columns - cell.column || cell.row_span > table.rows.size() - row ) { throw std::invalid_argument( "Table cell span lies outside its resolved grid" ); } const auto measure = blocks( cell.content, width, style ); const auto span = static_cast( cell.column_span ); const auto preferred = std::max( 1, ( measure.extent + span - 1 ) / span ); for ( auto column = cell.column; column < cell.column + cell.column_span; ++column ) { desired[column] = std::max( desired[column], preferred ); widths[column] = std::max( widths[column], std::min( preferred, minimum_cell_columns ) ); } } } const auto gaps = cell_gap * static_cast( table.columns - 1 ); auto remaining = width - gaps - std::accumulate( widths.begin(), widths.end(), std::int64_t { 0 } ); // Grow content-bearing columns toward their preferred width, within the viewport. while ( remaining > 0 ) { bool grew = false; for ( std::size_t column = 0; column < widths.size() && remaining > 0; ++column ) { if ( widths[column] < desired[column] ) { ++widths[column]; --remaining; grew = true; } } if ( !grew ) { break; } } struct CellBox { const TableCell* cell; std::size_t row; LayoutData box; }; std::vector cells; std::vector heights( table.rows.size(), 1 ); for ( std::size_t row = 0; row < table.rows.size(); ++row ) { for ( const auto& cell : table.rows[row].cells ) { const auto first = widths.begin() + static_cast( cell.column ); const auto last = first + static_cast( cell.column_span ); const auto cell_width = std::accumulate( first, last, std::int64_t { 0 } ) + cell_gap * static_cast( cell.column_span - 1 ); auto box = blocks( cell.content, cell_width, overlay( style, { .strong = cell.header } ) ); const auto begin = heights.begin() + static_cast( row ); const auto end = begin + static_cast( cell.row_span ); const auto height = std::accumulate( begin, end, std::size_t { 0 } ); if ( box.rows > height ) { heights[row + cell.row_span - 1] += box.rows - height; } cells.push_back( { &cell, row, std::move( box ) } ); } } std::vector row_offsets( heights.size() + 1, 0 ); std::partial_sum( heights.begin(), heights.end(), row_offsets.begin() + 1 ); std::vector column_offsets( widths.size(), 0 ); for ( std::size_t column = 1; column < widths.size(); ++column ) { column_offsets[column] = column_offsets[column - 1] + widths[column - 1] + cell_gap; } LayoutData result; result.extent = std::accumulate( widths.begin(), widths.end(), gaps ); result.rows = std::max( 1, row_offsets.back() ); for ( auto& cell : cells ) { const auto column = column_offsets[cell.cell->column]; append_box( result, std::move( cell.box ), row_offsets[cell.row], column ); if ( cell.cell->column != 0 ) { for ( auto row = row_offsets[cell.row]; row < row_offsets[cell.row + cell.cell->row_span]; ++row ) { result.glyphs.push_back( { .position = { row, column - 2 }, .columns = 1, .text = L"|", .style = { .subdued = true }, .clip_right = result.extent, .decoration = true } ); } } } // A nested preformatted block scrolls inside its cell; its natural extent // must not enlarge the enclosing table's horizontal scroll range. result.extent = std::accumulate( widths.begin(), widths.end(), gaps ); horizontal( result, id, width ); return result; } LayoutData block_box( const Block& block, std::int64_t width, TextStyle style ) const { auto result = std::visit( [this, &block, width, style]( const auto& value ) -> LayoutData { using Value = std::decay_t; if constexpr ( std::is_same_v ) { return flow( inline_atoms( value.content, style ), width, true ); } else if constexpr ( std::is_same_v ) { return flow( inline_atoms( value.content, overlay( style, { .strong = true } ) ), width, true ); } else if constexpr ( std::is_same_v || std::is_same_v ) { const Text& leaf = [&]() -> const Text& { if constexpr ( std::is_same_v ) { return value.content; } else { return value.description; } }(); const TextSpan span { &leaf, { 0, leaf.utf8.size() } }; std::vector atoms; constexpr bool preformatted = std::is_same_v; text_run( atoms, std::span( &span, 1 ), leaf.utf8, preformatted, overlay( style, { .code = preformatted } ) ); auto box = flow( atoms, width, !preformatted ); if constexpr ( preformatted ) { horizontal( box, block.id, width ); } return box; } else if constexpr ( std::is_same_v ) { return list_box( value, width, style ); } else if constexpr ( std::is_same_v ) { return table_box( value, block.id, width, style ); } else if constexpr ( std::is_same_v ) { std::vector atoms; control_atoms( atoms, value.control, style ); return flow( atoms, width, true ); } else { LayoutData box; box.extent = width; for ( std::int64_t column = 0; column < width; ++column ) { box.glyphs.push_back( { .position = { 0, column }, .columns = 1, .text = L"-", .style = { .subdued = true }, .clip_right = width, .decoration = true } ); } return box; } }, block.content ); const bool has_text = std::ranges::any_of( result.glyphs, []( const auto& glyph ) { return !glyph.decoration; } ); auto before = anchor( ObjectPosition { block.id, BoundarySide::Before }, {}, width ); auto after = anchor( ObjectPosition { block.id, BoundarySide::After }, { result.rows - 1, std::min( result.extent, width ) }, width ); before.boundary_only = has_text; after.boundary_only = true; result.glyphs.push_back( std::move( before ) ); result.glyphs.push_back( std::move( after ) ); return result; } const DocumentData& document_; const TextService& text_; std::span scrolls_; std::map controls_ {}; }; LayoutPoint end_point( const LayoutGlyph& glyph ) { // Object decorations and empty fields do not create a second text boundary. if ( glyph.source.start == glyph.source.end ) { return glyph.position; } if ( glyph.line_break ) { return { glyph.position.row + 1, glyph.line_start }; } return { glyph.position.row, glyph.position.column + glyph.columns }; } } // namespace DocumentLayout::DocumentLayout( const Document& document, const TextService& text, int columns, std::span scrolls ) : columns_( std::max( 1, columns ) ) , data_( LayoutBuilder( document.data(), text, scrolls ).blocks( document.data().blocks, columns_ ) ) { // An empty document still has a location for its cursor/viewport anchor. if ( data_.glyphs.empty() ) { data_.glyphs.push_back( anchor( DocumentStart {}, {}, columns_ ) ); } for ( std::size_t index = 0; index < data_.glyphs.size(); ++index ) { const auto& glyph = data_.glyphs[index]; if ( glyph.decoration || glyph.boundary_only ) { continue; } // Wrapped object borders and hard-break anchors can repeat a logical point. // Keep one reading stop, preferring actual text over its zero-width anchor. if ( !stops_.empty() && data_.glyphs[stops_.back()].source.start == glyph.source.start ) { if ( data_.glyphs[stops_.back()].columns == 0 ) { stops_.back() = index; } } else { stops_.push_back( index ); } } std::set omitted; for ( const auto& control : document.data().controls ) { if ( control.disabled ) { omitted.insert( control.id ); } } for ( std::size_t index = 0; index < stops_.size(); ++index ) { const auto& target = data_.glyphs[stops_[index]].target; // Skip ordinary text and disabled/previously encountered objects. A link's // later fragments never create extra Tab stops, including across blocks. if ( target && omitted.insert( *target ).second ) { focus_.push_back( { *target, index } ); } } } int DocumentLayout::columns() const { return columns_; } std::size_t DocumentLayout::rows() const { return data_.rows; } std::optional DocumentLayout::locate( const DisplayPosition& position ) const { if ( std::holds_alternative( position.point ) ) { return data_.glyphs[stops_.front()].position; } std::optional fallback; for ( const auto& glyph : data_.glyphs ) { if ( glyph.decoration ) { continue; } if ( position.point == glyph.source.start ) { if ( position.affinity == WrapAffinity::NextLine ) { return glyph.position; } fallback = glyph.position; } if ( position.point == glyph.source.end ) { if ( position.affinity == WrapAffinity::PreviousLine ) { return end_point( glyph ); } fallback = end_point( glyph ); } } return fallback; } std::optional DocumentLayout::hit( LayoutPoint point ) const { // Blank space below the document means its last reading position, independent // of the mouse column (especially when the final block is a table). if ( point.row >= data_.rows ) { const auto& glyph = data_.glyphs[stops_.back()]; return LayoutHit { last(), glyph.source, glyph.target }; } const LayoutGlyph* nearest = nullptr; auto distance = std::tuple { std::numeric_limits::max(), std::numeric_limits::max(), true }; for ( const auto& glyph : data_.glyphs ) { // Decorations and partially clipped wide glyphs have no independent text hit. if ( glyph.decoration || glyph.boundary_only ) { continue; } const auto left = std::max( glyph.position.column, glyph.clip_left ); const auto edge = std::min( glyph.position.column + glyph.columns, glyph.clip_right ); // Tab blanks may be clipped individually; a wide glyph remains indivisible. if ( ( glyph.fill && left >= edge ) || ( !glyph.fill && ( glyph.position.column < glyph.clip_left || glyph.position.column + glyph.columns > glyph.clip_right ) ) ) { continue; } const auto row_distance = glyph.position.row > point.row ? glyph.position.row - point.row : point.row - glyph.position.row; const auto right = std::max( left, edge - 1 ); const auto column_distance = std::max( { left - point.column, point.column - right, std::int64_t { 0 } } ); const auto candidate = std::tuple { row_distance, column_distance, glyph.columns == 0 }; if ( candidate < distance ) { nearest = &glyph; distance = candidate; } } if ( !nearest ) { return std::nullopt; } const bool after = !nearest->line_break && point.column >= nearest->position.column + nearest->columns; return LayoutHit { .position = { after ? nearest->source.end : nearest->source.start, after ? WrapAffinity::PreviousLine : WrapAffinity::NextLine }, .unit = nearest->source, .target = nearest->target }; } DisplayPosition DocumentLayout::stop( std::size_t index ) const { return { data_.glyphs[stops_[index]].source.start, WrapAffinity::NextLine }; } DisplayPosition DocumentLayout::first() const { return stop( 0 ); } DisplayPosition DocumentLayout::last() const { return stop( stops_.size() - 1 ); } std::size_t DocumentLayout::nearest_stop( LayoutPoint point ) const { std::size_t nearest = 0; auto distance = std::pair { std::numeric_limits::max(), std::numeric_limits::max() }; for ( std::size_t index = 0; index < stops_.size(); ++index ) { const auto& glyph = data_.glyphs[stops_[index]]; const auto row = glyph.position.row; const auto right = glyph.position.column + std::max( 1, glyph.columns ) - 1; const auto candidate = std::pair { row > point.row ? row - point.row : point.row - row, std::max( { glyph.position.column - point.column, point.column - right, std::int64_t { 0 } } ) }; if ( candidate < distance ) { nearest = index; distance = candidate; } } return nearest; } DocumentLayout::ReadingPosition DocumentLayout::reading_position( const DisplayPosition& position ) const { std::optional endpoint; for ( std::size_t index = 0; index < stops_.size(); ++index ) { const auto& glyph = data_.glyphs[stops_[index]]; if ( glyph.source.start == position.point ) { return { index, position.affinity == WrapAffinity::NextLine }; } if ( glyph.source.end == position.point ) { endpoint = index + 1; } } if ( endpoint ) { return { *endpoint, false }; } // Structural block anchors have coordinates but are not extra character stops. return { nearest_stop( locate( position ).value() ), true }; } DisplayPosition DocumentLayout::adjacent( const DisplayPosition& position, int direction ) const { const auto current = reading_position( position ); if ( direction < 0 ) { return stop( current.index == 0 ? 0 : current.index - 1 ); } return stop( std::min( stops_.size() - 1, current.index + static_cast( current.at_start ) ) ); } DisplayPosition DocumentLayout::word( const DisplayPosition& position, WordMotion motion ) const { const auto current = reading_position( position ); if ( motion == WordMotion::Previous ) { for ( auto index = current.index; index > 0; ) { --index; if ( data_.glyphs[stops_[index]].word_start ) { return stop( index ); } } return first(); } auto begin = current.index; if ( current.at_start && ( motion == WordMotion::Next || data_.glyphs[stops_[begin]].word_end ) ) { ++begin; } for ( auto index = begin; index < stops_.size(); ++index ) { const auto& glyph = data_.glyphs[stops_[index]]; if ( motion == WordMotion::Next ? glyph.word_start : glyph.word_end ) { return stop( index ); } } return last(); } DisplayPosition DocumentLayout::on_row( LayoutPoint point ) const { return stop( nearest_stop( point ) ); } DisplayPosition DocumentLayout::line_edge( const DisplayPosition& position, bool end ) const { const auto row = locate( position ).value().row; auto chosen = nearest_stop( { row, 0 } ); for ( std::size_t index = 0; index < stops_.size(); ++index ) { const auto& glyph = data_.glyphs[stops_[index]]; const auto column = data_.glyphs[stops_[chosen]].position.column; if ( glyph.position.row == row && ( end ? glyph.position.column > column : glyph.position.column < column ) ) { chosen = index; } } return stop( chosen ); } const LayoutGlyph& DocumentLayout::cursor_glyph( const DisplayPosition& position ) const { const auto current = reading_position( position ); const auto index = current.at_start || current.index == 0 ? current.index : current.index - 1; return data_.glyphs[stops_[std::min( index, stops_.size() - 1 )]]; } std::optional DocumentLayout::target( const DisplayPosition& position ) const { return cursor_glyph( position ).target; } std::optional DocumentLayout::focus( const DisplayPosition& position, bool backward ) const { if ( focus_.empty() ) { return std::nullopt; } const auto object = target( position ); const auto current = std::ranges::find_if( focus_, [&object]( const auto& entry ) { return object == entry.target; } ); auto origin = reading_position( position ); // Any fragment of an object uses that object's single place in the focus order. // Plain text and disabled controls instead start from the current reading stop. if ( current != focus_.end() ) { origin = { current->stop, true }; } if ( backward ) { const auto previous = std::find_if( focus_.rbegin(), focus_.rend(), [&origin]( const auto& entry ) { return entry.stop < origin.index; } ); const bool wrapped = previous == focus_.rend(); return FocusStep { stop( wrapped ? focus_.back().stop : previous->stop ), wrapped }; } const auto next = std::ranges::find_if( focus_, [&origin]( const auto& entry ) { // A line-end insertion boundary is before the next stop, not on its glyph. return entry.stop > origin.index || ( !origin.at_start && entry.stop == origin.index ); } ); const bool wrapped = next == focus_.end(); return FocusStep { stop( wrapped ? focus_.front().stop : next->stop ), wrapped }; } std::optional DocumentLayout::cursor_cell( const DisplayPosition& position ) const { const auto& glyph = cursor_glyph( position ); auto point = locate( position ).value(); if ( glyph.fill ) { // Any visible part of an expanded tab represents the same logical unit. const auto left = std::max( glyph.position.column, glyph.clip_left ); const auto right = std::min( glyph.position.column + glyph.columns, glyph.clip_right ); if ( left >= right ) { return std::nullopt; } point.column = std::clamp( point.column, left, right - 1 ); return point; } if ( point.column == glyph.clip_right && point.row == glyph.position.row ) { // An insertion boundary or LF immediately after a full row has no cell. // Use the preceding complete glyph; do not reveal a cursor further offscreen. auto column = glyph.clip_right - 1; for ( const auto index : stops_ ) { const auto& candidate = data_.glyphs[index]; if ( candidate.position.row == point.row && candidate.columns > 0 && candidate.position.column >= glyph.clip_left && candidate.position.column + candidate.columns == glyph.clip_right ) { column = candidate.position.column; } } point.column = column; } // In a one-cell viewport a wide glyph is clipped entirely. Its cursor occupies // the blank fallback cell, never paints half of the character. const auto width = point.column == glyph.position.column ? std::min( std::max( 1, glyph.columns ), glyph.clip_right - glyph.clip_left ) : 1; if ( point.column < glyph.clip_left || point.column >= glyph.clip_right || point.column + width > glyph.clip_right ) { return std::nullopt; } return point; } std::vector DocumentLayout::reveal( const DisplayPosition& position ) const { const auto& glyph = cursor_glyph( position ); auto column = glyph.position.column; const auto width = std::max( 1, glyph.columns ); std::vector result; for ( const auto& window : glyph.scrolls ) { const auto delta = column < window.left ? column - window.left : std::max( 0, column + std::min( width, window.width ) - window.left - window.width ); const auto offset = std::clamp( window.offset + delta, std::int64_t { 0 }, window.maximum ); if ( offset != window.offset ) { result.push_back( { window.block, static_cast( offset ) } ); column -= offset - window.offset; } } return result; } std::vector DocumentLayout::pan( const DisplayPosition& position, int direction ) const { for ( const auto& window : cursor_glyph( position ).scrolls ) { if ( window.maximum > 0 ) { const auto offset = std::clamp( window.offset + direction, std::int64_t { 0 }, window.maximum ); if ( offset == window.offset ) { return {}; } return { { window.block, static_cast( offset ) } }; } } return {}; } DocumentRange DocumentLayout::unit( const DisplayPosition& position ) const { const auto reading = reading_position( position ); if ( !reading.at_start ) { return { position.point, position.point }; } return data_.glyphs[stops_[reading.index]].source; } void DocumentLayout::append_frame( Frame& frame, std::size_t first_row, CellPoint origin, ScreenSize viewport, const DocumentText& content, std::optional selection, const DisplayPosition& cursor, std::optional focus ) const { const auto selected = selection ? content.offsets( *selection ) : ByteRange {}; const auto caret = cursor_cell( cursor ); for ( const auto& glyph : data_.glyphs ) { if ( glyph.position.row < first_row || glyph.position.row - first_row >= static_cast( std::max( 0, viewport.rows ) ) ) { continue; } bool highlighted = false; if ( selection && !glyph.decoration && !glyph.boundary_only ) { const auto bytes = content.offsets( glyph.source ); highlighted = bytes.start < selected.end && selected.start < bytes.end; // A control border has an object position but contributes no decoration to copy. if ( bytes.start == bytes.end && std::holds_alternative( glyph.source.start ) ) { highlighted = selected.start <= bytes.start && bytes.start < selected.end; } } const bool newline = glyph.line_break && highlighted; if ( glyph.text.empty() && !newline ) { continue; } auto style = glyph.style; style.strong = style.strong || ( focus && glyph.target == focus ); const auto left = std::max( 0, glyph.clip_left ); const auto right = std::min( viewport.columns, glyph.clip_right ); const auto row = origin.row + static_cast( glyph.position.row - first_row ); const bool at_cursor = caret && caret->row == glyph.position.row && caret->column >= glyph.position.column && caret->column < glyph.position.column + std::max( 1, glyph.columns ); if ( glyph.fill ) { // Expanded tabs are many blank cells but keep a single source range/hit unit. for ( auto column = std::max( left, glyph.position.column ); column < std::min( right, glyph.position.column + glyph.columns ); ++column ) { frame.glyphs.push_back( { .position = { row, origin.column + static_cast( column ) }, .text = L" ", .style = style, .selected = highlighted, .cursor = at_cursor && column == caret->column } ); } } else if ( glyph.position.column >= left && glyph.position.column + ( newline ? 1 : glyph.columns ) <= right ) { frame.glyphs.push_back( { .position = { row, origin.column + static_cast( glyph.position.column ) }, .width = newline ? 1 : glyph.columns, .text = newline ? L" " : glyph.text, .style = style, .selected = highlighted, .cursor = at_cursor } ); } } } } // namespace ricochet::tui::detail