diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/tui/document_view.cc | 22 | ||||
| -rw-r--r-- | src/tui/document_view.hh | 10 | ||||
| -rw-r--r-- | src/tui/layout.cc | 43 | ||||
| -rw-r--r-- | src/tui/layout.hh | 15 | ||||
| -rw-r--r-- | src/tui/session.cc | 64 |
5 files changed, 151 insertions, 3 deletions
diff --git a/src/tui/document_view.cc b/src/tui/document_view.cc index ee74e53..13a5d5f 100644 --- a/src/tui/document_view.cc +++ b/src/tui/document_view.cc @@ -361,6 +361,28 @@ bool DocumentView::locate( const DocumentPoint& point ) std::optional<HitTarget> 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<LinkId>( *target ) ) { + return nullptr; + } + // Layout link targets originate from this same immutable document. + return &*std::ranges::find( document_.data().links, std::get<LinkId>( *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/src/tui/document_view.hh b/src/tui/document_view.hh index 587f419..5f5e331 100644 --- a/src/tui/document_view.hh +++ b/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<HitTarget> 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/src/tui/layout.cc b/src/tui/layout.cc index e12c8d0..46bd626 100644 --- a/src/tui/layout.cc +++ b/src/tui/layout.cc @@ -23,6 +23,7 @@ #include <limits> #include <map> #include <numeric> +#include <set> #include <stdexcept> #include <string> #include <string_view> @@ -565,6 +566,20 @@ DocumentLayout::DocumentLayout( const Document& document, stops_.push_back( index ); } } + std::set<HitTarget> 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<HitTarget> DocumentLayout::target( const DisplayPosition& position ) const { return cursor_glyph( position ).target; } +std::optional<FocusStep> 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<LayoutPoint> DocumentLayout::cursor_cell( const DisplayPosition& position ) const { const auto& glyph = cursor_glyph( position ); diff --git a/src/tui/layout.hh b/src/tui/layout.hh index 7e02e76..f5db28c 100644 --- a/src/tui/layout.hh +++ b/src/tui/layout.hh @@ -48,6 +48,12 @@ struct LayoutHit std::optional<HitTarget> 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<HitTarget> target( const DisplayPosition& position ) const; + std::optional<FocusStep> focus( const DisplayPosition& position, bool backward ) const; std::optional<LayoutPoint> cursor_cell( const DisplayPosition& position ) const; std::vector<HorizontalScroll> reveal( const DisplayPosition& position ) const; std::vector<HorizontalScroll> 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<std::size_t> stops_ {}; + // First reading stop per enabled object, independent of viewport clipping. + std::vector<FocusEntry> focus_ {}; }; } // namespace ricochet::tui::detail diff --git a/src/tui/session.cc b/src/tui/session.cc index 069c938..f54d382 100644 --- a/src/tui/session.cc +++ b/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"; } |
