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/src/tui/document_view.cc | 377 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 ricochet/src/tui/document_view.cc (limited to 'ricochet/src/tui/document_view.cc') 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 -- 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/document_view.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