From b84c87ef820976cc541586a09ec6f845726580f2 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 17 Oct 2026 03:54:06 +0800 Subject: feat(tui): add Unicode text service and document layout --- ricochet/src/tui/layout.cc | 677 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 677 insertions(+) create mode 100644 ricochet/src/tui/layout.cc (limited to 'ricochet/src/tui/layout.cc') diff --git a/ricochet/src/tui/layout.cc b/ricochet/src/tui/layout.cc new file mode 100644 index 0000000..5aebe17 --- /dev/null +++ b/ricochet/src/tui/layout.cc @@ -0,0 +1,677 @@ +/* + * 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 +#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; + 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 } ); + } + } + + std::string control_text( const Control& control ) const + { + return std::visit( + [this, &control]( 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 ) { + 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 if constexpr ( std::is_same_v ) { + return control.label; + } else { + return value.description; + } + }, + control.content ); + } + + 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_text( control ); + 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 } ); + 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 ) { + 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 = has_text; + 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_ ) ); + } +} + +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 LayoutPoint {}; + } + 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 +{ + 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 }; +} + +void DocumentLayout::append_frame( Frame& frame, + std::size_t first_row, + CellPoint origin, + ScreenSize viewport ) const +{ + for ( const auto& glyph : data_.glyphs ) { + if ( glyph.text.empty() || glyph.position.row < first_row + || glyph.position.row - first_row >= static_cast( std::max( 0, viewport.rows ) ) ) { + continue; + } + 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 ); + 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 = glyph.style } ); + } + } else if ( glyph.position.column >= left && glyph.position.column + glyph.columns <= right ) { + frame.glyphs.push_back( { .position = { row, origin.column + static_cast( glyph.position.column ) }, + .width = glyph.columns, + .text = glyph.text, + .style = glyph.style } ); + } + } +} + +} // namespace ricochet::tui::detail -- cgit v1.2.3 From 2063bf09fa8828c602ca1726a1188f0c95ae8adb Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 18 Oct 2026 00:45:42 +0800 Subject: feat(tui): add structured selection text and clipboard messages --- ricochet/include/ricochet/tui/messages.hh | 39 ++++- ricochet/include/ricochet/tui/view.hh | 3 + ricochet/src/tui/CMakeLists.txt | 2 +- ricochet/src/tui/document_text.cc | 231 ++++++++++++++++++++++++++++++ ricochet/src/tui/document_text.hh | 66 +++++++++ ricochet/src/tui/layout.cc | 35 +---- 6 files changed, 340 insertions(+), 36 deletions(-) create mode 100644 ricochet/src/tui/document_text.cc create mode 100644 ricochet/src/tui/document_text.hh (limited to 'ricochet/src/tui/layout.cc') diff --git a/ricochet/include/ricochet/tui/messages.hh b/ricochet/include/ricochet/tui/messages.hh index e7dc417..9d51fa3 100644 --- a/ricochet/include/ricochet/tui/messages.hh +++ b/ricochet/include/ricochet/tui/messages.hh @@ -139,12 +139,30 @@ struct ExitDecision std::optional explanation {}; }; +enum class CopyStatus : std::uint8_t +{ + Confirmed, + Dispatched, + Unavailable, + Failed +}; + +// The host's injected clipboard provider completes a CopySelection request once. +// Dispatched must not be used as confirmation that a terminal accepted the text. +struct CopyResult +{ + RequestId request {}; + CopyStatus status { CopyStatus::Unavailable }; + std::string summary {}; +}; + using HostUpdate = std::variant; @@ -220,6 +238,16 @@ struct ExportSelection std::string text {}; }; +struct CopySelection +{ + RequestId request {}; + DocumentScope source {}; + DraftRevision draft_revision {}; + // Frozen before dispatch; later cursor/selection changes cannot change this copy. + // Password content is already masked. Clipboard providers never receive drafts. + std::string text {}; +}; + struct RequestExit { RequestId request {}; @@ -227,7 +255,14 @@ struct RequestExit std::vector views {}; }; -using UserAction - = std::variant; +using UserAction = std::variant; } // namespace ricochet::tui diff --git a/ricochet/include/ricochet/tui/view.hh b/ricochet/include/ricochet/tui/view.hh index d259cf3..f5f33d4 100644 --- a/ricochet/include/ricochet/tui/view.hh +++ b/ricochet/include/ricochet/tui/view.hh @@ -39,6 +39,9 @@ struct SelectionSnapshot // The normalized half-open range also covers any controls between its endpoints. DocumentRange range {}; SelectionDirection direction { SelectionDirection::Forward }; + // Inclusive endpoint units retain the anchor through reversal and history restore. + DocumentRange anchor {}; + DocumentRange active {}; }; struct HorizontalScroll diff --git a/ricochet/src/tui/CMakeLists.txt b/ricochet/src/tui/CMakeLists.txt index 8b49cf1..ed7b067 100644 --- a/ricochet/src/tui/CMakeLists.txt +++ b/ricochet/src/tui/CMakeLists.txt @@ -4,7 +4,7 @@ find_package(Threads REQUIRED) # ICU 76.1 supplies UTF-8 grapheme/line boundaries and Unicode properties. pkg_check_modules(ICU REQUIRED IMPORTED_TARGET icu-uc>=76.1 icu-i18n>=76.1) -add_library(ricochet_tui STATIC document.cc mailbox.cc screen.cc session.cc text.cc layout.cc) +add_library(ricochet_tui STATIC document_text.cc document.cc mailbox.cc screen.cc session.cc text.cc layout.cc) target_include_directories(ricochet_tui PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(ricochet_tui PUBLIC cxx_std_23) target_link_libraries(ricochet_tui PRIVATE PkgConfig::NCURSESW PkgConfig::ICU Threads::Threads) diff --git a/ricochet/src/tui/document_text.cc b/ricochet/src/tui/document_text.cc new file mode 100644 index 0000000..dd0c64d --- /dev/null +++ b/ricochet/src/tui/document_text.cc @@ -0,0 +1,231 @@ +/* + * 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 diff --git a/ricochet/src/tui/document_text.hh b/ricochet/src/tui/document_text.hh new file mode 100644 index 0000000..d490c9d --- /dev/null +++ b/ricochet/src/tui/document_text.hh @@ -0,0 +1,66 @@ +/* + * 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 . + */ + +#pragma once + +#include "ricochet/tui/document.hh" +#include "ricochet/tui/view.hh" +#include "text.hh" + +#include +#include +#include + +namespace ricochet::tui::detail { + +// Shared by layout and extraction: password positions address only this mask. +std::string control_display_text( const Control& control, const TextService& text ); + +// Logical reading-order text, independent of wrapping, clipping and decorations. +// Owned together with one immutable document and its revision-zero control values. +class DocumentText +{ +public: + DocumentText( const Document& document, const TextService& text ); + ByteRange offsets( const DocumentRange& range ) const; + SelectionSnapshot select( const DocumentRange& anchor, const DocumentRange& active ) const; + std::string extract( const DocumentRange& range ) const; + +private: + struct ControlSpan + { + ByteRange object {}; + ByteRange display {}; + }; + std::size_t offset( const DocumentPoint& point ) const; + void append_blocks( std::span blocks, std::size_t depth ); + void append_block( const Block& block, std::size_t depth ); + void append_inline( std::span content ); + void append_text( const Text& text ); + void append_control( ControlId id ); + + std::string text_ {}; + std::map blocks_ {}; + std::map controls_ {}; + std::map definitions_ {}; + // Prefix ends coincide with item starts; selecting that start includes its marker. + std::vector prefixes_ {}; + const TextService& service_; +}; + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/layout.cc b/ricochet/src/tui/layout.cc index 5aebe17..fa6eaa6 100644 --- a/ricochet/src/tui/layout.cc +++ b/ricochet/src/tui/layout.cc @@ -17,6 +17,7 @@ */ #include "layout.hh" +#include "document_text.hh" #include #include @@ -161,38 +162,6 @@ private: } } - std::string control_text( const Control& control ) const - { - return std::visit( - [this, &control]( 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 ) { - 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 if constexpr ( std::is_same_v ) { - return control.label; - } else { - return value.description; - } - }, - control.content ); - } - void control_atoms( std::vector& atoms, ControlId id, TextStyle base ) const { // A missing ID is a malformed document reference; map::at reports it directly. @@ -217,7 +186,7 @@ private: .style = style, .target = id } ); } - const auto display = control_text( control ); + 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, -- cgit v1.2.3 From 2e6dc81391c5dbb787cfd793ad5823904b3b9c94 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 26 Oct 2026 20:37:52 +0800 Subject: feat(tui): add document navigation and text selection --- ricochet/include/ricochet/tui/view.hh | 2 + ricochet/src/tui/CMakeLists.txt | 2 +- ricochet/src/tui/document_view.cc | 377 +++++++++++++++++++++++++ ricochet/src/tui/document_view.hh | 107 +++++++ ricochet/src/tui/layout.cc | 272 +++++++++++++++++- ricochet/src/tui/layout.hh | 53 +++- ricochet/src/tui/paint.hh | 2 + ricochet/src/tui/screen.cc | 3 +- ricochet/src/tui/session.cc | 515 ++++++++++++++++++++++++++++++---- ricochet/src/tui/text.cc | 3 + ricochet/src/tui/text.hh | 2 + 11 files changed, 1262 insertions(+), 76 deletions(-) create mode 100644 ricochet/src/tui/document_view.cc create mode 100644 ricochet/src/tui/document_view.hh (limited to 'ricochet/src/tui/layout.cc') diff --git a/ricochet/include/ricochet/tui/view.hh b/ricochet/include/ricochet/tui/view.hh index f5f33d4..cc1d0fd 100644 --- a/ricochet/include/ricochet/tui/view.hh +++ b/ricochet/include/ricochet/tui/view.hh @@ -64,6 +64,8 @@ struct ViewSnapshot DraftRevision draft_revision {}; DisplayPosition cursor {}; DisplayPosition viewport_anchor {}; + // Relative visual row for blank table space with no text at the exact top. + std::int64_t viewport_row_offset { 0 }; std::optional selection {}; std::vector horizontal_scroll {}; SearchSnapshot search {}; diff --git a/ricochet/src/tui/CMakeLists.txt b/ricochet/src/tui/CMakeLists.txt index ed7b067..98dcee2 100644 --- a/ricochet/src/tui/CMakeLists.txt +++ b/ricochet/src/tui/CMakeLists.txt @@ -4,7 +4,7 @@ find_package(Threads REQUIRED) # ICU 76.1 supplies UTF-8 grapheme/line boundaries and Unicode properties. pkg_check_modules(ICU REQUIRED IMPORTED_TARGET icu-uc>=76.1 icu-i18n>=76.1) -add_library(ricochet_tui STATIC document_text.cc document.cc mailbox.cc screen.cc session.cc text.cc layout.cc) +add_library(ricochet_tui STATIC document_text.cc document.cc document_view.cc mailbox.cc screen.cc session.cc text.cc layout.cc) target_include_directories(ricochet_tui PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(ricochet_tui PUBLIC cxx_std_23) target_link_libraries(ricochet_tui PRIVATE PkgConfig::NCURSESW PkgConfig::ICU Threads::Threads) diff --git a/ricochet/src/tui/document_view.cc b/ricochet/src/tui/document_view.cc new file mode 100644 index 0000000..ee74e53 --- /dev/null +++ b/ricochet/src/tui/document_view.cc @@ -0,0 +1,377 @@ +/* + * 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_view.hh" + +#include +#include + +namespace ricochet::tui::detail { +namespace { + +ScreenSize body_size( ScreenSize viewport ) +{ return { std::max( 1, viewport.rows ), std::max( 1, viewport.columns ) }; } + +std::size_t shifted_row( std::size_t row, std::int64_t distance, std::size_t last ) +{ + // Clamp the displacement before addition; restored relative offsets can be signed. + const auto origin = static_cast( row ); + return static_cast( origin + + std::clamp( distance, -origin, static_cast( last ) - origin ) ); +} + +} // namespace + +DocumentView::DocumentView( Document document, ViewSnapshot snapshot, const TextService& text, ScreenSize viewport ) + : document_( std::move( document ) ) + , snapshot_( std::move( snapshot ) ) + , text_( text ) + , content_( document_, text_ ) + , viewport_( body_size( viewport ) ) + , layout_( document_, text_, viewport_.columns, snapshot_.horizontal_scroll ) +{ + // A history snapshot can name a removed boundary. The new document starts at + // its first reading unit; it never manufactures a byte offset into that text. + if ( std::holds_alternative( snapshot_.cursor.point ) || !layout_.locate( snapshot_.cursor ) ) { + snapshot_.cursor = layout_.first(); + } + desired_column_ = layout_.locate( snapshot_.cursor ).value().column; + restore_viewport(); + if ( snapshot_.selection ) { + const auto& selection = *snapshot_.selection; + // History endpoints must still be layout boundaries in this exact document/draft. + if ( layout_.locate( { selection.anchor.start } ) && layout_.locate( { selection.anchor.end } ) + && layout_.locate( { selection.active.start } ) && layout_.locate( { selection.active.end } ) ) { + snapshot_.selection = content_.select( selection.anchor, selection.active ); + } else { + snapshot_.selection.reset(); + } + } +} + +const Document& DocumentView::document() const +{ return document_; } +const ViewSnapshot& DocumentView::snapshot() const +{ return snapshot_; } + +std::size_t DocumentView::last_top_row() const +{ return layout_.rows() - std::min( layout_.rows(), static_cast( viewport_.rows ) ); } + +void DocumentView::set_top_row( std::size_t row ) +{ + top_row_ = std::min( row, last_top_row() ); + snapshot_.viewport_anchor = layout_.on_row( { top_row_, 0 } ); + const auto anchor = layout_.locate( snapshot_.viewport_anchor ).value(); + snapshot_.viewport_row_offset = static_cast( top_row_ ) - static_cast( anchor.row ); +} + +void DocumentView::restore_viewport() +{ + auto anchor = layout_.locate( snapshot_.viewport_anchor ); + if ( !anchor ) { + snapshot_.viewport_anchor = layout_.first(); + snapshot_.viewport_row_offset = 0; + anchor = layout_.locate( snapshot_.viewport_anchor ); + } + set_top_row( shifted_row( anchor->row, snapshot_.viewport_row_offset, last_top_row() ) ); +} + +void DocumentView::resize( ScreenSize viewport ) +{ + const auto next = body_size( viewport ); + if ( next == viewport_ ) { + return; + } + stop_drag(); + const bool reflow = next.columns != viewport_.columns; + viewport_ = next; + if ( reflow ) { + layout_ = DocumentLayout( document_, text_, viewport_.columns, snapshot_.horizontal_scroll ); + desired_column_ = layout_.locate( snapshot_.cursor ).value().column; + } + restore_viewport(); + // A wheel/pan action deliberately allows the cursor to stay outside the view. + if ( follow_cursor_ ) { + reveal_cursor(); + } +} + +void DocumentView::apply_scrolls( std::span changes ) +{ + if ( changes.empty() ) { + return; + } + for ( const auto& change : changes ) { + const auto found = std::ranges::find( snapshot_.horizontal_scroll, change.block, &HorizontalScroll::block ); + if ( found == snapshot_.horizontal_scroll.end() ) { + snapshot_.horizontal_scroll.push_back( change ); + } else { + *found = change; + } + } + layout_ = DocumentLayout( document_, text_, viewport_.columns, snapshot_.horizontal_scroll ); + restore_viewport(); +} + +void DocumentView::reveal_cursor() +{ + apply_scrolls( layout_.reveal( snapshot_.cursor ) ); + const auto point = layout_.locate( snapshot_.cursor ).value(); + if ( point.row < top_row_ ) { + set_top_row( point.row ); + } else if ( point.row - top_row_ >= static_cast( viewport_.rows ) ) { + set_top_row( point.row - static_cast( viewport_.rows - 1 ) ); + } +} + +void DocumentView::finish_move( bool preserve_column ) +{ + follow_cursor_ = true; + extend_selection(); + reveal_cursor(); + if ( !preserve_column ) { + desired_column_ = layout_.locate( snapshot_.cursor ).value().column; + } +} + +void DocumentView::move_rows( std::int64_t distance, bool page ) +{ + const auto origin = layout_.locate( snapshot_.cursor ).value(); + const auto row = shifted_row( origin.row, distance, layout_.rows() - 1 ); + snapshot_.cursor = layout_.on_row( { row, desired_column_ } ); + if ( page ) { + // Shift both by the same amount before revealing, retaining screen-relative + // cursor position wherever document boundaries permit it. + set_top_row( shifted_row( top_row_, distance, last_top_row() ) ); + } + finish_move( true ); +} + +void DocumentView::move( ViewMotion motion ) +{ + stop_drag(); + switch ( motion ) { + case ViewMotion::Left: + snapshot_.cursor = layout_.adjacent( snapshot_.cursor, -1 ); + break; + case ViewMotion::Right: + snapshot_.cursor = layout_.adjacent( snapshot_.cursor, 1 ); + break; + case ViewMotion::NextWord: + snapshot_.cursor = layout_.word( snapshot_.cursor, WordMotion::Next ); + break; + case ViewMotion::PreviousWord: + snapshot_.cursor = layout_.word( snapshot_.cursor, WordMotion::Previous ); + break; + case ViewMotion::WordEnd: + snapshot_.cursor = layout_.word( snapshot_.cursor, WordMotion::End ); + break; + case ViewMotion::LineStart: + snapshot_.cursor = layout_.line_edge( snapshot_.cursor, false ); + break; + case ViewMotion::LineEnd: + snapshot_.cursor = layout_.line_edge( snapshot_.cursor, true ); + break; + case ViewMotion::DocumentStart: + snapshot_.cursor = layout_.first(); + break; + case ViewMotion::DocumentEnd: + snapshot_.cursor = layout_.last(); + break; + case ViewMotion::Up: + move_rows( -1, false ); + return; + case ViewMotion::Down: + move_rows( 1, false ); + return; + case ViewMotion::PageUp: + move_rows( -std::max( 1, viewport_.rows - 1 ), true ); + return; + case ViewMotion::PageDown: + move_rows( std::max( 1, viewport_.rows - 1 ), true ); + return; + case ViewMotion::HalfPageUp: + move_rows( -std::max( 1, viewport_.rows / 2 ), true ); + return; + case ViewMotion::HalfPageDown: + move_rows( std::max( 1, viewport_.rows / 2 ), true ); + return; + case ViewMotion::PanLeft: + case ViewMotion::PanRight: { + const auto changes = layout_.pan( snapshot_.cursor, motion == ViewMotion::PanLeft ? -1 : 1 ); + if ( changes.empty() ) { + return; + } + const auto old_column = layout_.locate( snapshot_.cursor ).value().column; + apply_scrolls( changes ); + // Panning changes screen coordinates, not the desired logical column for + // the next vertical move (including one remembered across a short line). + desired_column_ += layout_.locate( snapshot_.cursor ).value().column - old_column; + follow_cursor_ = false; + return; + } + } + finish_move( false ); +} + +void DocumentView::begin_selection() +{ + stop_drag(); + const auto unit = layout_.unit( snapshot_.cursor ); + snapshot_.selection = content_.select( unit, unit ); +} + +void DocumentView::cancel_selection() +{ + stop_drag(); + snapshot_.selection.reset(); +} + +void DocumentView::extend_selection() +{ + if ( snapshot_.selection ) { + snapshot_.selection = content_.select( snapshot_.selection->anchor, layout_.unit( snapshot_.cursor ) ); + } +} + +std::optional DocumentView::selected_text() const +{ + if ( !snapshot_.selection ) { + return std::nullopt; + } + return content_.extract( snapshot_.selection->range ); +} + +void DocumentView::press( CellPoint point, std::chrono::steady_clock::time_point now ) +{ + const auto hit = layout_.hit( { top_row_ + static_cast( point.row ), point.column } ); + // A completely clipped wide glyph has no visible cell to hit. + if ( !hit ) { + return; + } + snapshot_.cursor = hit->position; + cancel_selection(); + desired_column_ = layout_.locate( snapshot_.cursor ).value().column; + follow_cursor_ = false; + drag_ = Drag { point, point, layout_.unit( snapshot_.cursor ), false, now + std::chrono::milliseconds( 100 ) }; +} + +void DocumentView::drag_to_pointer() +{ + const auto row = std::clamp( drag_->pointer.row, 0, viewport_.rows - 1 ); + const auto column = std::clamp( drag_->pointer.column, 0, viewport_.columns - 1 ); + const auto hit = layout_.hit( { top_row_ + static_cast( row ), column } ); + if ( !hit ) { + return; + } + snapshot_.cursor = hit->position; + snapshot_.selection = content_.select( drag_->anchor, layout_.unit( snapshot_.cursor ) ); + desired_column_ = layout_.locate( snapshot_.cursor ).value().column; + follow_cursor_ = false; +} + +void DocumentView::drag( CellPoint point ) +{ + drag_->pointer = point; + // One cell is the terminal's movement threshold; wide halves still share a unit. + drag_->selecting = drag_->selecting || point.row != drag_->press.row || point.column != drag_->press.column; + if ( drag_->selecting ) { + drag_to_pointer(); + } +} + +void DocumentView::release( CellPoint point ) +{ + drag( point ); + stop_drag(); +} + +void DocumentView::stop_drag() +{ drag_.reset(); } + +bool DocumentView::dragging() const +{ return drag_.has_value(); } + +bool DocumentView::tick( std::chrono::steady_clock::time_point now ) +{ + if ( !drag_ || !drag_->selecting || now < drag_->next_scroll ) { + return false; + } + drag_->next_scroll = now + std::chrono::milliseconds( 100 ); + // With a one-row body both edges coincide; leaving it above/below disambiguates. + const int direction = drag_->pointer.row < 0 ? -1 + : drag_->pointer.row >= viewport_.rows ? 1 + : viewport_.rows == 1 ? 0 + : drag_->pointer.row == 0 ? -1 + : drag_->pointer.row == viewport_.rows - 1 ? 1 + : 0; + const auto next = shifted_row( top_row_, direction, last_top_row() ); + if ( next == top_row_ ) { + return false; + } + set_top_row( next ); + drag_to_pointer(); + return true; +} + +void DocumentView::wheel( int steps ) +{ + set_top_row( shifted_row( top_row_, static_cast( steps ) * 3, last_top_row() ) ); + follow_cursor_ = false; + if ( drag_ && drag_->selecting ) { + drag_to_pointer(); + } +} + +bool DocumentView::locate( const DocumentPoint& point ) +{ + const DisplayPosition requested { point, WrapAffinity::NextLine }; + const auto location = layout_.locate( requested ); + // Host jumps can refer to absent anchors, removed leaves or non-grapheme offsets. + if ( !location ) { + return false; + } + snapshot_.cursor = requested; + if ( std::holds_alternative( point ) ) { + snapshot_.cursor = layout_.first(); + } else if ( const auto* object = std::get_if( &point ); + object && std::holds_alternative( object->object ) ) { + snapshot_.cursor = layout_.on_row( *location ); + } + cancel_selection(); + finish_move( false ); + return true; +} + +std::optional DocumentView::focused_target() const +{ return layout_.target( snapshot_.cursor ); } + +void DocumentView::draw( Frame& frame, CellPoint origin ) const +{ + const auto selection = snapshot_.selection ? std::optional( snapshot_.selection->range ) : std::nullopt; + layout_.append_frame( + frame, top_row_, origin, viewport_, content_, selection, snapshot_.cursor, focused_target() ); + const auto cursor = layout_.cursor_cell( snapshot_.cursor ); + if ( cursor && cursor->row >= top_row_ && cursor->row - top_row_ < static_cast( viewport_.rows ) + && cursor->column >= 0 && cursor->column < viewport_.columns ) { + frame.cursor = CellPoint { origin.row + static_cast( cursor->row - top_row_ ), + origin.column + static_cast( cursor->column ) }; + } +} + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/document_view.hh b/ricochet/src/tui/document_view.hh new file mode 100644 index 0000000..587f419 --- /dev/null +++ b/ricochet/src/tui/document_view.hh @@ -0,0 +1,107 @@ +/* + * 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 . + */ + +#pragma once + +#include "document_text.hh" +#include "layout.hh" + +#include + +namespace ricochet::tui::detail { + +enum class ViewMotion : std::uint8_t +{ + Left, + Right, + Up, + Down, + NextWord, + PreviousWord, + WordEnd, + LineStart, + LineEnd, + DocumentStart, + DocumentEnd, + PageUp, + PageDown, + HalfPageUp, + HalfPageDown, + PanLeft, + PanRight +}; + +// One tab's reading state. It owns the logical snapshot; screen rows are caches. +// Keyboard, mouse and host navigation all use the same layout/source mappings. +class DocumentView +{ +public: + DocumentView( Document document, ViewSnapshot snapshot, const TextService& text, ScreenSize viewport ); + + const Document& document() const; + const ViewSnapshot& snapshot() const; + void resize( ScreenSize viewport ); + void move( ViewMotion motion ); + void press( CellPoint point, std::chrono::steady_clock::time_point now ); + // The session routes these only while dragging() owns mouse capture. + void drag( CellPoint point ); + void release( CellPoint point ); + void stop_drag(); + bool dragging() const; + bool tick( std::chrono::steady_clock::time_point now ); + void begin_selection(); + void cancel_selection(); + std::optional selected_text() const; + void wheel( int steps ); + bool locate( const DocumentPoint& point ); + std::optional focused_target() const; + void draw( Frame& frame, CellPoint origin ) const; + +private: + std::size_t last_top_row() const; + void set_top_row( std::size_t row ); + void restore_viewport(); + void apply_scrolls( std::span changes ); + void reveal_cursor(); + void finish_move( bool preserve_column ); + void move_rows( std::int64_t distance, bool page ); + void extend_selection(); + void drag_to_pointer(); + + struct Drag + { + CellPoint press {}; + CellPoint pointer {}; + DocumentRange anchor {}; + bool selecting { false }; + std::chrono::steady_clock::time_point next_scroll {}; + }; + + Document document_; + ViewSnapshot snapshot_; + const TextService& text_; + DocumentText content_; + ScreenSize viewport_; + DocumentLayout layout_; + std::size_t top_row_ { 0 }; + std::int64_t desired_column_ { 0 }; + bool follow_cursor_ { false }; + std::optional drag_ {}; +}; + +} // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/layout.cc b/ricochet/src/tui/layout.cc index fa6eaa6..e12c8d0 100644 --- a/ricochet/src/tui/layout.cc +++ b/ricochet/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::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( 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 ); @@ -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 DocumentLayout::locate( const DisplayPosition& position ) const { if ( std::holds_alternative( position.point ) ) { - return LayoutPoint {}; + return data_.glyphs[stops_.front()].position; } std::optional fallback; for ( const auto& glyph : data_.glyphs ) { @@ -576,6 +600,12 @@ std::optional DocumentLayout::locate( const DisplayPosition& positi 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 }; @@ -613,32 +643,252 @@ std::optional 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::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::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 + 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.text.empty() || glyph.position.row < first_row + 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 = glyph.style } ); + 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 + 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( 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 } ); } } } diff --git a/ricochet/src/tui/layout.hh b/ricochet/src/tui/layout.hh index 5feb0cd..7e02e76 100644 --- a/ricochet/src/tui/layout.hh +++ b/ricochet/src/tui/layout.hh @@ -31,6 +31,8 @@ namespace ricochet::tui::detail { +class DocumentText; + using HitTarget = std::variant; struct LayoutPoint @@ -46,6 +48,22 @@ struct LayoutHit std::optional target {}; }; +struct ScrollWindow +{ + BlockId block {}; + std::int64_t left { 0 }; + std::int64_t width { 0 }; + std::int64_t offset { 0 }; + std::int64_t maximum { 0 }; +}; + +enum class WordMotion : std::uint8_t +{ + Next, + Previous, + End +}; + struct LayoutGlyph { LayoutPoint position {}; @@ -63,6 +81,10 @@ struct LayoutGlyph bool decoration { false }; bool line_break { false }; bool fill { false }; + bool word_start { false }; + bool word_end { false }; + // Inner-to-outer windows allow a code block inside a table to be revealed. + std::vector scrolls {}; }; struct LayoutData @@ -88,11 +110,40 @@ public: std::size_t rows() const; std::optional locate( const DisplayPosition& position ) const; std::optional hit( LayoutPoint point ) const; - void append_frame( Frame& frame, std::size_t first_row, CellPoint origin, ScreenSize viewport ) const; + DisplayPosition first() const; + DisplayPosition last() const; + DisplayPosition adjacent( const DisplayPosition& position, int direction ) const; + DisplayPosition word( const DisplayPosition& position, WordMotion motion ) const; + DisplayPosition on_row( LayoutPoint point ) const; + DisplayPosition line_edge( const DisplayPosition& position, bool end ) const; + DocumentRange unit( const DisplayPosition& position ) const; + std::optional target( const DisplayPosition& position ) const; + std::optional cursor_cell( const DisplayPosition& position ) const; + std::vector reveal( const DisplayPosition& position ) const; + std::vector pan( const DisplayPosition& position, int direction ) const; + void 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; private: + struct ReadingPosition + { + std::size_t index { 0 }; + bool at_start { true }; + }; + ReadingPosition reading_position( const DisplayPosition& position ) const; + std::size_t nearest_stop( LayoutPoint point ) const; + const LayoutGlyph& cursor_glyph( const DisplayPosition& position ) const; + DisplayPosition stop( std::size_t index ) const; + int columns_; LayoutData data_; + std::vector stops_ {}; }; } // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/paint.hh b/ricochet/src/tui/paint.hh index 549718d..43bdf89 100644 --- a/ricochet/src/tui/paint.hh +++ b/ricochet/src/tui/paint.hh @@ -35,6 +35,8 @@ struct PaintGlyph int width { 1 }; std::wstring text {}; TextStyle style {}; + bool selected { false }; + bool cursor { false }; }; struct Frame diff --git a/ricochet/src/tui/screen.cc b/ricochet/src/tui/screen.cc index 34a8542..99d4e36 100644 --- a/ricochet/src/tui/screen.cc +++ b/ricochet/src/tui/screen.cc @@ -532,7 +532,8 @@ public: } const attr_t attributes = ( glyph.style.strong ? A_BOLD : A_NORMAL ) | ( glyph.style.emphasis ? A_UNDERLINE : A_NORMAL ) - | ( glyph.style.subdued ? A_DIM : A_NORMAL ) | ( glyph.style.warning ? A_REVERSE : A_NORMAL ); + | ( glyph.style.subdued ? A_DIM : A_NORMAL ) | ( glyph.style.warning ? A_REVERSE : A_NORMAL ) + | ( glyph.selected ? A_REVERSE | A_UNDERLINE : A_NORMAL ) | ( glyph.cursor ? A_BOLD : A_NORMAL ); cchar_t cell {}; if ( setcchar( &cell, glyph.text.c_str(), attributes, 0, nullptr ) == ERR || mvwadd_wchnstr( stdscr, row, column, &cell, 1 ) == ERR ) { diff --git a/ricochet/src/tui/session.cc b/ricochet/src/tui/session.cc index a596c87..069c938 100644 --- a/ricochet/src/tui/session.cc +++ b/ricochet/src/tui/session.cc @@ -18,11 +18,12 @@ #include "ricochet/tui/session.hh" -#include "layout.hh" +#include "document_view.hh" #include "mailbox.hh" #include "screen.hh" #include +#include #include #include #include @@ -32,14 +33,22 @@ namespace ricochet::tui { namespace { +using Clock = std::chrono::steady_clock; + +struct KeyPrefix +{ + char32_t character; + Clock::time_point deadline; +}; + struct TabState { TabInfo info {}; std::optional navigation {}; - std::optional document {}; - std::optional layout {}; - std::optional view {}; + std::optional view {}; std::optional failure {}; + std::optional copy_request {}; + std::string copy_feedback {}; }; // Only fixed ASCII chrome uses this helper. Document text goes through the @@ -78,12 +87,13 @@ public: if ( exit_request_ ) { return; } + prefix_.reset(); const RequestId request { next_request_++ }; std::vector views; for ( const auto id : tab_order_ ) { const auto& tab = tabs_.at( id ); if ( tab.view ) { - views.push_back( *tab.view ); + views.push_back( tab.view->snapshot() ); } } actions_.emplace_back( RequestExit { request, active_, std::move( views ) } ); @@ -93,6 +103,9 @@ public: void apply( SyncTabs update, StepResult& ) { + if ( active_ != update.active ) { + stop_drag(); + } std::map retained; std::vector order; order.reserve( update.tabs.size() ); @@ -109,6 +122,10 @@ public: } tabs_ = std::move( retained ); tab_order_ = std::move( order ); + if ( active_ != update.active ) { + prefix_.reset(); + notice_.clear(); + } active_ = update.active; dirty_ = true; } @@ -134,28 +151,74 @@ public: } auto& tab = found->second; const auto version = update.document.data().version; - if ( tab.document && tab.document->data().version.document == version.document - && version.revision < tab.document->data().version.revision ) { + if ( tab.view && tab.view->document().data().version.document == version.document + && version.revision < tab.view->document().data().version.revision ) { return; } const DocumentScope scope { update.tab, version }; - tab.view = ViewSnapshot { .scope = scope }; + ViewSnapshot snapshot { .scope = scope }; // Initial drafts are revision zero. A history view referring to edited values // cannot be restored without the corresponding draft snapshot/mapping. if ( update.restore && update.restore->scope == scope && update.restore->draft_revision == DraftRevision {} ) { - tab.view = std::move( update.restore ); + snapshot = std::move( *update.restore ); } tab.info.title = update.document.data().metadata.title; tab.info.display_url = update.document.data().metadata.display_url; tab.info.loading_task.reset(); - tab.document = std::move( update.document ); - tab.layout.reset(); + const auto dimensions = screen_->size(); + tab.view.emplace( std::move( update.document ), + std::move( snapshot ), + text_, + ScreenSize { dimensions.rows - 2, dimensions.columns } ); + if ( active_ == update.tab ) { + prefix_.reset(); + notice_.clear(); + } tab.failure.reset(); + tab.copy_request.reset(); + tab.copy_feedback.clear(); + dirty_ = true; + } + + void apply( CopyResult update, StepResult& ) + { + const auto found = std::ranges::find_if( + tabs_, [&update]( const auto& entry ) { return entry.second.copy_request == update.request; } ); + // Superseded requests, replaced documents, closed tabs and duplicate results + // have no pending request; they cannot replace the current operation's feedback. + if ( found == tabs_.end() ) { + return; + } + auto& tab = found->second; + tab.copy_request.reset(); + switch ( update.status ) { + case CopyStatus::Confirmed: + tab.copy_feedback = "Copied"; + break; + case CopyStatus::Dispatched: + tab.copy_feedback = "Copy request sent; clipboard acceptance unconfirmed"; + break; + case CopyStatus::Unavailable: + tab.copy_feedback = "Clipboard unavailable; disable mouse capture for terminal selection"; + break; + case CopyStatus::Failed: + tab.copy_feedback = "Copy failed | y retry"; + break; + } + if ( !update.summary.empty() ) { + tab.copy_feedback += ": " + update.summary; + } dirty_ = true; } - void apply( ActionFailed update, StepResult& ) + void apply( ActionFailed update, StepResult& result ) { + const auto copy = std::ranges::find_if( + tabs_, [&update]( const auto& entry ) { return entry.second.copy_request == update.request; } ); + if ( copy != tabs_.end() ) { + apply( CopyResult { update.request, CopyStatus::Failed, std::move( update.summary ) }, result ); + return; + } if ( !update.tab && exit_request_ == update.request ) { exit_request_.reset(); dirty_ = true; @@ -185,13 +248,37 @@ public: dirty_ = true; } - // These messages require document-view/editor state, which this backend stage - // does not implement. Returning ownership keeps the incomplete boundary explicit. + // Incremental updates and form editing still require the later editing layer. void apply( UpdateDocument update, StepResult& result ) { result.unhandled_updates.emplace_back( std::move( update ) ); } - void apply( LocateView update, StepResult& result ) - { result.unhandled_updates.emplace_back( std::move( update ) ); } + void apply( LocateView update, StepResult& ) + { + const auto found = tabs_.find( update.scope.tab ); + // A late anchor request must never move another tab/document revision. + if ( found == tabs_.end() || !found->second.view || found->second.view->snapshot().scope != update.scope ) { + return; + } + auto& view = *found->second.view; + const auto dimensions = screen_->size(); + view.resize( { dimensions.rows - 2, dimensions.columns } ); + std::optional point; + if ( const auto* name = std::get_if( &update.target ) ) { + const auto& anchors = view.document().data().anchors; + const auto anchor = std::ranges::find( anchors, *name, &Anchor::name ); + if ( anchor != anchors.end() ) { + point = std::visit( []( const auto& value ) -> DocumentPoint { return value; }, anchor->point ); + } + } else { + point = std::get( update.target ); + } + const bool located = point && view.locate( *point ); + if ( active_ == update.scope.tab ) { + prefix_.reset(); + notice_ = located ? L"" : L"Location not found"; + } + dirty_ = true; + } void apply( FormFeedback update, StepResult& result ) { result.unhandled_updates.emplace_back( std::move( update ) ); } @@ -210,81 +297,352 @@ public: return count; } + detail::DocumentView* active_view() + { + if ( !active_ ) { + return nullptr; + } + const auto found = tabs_.find( *active_ ); + const auto dimensions = screen_->size(); + // Selection/copy/cancel still address the active document in compact mode. + if ( found == tabs_.end() || !found->second.view ) { + return nullptr; + } + auto& view = *found->second.view; + view.resize( { dimensions.rows - 2, dimensions.columns } ); + return &view; + } + + void move( detail::ViewMotion motion ) + { + const auto dimensions = screen_->size(); + if ( dimensions.rows < 3 || dimensions.columns < 1 ) { + return; // No body rows exist for a visual-row/page motion. + } + if ( auto* view = active_view() ) { + view->move( motion ); + tabs_.at( view->snapshot().scope.tab ).copy_feedback.clear(); + notice_.clear(); + dirty_ = true; + } + } + + void stop_drag() + { + if ( auto* view = active_view() ) { + view->stop_drag(); + } + } + + void copy_selection() + { + auto* view = active_view(); + if ( !view ) { + return; + } + auto selected = view->selected_text(); + if ( !selected || selected->empty() ) { + notice_ = selected ? L"Selection is empty" : L"No selection | v select"; + dirty_ = true; + return; + } + const RequestId request { next_request_++ }; + const auto& snapshot = view->snapshot(); + actions_.emplace_back( + CopySelection { request, snapshot.scope, snapshot.draft_revision, std::move( *selected ) } ); + auto& tab = tabs_.at( snapshot.scope.tab ); + tab.copy_request = request; + tab.copy_feedback = "Copy pending"; + notice_.clear(); + dirty_ = true; + } + + void expire_prefix() + { + if ( prefix_ && Clock::now() >= prefix_->deadline ) { + prefix_.reset(); + dirty_ = true; + } + } + + void browse_text( char32_t character ) + { + using Motion = detail::ViewMotion; + if ( prefix_ ) { + const auto prefix = std::exchange( prefix_, std::nullopt )->character; + dirty_ = true; + if ( prefix == U'g' && character == U'g' ) { + move( Motion::DocumentStart ); + } else if ( prefix == U'z' && character == U'h' ) { + move( Motion::PanLeft ); + } else if ( prefix == U'z' && character == U'l' ) { + move( Motion::PanRight ); + } + // A mismatched sequence is consumed; gq must not turn into a quit action. + return; + } + switch ( character ) { + case U'h': + move( Motion::Left ); + break; + case U'j': + move( Motion::Down ); + break; + case U'k': + move( Motion::Up ); + break; + case U'l': + move( Motion::Right ); + break; + case U'w': + move( Motion::NextWord ); + break; + case U'b': + move( Motion::PreviousWord ); + break; + case U'e': + move( Motion::WordEnd ); + break; + case U'G': + move( Motion::DocumentEnd ); + break; + case U'g': + case U'z': + prefix_ = KeyPrefix { character, Clock::now() + std::chrono::seconds( 1 ) }; + dirty_ = true; + break; + case U'v': + if ( auto* view = active_view(); view && !view->snapshot().selection ) { + view->begin_selection(); + tabs_.at( view->snapshot().scope.tab ).copy_feedback.clear(); + notice_.clear(); + dirty_ = true; + } + break; + case U'y': + copy_selection(); + break; + case U' ': { + auto* view = active_view(); + if ( view ) { + const auto target = view->focused_target(); + // Selection owns Space; browse mode reserves control Space for activation. + if ( view->snapshot().selection || !target || !std::holds_alternative( *target ) ) { + move( Motion::PageDown ); + } + } + break; + } + case U'q': { + const auto* view = active_view(); + if ( !view || !view->snapshot().selection ) { + request_exit(); + } + break; + } + default: + break; + } + } + + void browse_key( const Key& key ) + { + using Motion = detail::ViewMotion; + if ( key.code == KeyCode::Escape ) { + // A pending prefix is the top input layer; one Esc cancels only that layer. + if ( !prefix_ ) { + if ( auto* view = active_view() ) { + view->cancel_selection(); + tabs_.at( view->snapshot().scope.tab ).copy_feedback.clear(); + } + } + prefix_.reset(); + notice_.clear(); + dirty_ = true; + return; + } + if ( prefix_ ) { + // Ctrl+C cancels the prefix first; no key falls through into another action. + prefix_.reset(); + dirty_ = true; + return; + } + if ( key.modifiers == Modifiers { .control = true } && key.code == KeyCode::Character ) { + switch ( key.character ) { + case U'c': + if ( auto* view = active_view(); view && view->snapshot().selection ) { + view->cancel_selection(); + tabs_.at( view->snapshot().scope.tab ).copy_feedback.clear(); + notice_.clear(); + dirty_ = true; + } else { + request_exit(); + } + break; + case U'u': + move( Motion::HalfPageUp ); + break; + case U'd': + move( Motion::HalfPageDown ); + break; + case U'z': + notice_ = L"Suspend is unavailable"; + dirty_ = true; + break; + default: + break; + } + return; + } + if ( key.modifiers == Modifiers { .alt = true } + && ( key.code == KeyCode::Left || key.code == KeyCode::Right ) ) { + const auto* view = active_view(); + if ( view && view->snapshot().selection ) { + return; // Selection mode does not turn navigation into implicit activation. + } + if ( active_ ) { + const auto found = tabs_.find( *active_ ); + if ( found != tabs_.end() ) { + std::optional version; + if ( found->second.view ) { + version = found->second.view->document().data().version; + } + actions_.emplace_back( + Navigate { RequestId { next_request_++ }, + *active_, + version, + key.code == KeyCode::Left ? NavigationCommand::Back : NavigationCommand::Forward } ); + } + } + return; + } + if ( key.modifiers != Modifiers {} ) { + return; // Modified keys have distinct bindings; do not alias them to arrows. + } + switch ( key.code ) { + case KeyCode::Left: + move( Motion::Left ); + break; + case KeyCode::Right: + move( Motion::Right ); + break; + case KeyCode::Up: + move( Motion::Up ); + break; + case KeyCode::Down: + move( Motion::Down ); + break; + case KeyCode::Home: + move( Motion::LineStart ); + break; + case KeyCode::End: + move( Motion::LineEnd ); + break; + case KeyCode::PageUp: + move( Motion::PageUp ); + break; + case KeyCode::PageDown: + move( Motion::PageDown ); + break; + default: + break; + } + } + + void browse_mouse( const Mouse& mouse ) + { + auto* view = active_view(); + if ( !view ) { + return; + } + const CellPoint point { mouse.position.row - 1, mouse.position.column }; + // Capture owns motion/release outside the body as well, so releasing on the + // header/footer cannot leave a background autoscroll running. + if ( view->dragging() ) { + if ( mouse.released.left ) { + view->release( point ); + } else if ( mouse.motion || mouse.pressed.left ) { + view->drag( point ); + } else if ( mouse.wheel_steps != 0 ) { + view->wheel( mouse.wheel_steps ); + } + dirty_ = true; + return; + } + const auto dimensions = screen_->size(); + if ( point.row < 0 || point.row >= dimensions.rows - 2 || point.column < 0 || point.column >= dimensions.columns + || mouse.modifiers != Modifiers {} ) { + return; + } + if ( mouse.wheel_steps != 0 ) { + prefix_.reset(); + view->wheel( mouse.wheel_steps ); + dirty_ = true; + } else if ( mouse.pressed.left ) { + prefix_.reset(); + view->press( point, Clock::now() ); + tabs_.at( view->snapshot().scope.tab ).copy_feedback.clear(); + notice_.clear(); + dirty_ = true; + } + } + void dispatch( const InputEvent& event, StepResult& result ) { if ( const auto* closed = std::get_if( &event ) ) { result.terminal_closed = *closed; close(); } else if ( std::holds_alternative( event ) ) { + stop_drag(); dirty_ = true; + } else if ( const auto* tick = std::get_if( &event ) ) { + if ( auto* view = active_view() ) { + dirty_ = view->tick( tick->now ) || dirty_; + } } else if ( std::holds_alternative( event ) ) { - // The baseline session is Browse: a paste never executes its text as keys. + stop_drag(); + prefix_.reset(); notice_ = L"Enter an input field before pasting"; dirty_ = true; } else if ( const auto* text = std::get_if( &event ) ) { - if ( text->character == U'q' ) { - request_exit(); - } + stop_drag(); + browse_text( text->character ); } else if ( const auto* key = std::get_if( &event ) ) { - if ( key->code == KeyCode::Character && key->modifiers == Modifiers { .control = true } ) { - switch ( key->character ) { - case U'c': - request_exit(); - break; - case U'z': - notice_ = L"Suspend is unavailable"; - dirty_ = true; - break; - default: - break; - } - } + stop_drag(); + browse_key( *key ); + } else if ( const auto* mouse = std::get_if( &event ) ) { + browse_mouse( *mouse ); } - // Mouse/navigation/editor bindings belong to the upcoming document-view layer. } bool read_input( StepResult& result ) { const auto batch = screen_->read_events( 64 ); for ( const auto& event : batch.events ) { + expire_prefix(); dispatch( event, result ); if ( !screen_ ) { break; } } + expire_prefix(); return batch.consumed || !batch.events.empty(); } - void draw_document( TabState& tab, detail::Frame& frame, ScreenSize dimensions ) - { - // The cached layout belongs to the committed snapshot and terminal width. - // CommitDocument drops it; resizing rebuilds it from logical view anchors. - if ( !tab.layout || tab.layout->columns() != dimensions.columns ) { - tab.layout.emplace( *tab.document, text_, dimensions.columns, tab.view->horizontal_scroll ); - } - const auto& layout = *tab.layout; - const auto anchor = layout.locate( tab.view->viewport_anchor ).value_or( detail::LayoutPoint {} ); - const auto body_rows = dimensions.rows - 2; - const auto first_row = std::min( anchor.row, layout.rows() - 1 ); - layout.append_frame( frame, first_row, { 1, 0 }, { body_rows, dimensions.columns } ); - const auto cursor = layout.locate( tab.view->cursor ); - if ( cursor && cursor->row >= first_row && cursor->row - first_row < static_cast( body_rows ) - && cursor->column >= 0 && cursor->column < dimensions.columns ) { - frame.cursor - = CellPoint { 1 + static_cast( cursor->row - first_row ), static_cast( cursor->column ) }; - } - } - void draw() { detail::Frame frame; const auto dimensions = screen_->size(); // With fewer than three rows, header/body/footer cannot coexist. if ( dimensions.rows < 3 ) { - append_chrome( frame, { 0, 0 }, L"Resize | q quit" ); + const auto* view = active_view(); + append_chrome( frame, + { 0, 0 }, + view && view->snapshot().selection ? L"Resize | Esc cancel selection" : L"Resize | q quit" ); } else { append_chrome( frame, { 0, 0 }, L"Ricochet", { .strong = true } ); - std::wstring_view status = L"Browse | q quit"; + std::wstring_view status = L"Browse | v select | q quit"; + std::string_view copy_feedback; if ( !capabilities_.bracketed_paste ) { status = L"Paste boundaries unavailable; multiline paste may confirm input | q quit"; } @@ -294,9 +652,14 @@ public: if ( active_ ) { const auto found = tabs_.find( *active_ ); if ( found != tabs_.end() ) { - if ( found->second.document ) { - draw_document( found->second, frame, dimensions ); + if ( found->second.view ) { + found->second.view->resize( { dimensions.rows - 2, dimensions.columns } ); + found->second.view->draw( frame, { 1, 0 } ); + if ( found->second.view->snapshot().selection && notice_.empty() ) { + status = L"Select | move extends | y copy | Esc cancel"; + } } + copy_feedback = found->second.copy_feedback; if ( found->second.info.loading_task ) { status = L"Loading | q quit"; } @@ -305,10 +668,25 @@ public: } } } + if ( prefix_ ) { + status = prefix_->character == U'g' ? L"g ... | Esc cancel" : L"z ... | Esc cancel"; + } if ( exit_request_ ) { status = L"Waiting for exit decision"; } - append_chrome( frame, { dimensions.rows - 1, 0 }, status ); + if ( !copy_feedback.empty() && notice_.empty() && !prefix_ && !exit_request_ ) { + int column = 0; + for ( const auto& glyph : text_.analyze( copy_feedback, false, false ).graphemes ) { + if ( glyph.columns > dimensions.columns - column ) { + break; // Provider messages are clipped at a complete grapheme boundary. + } + frame.glyphs.push_back( + { .position = { dimensions.rows - 1, column }, .width = glyph.columns, .text = glyph.display } ); + column += glyph.columns; + } + } else { + append_chrome( frame, { dimensions.rows - 1, 0 }, status ); + } } screen_->draw( frame ); dirty_ = false; @@ -323,11 +701,19 @@ public: return result; } try { + expire_prefix(); const auto received = receive( result ); if ( screen_ ) { const bool input = read_input( result ); if ( screen_ && received == 0 && !input && !dirty_ ) { - const auto closed = screen_->wait( mailbox_->descriptor(), max_wait ); + auto wait = max_wait; + if ( prefix_ ) { + wait = std::min( + wait, + std::max( std::chrono::milliseconds::zero(), + std::chrono::ceil( prefix_->deadline - Clock::now() ) ) ); + } + const auto closed = screen_->wait( mailbox_->descriptor(), wait ); if ( closed ) { dispatch( *closed, result ); } else { @@ -338,6 +724,10 @@ public: } } } + if ( screen_ ) { + // Advance drag dwell even on a quiet terminal or a continuously busy input queue. + dispatch( Tick { Clock::now() }, result ); + } if ( screen_ && dirty_ ) { draw(); } @@ -358,6 +748,7 @@ public: std::vector tab_order_ {}; std::optional active_ {}; std::optional exit_request_ {}; + std::optional prefix_ {}; std::uint64_t next_request_ { 1 }; std::vector actions_ {}; std::wstring notice_ {}; @@ -392,10 +783,10 @@ std::vector Session::take_actions() std::optional Session::snapshot( TabId tab ) const { const auto found = impl_->tabs_.find( tab ); - if ( found == impl_->tabs_.end() ) { + if ( found == impl_->tabs_.end() || !found->second.view ) { return std::nullopt; } - return found->second.view; + return found->second.view->snapshot(); } TerminalCapabilities Session::capabilities() const diff --git a/ricochet/src/tui/text.cc b/ricochet/src/tui/text.cc index 14f0e09..b2c64b8 100644 --- a/ricochet/src/tui/text.cc +++ b/ricochet/src/tui/text.cc @@ -125,10 +125,13 @@ TextAnalysis TextService::analyze( std::string_view utf8, bool line_breaks, bool if ( glyph.word != WordKind::Space ) { if ( glyph.word == previous && glyph.word != WordKind::Ideograph ) { result.words.back().end = glyph.bytes.end; + result.graphemes.back().word_end = false; } else { result.words.push_back( glyph.bytes ); + glyph.word_start = true; } } + glyph.word_end = glyph.word != WordKind::Space; previous = glyph.word; result.graphemes.push_back( std::move( glyph ) ); } diff --git a/ricochet/src/tui/text.hh b/ricochet/src/tui/text.hh index c2d4af9..3c0b103 100644 --- a/ricochet/src/tui/text.hh +++ b/ricochet/src/tui/text.hh @@ -50,6 +50,8 @@ struct Grapheme TextUnitKind kind { TextUnitKind::Glyph }; WordKind word { WordKind::Punctuation }; bool break_after { false }; + bool word_start { false }; + bool word_end { false }; }; struct TextAnalysis -- cgit v1.2.3 From 032b82c13e157a048ac13a540dfd18be9667fe49 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 26 Oct 2026 20:38:32 +0800 Subject: feat(tui): add object focus traversal and link activation --- ricochet/src/tui/document_view.cc | 22 ++++++++++++++ ricochet/src/tui/document_view.hh | 10 ++++++ ricochet/src/tui/layout.cc | 43 ++++++++++++++++++++++++++ ricochet/src/tui/layout.hh | 15 +++++++++ ricochet/src/tui/session.cc | 64 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 151 insertions(+), 3 deletions(-) (limited to 'ricochet/src/tui/layout.cc') diff --git a/ricochet/src/tui/document_view.cc b/ricochet/src/tui/document_view.cc index ee74e53..13a5d5f 100644 --- a/ricochet/src/tui/document_view.cc +++ b/ricochet/src/tui/document_view.cc @@ -361,6 +361,28 @@ bool DocumentView::locate( const DocumentPoint& point ) std::optional DocumentView::focused_target() const { return layout_.target( snapshot_.cursor ); } +FocusResult DocumentView::focus( bool backward ) +{ + const auto next = layout_.focus( snapshot_.cursor, backward ); + if ( !next ) { + return FocusResult::Empty; + } + stop_drag(); + snapshot_.cursor = next->position; + finish_move( false ); + return next->wrapped ? FocusResult::Wrapped : FocusResult::Moved; +} + +const Link* DocumentView::focused_link() const +{ + const auto target = focused_target(); + if ( !target || !std::holds_alternative( *target ) ) { + return nullptr; + } + // Layout link targets originate from this same immutable document. + return &*std::ranges::find( document_.data().links, std::get( *target ), &Link::id ); +} + void DocumentView::draw( Frame& frame, CellPoint origin ) const { const auto selection = snapshot_.selection ? std::optional( snapshot_.selection->range ) : std::nullopt; diff --git a/ricochet/src/tui/document_view.hh b/ricochet/src/tui/document_view.hh index 587f419..5f5e331 100644 --- a/ricochet/src/tui/document_view.hh +++ b/ricochet/src/tui/document_view.hh @@ -46,6 +46,13 @@ enum class ViewMotion : std::uint8_t PanRight }; +enum class FocusResult : std::uint8_t +{ + Empty, + Moved, + Wrapped +}; + // One tab's reading state. It owns the logical snapshot; screen rows are caches. // Keyboard, mouse and host navigation all use the same layout/source mappings. class DocumentView @@ -70,6 +77,9 @@ public: void wheel( int steps ); bool locate( const DocumentPoint& point ); std::optional focused_target() const; + // Browse/field-editor routing owns mode eligibility; this follows the shared order. + FocusResult focus( bool backward ); + const Link* focused_link() const; void draw( Frame& frame, CellPoint origin ) const; private: diff --git a/ricochet/src/tui/layout.cc b/ricochet/src/tui/layout.cc index e12c8d0..46bd626 100644 --- a/ricochet/src/tui/layout.cc +++ b/ricochet/src/tui/layout.cc @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -565,6 +566,20 @@ DocumentLayout::DocumentLayout( const Document& document, 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 @@ -750,6 +765,34 @@ const LayoutGlyph& DocumentLayout::cursor_glyph( const DisplayPosition& position 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 ); diff --git a/ricochet/src/tui/layout.hh b/ricochet/src/tui/layout.hh index 7e02e76..f5db28c 100644 --- a/ricochet/src/tui/layout.hh +++ b/ricochet/src/tui/layout.hh @@ -48,6 +48,12 @@ struct LayoutHit std::optional target {}; }; +struct FocusStep +{ + DisplayPosition position {}; + bool wrapped { false }; +}; + struct ScrollWindow { BlockId block {}; @@ -118,6 +124,7 @@ public: DisplayPosition line_edge( const DisplayPosition& position, bool end ) const; DocumentRange unit( const DisplayPosition& position ) const; std::optional target( const DisplayPosition& position ) const; + std::optional focus( const DisplayPosition& position, bool backward ) const; std::optional cursor_cell( const DisplayPosition& position ) const; std::vector reveal( const DisplayPosition& position ) const; std::vector pan( const DisplayPosition& position, int direction ) const; @@ -141,9 +148,17 @@ private: const LayoutGlyph& cursor_glyph( const DisplayPosition& position ) const; DisplayPosition stop( std::size_t index ) const; + struct FocusEntry + { + HitTarget target {}; + std::size_t stop { 0 }; + }; + int columns_; LayoutData data_; std::vector stops_ {}; + // First reading stop per enabled object, independent of viewport clipping. + std::vector focus_ {}; }; } // namespace ricochet::tui::detail diff --git a/ricochet/src/tui/session.cc b/ricochet/src/tui/session.cc index 069c938..f54d382 100644 --- a/ricochet/src/tui/session.cc +++ b/ricochet/src/tui/session.cc @@ -334,6 +334,54 @@ public: } } + void focus( bool backward ) + { + const auto dimensions = screen_->size(); + if ( dimensions.rows < 3 || dimensions.columns < 1 ) { + return; // Object positioning requires a visible document region. + } + auto* view = active_view(); + if ( !view || view->snapshot().selection ) { + return; // Tab must not move the cursor or extend/cancel an active selection. + } + const auto result = view->focus( backward ); + switch ( result ) { + case detail::FocusResult::Empty: + notice_ = L"No focusable links or controls"; + break; + case detail::FocusResult::Moved: + notice_.clear(); + break; + case detail::FocusResult::Wrapped: + notice_ = backward ? L"Focus wrapped to last object" : L"Focus wrapped to first object"; + break; + } + tabs_.at( view->snapshot().scope.tab ).copy_feedback.clear(); + dirty_ = true; + } + + void activate_link() + { + auto* view = active_view(); + if ( !view || view->snapshot().selection ) { + return; // Selection mode consumes activation without navigating. + } + const auto* link = view->focused_link(); + if ( !link ) { + return; // Plain text and controls do not produce link navigation requests. + } + const RequestId request { next_request_++ }; + const auto source = view->snapshot().scope; + actions_.emplace_back( OpenLink { request, source, link->id, link->target, OpenTarget::CurrentTab } ); + auto& tab = tabs_.at( source.tab ); + // Correlate rejection even when the host fails before NavigationStarted. + tab.navigation = request; + tab.failure.reset(); + tab.copy_feedback.clear(); + notice_.clear(); + dirty_ = true; + } + void copy_selection() { auto* view = active_view(); @@ -516,10 +564,18 @@ public: } return; } + if ( key.code == KeyCode::Tab + && ( key.modifiers == Modifiers {} || key.modifiers == Modifiers { .shift = true } ) ) { + focus( key.modifiers.shift ); + return; + } if ( key.modifiers != Modifiers {} ) { return; // Modified keys have distinct bindings; do not alias them to arrows. } switch ( key.code ) { + case KeyCode::Enter: + activate_link(); + break; case KeyCode::Left: move( Motion::Left ); break; @@ -646,9 +702,6 @@ public: if ( !capabilities_.bracketed_paste ) { status = L"Paste boundaries unavailable; multiline paste may confirm input | q quit"; } - if ( !notice_.empty() ) { - status = notice_; - } if ( active_ ) { const auto found = tabs_.find( *active_ ); if ( found != tabs_.end() ) { @@ -668,6 +721,11 @@ public: } } } + // Immediate input feedback (including focus wrap) must remain visible while + // the page has a background navigation status. + if ( !notice_.empty() ) { + status = notice_; + } if ( prefix_ ) { status = prefix_->character == U'g' ? L"g ... | Esc cancel" : L"z ... | Esc cancel"; } -- cgit v1.2.3