diff options
| author | Richard Qin <richard.qin.x@gmail.com> | 2026-10-26 20:37:52 +0800 |
|---|---|---|
| committer | Richard Qin <richard.qin.x@gmail.com> | 2026-10-26 20:37:52 +0800 |
| commit | cb1f404cc6b8b9dce135efd26fa32dc0d6fd79c4 (patch) | |
| tree | 70fea121bc8c6fac3287c7a96fb8f5f86ec6f2e7 /src/tui/layout.cc | |
| parent | 795126a90d651ede075e9a94741dae4841e4de62 (diff) | |
| download | rwebbrowser-cb1f404cc6b8b9dce135efd26fa32dc0d6fd79c4.tar.gz rwebbrowser-cb1f404cc6b8b9dce135efd26fa32dc0d6fd79c4.tar.xz | |
feat(tui): add document navigation and text selection
Diffstat (limited to 'src/tui/layout.cc')
| -rw-r--r-- | src/tui/layout.cc | 272 |
1 files changed, 261 insertions, 11 deletions
diff --git a/src/tui/layout.cc b/src/tui/layout.cc index fa6eaa6..e12c8d0 100644 --- a/src/tui/layout.cc +++ b/src/tui/layout.cc @@ -84,6 +84,9 @@ void append_box( LayoutData& destination, LayoutData source, std::size_t row, st 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 ) ); @@ -276,7 +279,9 @@ private: .target = atom.target, .clip_right = wrap ? width : std::numeric_limits<std::int64_t>::max(), .decoration = atom.decoration, - .fill = tab } ); + .fill = tab, + .word_start = atom.glyph.word_start, + .word_end = atom.glyph.word_end } ); column += cells; } result.extent = std::max( result.extent, column ); @@ -309,6 +314,10 @@ private: const auto maximum = std::max<std::int64_t>( 0, box.extent - width ); const auto shift = static_cast<std::int64_t>( std::min<std::uint64_t>( 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<std::int64_t>( 0, glyph.clip_left - shift ); @@ -504,7 +513,7 @@ private: { result.rows - 1, std::min( result.extent, width ) }, width ); before.boundary_only = has_text; - after.boundary_only = has_text; + after.boundary_only = true; result.glyphs.push_back( std::move( before ) ); result.glyphs.push_back( std::move( after ) ); return result; @@ -541,6 +550,21 @@ DocumentLayout::DocumentLayout( const Document& document, 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 ); + } + } } int DocumentLayout::columns() const @@ -551,7 +575,7 @@ std::size_t DocumentLayout::rows() const std::optional<LayoutPoint> DocumentLayout::locate( const DisplayPosition& position ) const { if ( std::holds_alternative<DocumentStart>( position.point ) ) { - return LayoutPoint {}; + return data_.glyphs[stops_.front()].position; } std::optional<LayoutPoint> fallback; for ( const auto& glyph : data_.glyphs ) { @@ -576,6 +600,12 @@ std::optional<LayoutPoint> DocumentLayout::locate( const DisplayPosition& positi std::optional<LayoutHit> 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<std::size_t>::max(), std::numeric_limits<std::int64_t>::max(), true }; @@ -613,32 +643,252 @@ std::optional<LayoutHit> DocumentLayout::hit( LayoutPoint point ) const .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<std::size_t>::max(), std::numeric_limits<std::int64_t>::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<std::size_t> 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<std::size_t>( 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<HitTarget> DocumentLayout::target( const DisplayPosition& position ) const +{ return cursor_glyph( position ).target; } + +std::optional<LayoutPoint> 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::int64_t>( 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<HorizontalScroll> 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<HorizontalScroll> result; + for ( const auto& window : glyph.scrolls ) { + const auto delta + = column < window.left + ? column - window.left + : std::max<std::int64_t>( + 0, column + std::min<std::int64_t>( 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<std::size_t>( offset ) } ); + column -= offset - window.offset; + } + } + return result; +} + +std::vector<HorizontalScroll> 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<std::size_t>( 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 + ScreenSize viewport, + const DocumentText& content, + std::optional<DocumentRange> selection, + const DisplayPosition& cursor, + std::optional<HitTarget> focus ) const { + const auto selected = selection ? content.offsets( *selection ) : ByteRange {}; + const auto caret = cursor_cell( cursor ); for ( const auto& glyph : data_.glyphs ) { - if ( glyph.text.empty() || glyph.position.row < first_row + if ( glyph.position.row < first_row || glyph.position.row - first_row >= static_cast<std::size_t>( 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<ObjectPosition>( 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<std::int64_t>( 0, glyph.clip_left ); const auto right = std::min<std::int64_t>( viewport.columns, glyph.clip_right ); const auto row = origin.row + static_cast<int>( 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<int>( column ) }, .text = L" ", .style = glyph.style } ); + frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( column ) }, + .text = L" ", + .style = style, + .selected = highlighted, + .cursor = at_cursor && column == caret->column } ); } - } else if ( glyph.position.column >= left && glyph.position.column + glyph.columns <= right ) { + } else if ( glyph.position.column >= left + && glyph.position.column + ( newline ? 1 : glyph.columns ) <= right ) { frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( glyph.position.column ) }, - .width = glyph.columns, - .text = glyph.text, - .style = glyph.style } ); + .width = newline ? 1 : glyph.columns, + .text = newline ? L" " : glyph.text, + .style = style, + .selected = highlighted, + .cursor = at_cursor } ); } } } |
