aboutsummaryrefslogtreecommitdiff
path: root/src/tui
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui')
-rw-r--r--src/tui/document_view.cc22
-rw-r--r--src/tui/document_view.hh10
-rw-r--r--src/tui/layout.cc43
-rw-r--r--src/tui/layout.hh15
-rw-r--r--src/tui/session.cc64
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";
}