aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRichard Qin <richard.qin.x@gmail.com>2026-10-26 20:37:52 +0800
committerRichard Qin <richard.qin.x@gmail.com>2026-10-26 20:37:52 +0800
commitcb1f404cc6b8b9dce135efd26fa32dc0d6fd79c4 (patch)
tree70fea121bc8c6fac3287c7a96fb8f5f86ec6f2e7
parent795126a90d651ede075e9a94741dae4841e4de62 (diff)
downloadrwebbrowser-cb1f404cc6b8b9dce135efd26fa32dc0d6fd79c4.tar.gz
rwebbrowser-cb1f404cc6b8b9dce135efd26fa32dc0d6fd79c4.tar.xz
feat(tui): add document navigation and text selection
-rw-r--r--include/ricochet/tui/view.hh2
-rw-r--r--src/tui/CMakeLists.txt2
-rw-r--r--src/tui/document_view.cc377
-rw-r--r--src/tui/document_view.hh107
-rw-r--r--src/tui/layout.cc272
-rw-r--r--src/tui/layout.hh53
-rw-r--r--src/tui/paint.hh2
-rw-r--r--src/tui/screen.cc3
-rw-r--r--src/tui/session.cc515
-rw-r--r--src/tui/text.cc3
-rw-r--r--src/tui/text.hh2
11 files changed, 1262 insertions, 76 deletions
diff --git a/include/ricochet/tui/view.hh b/include/ricochet/tui/view.hh
index f5f33d4..cc1d0fd 100644
--- a/include/ricochet/tui/view.hh
+++ b/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<SelectionSnapshot> selection {};
std::vector<HorizontalScroll> horizontal_scroll {};
SearchSnapshot search {};
diff --git a/src/tui/CMakeLists.txt b/src/tui/CMakeLists.txt
index ed7b067..98dcee2 100644
--- a/src/tui/CMakeLists.txt
+++ b/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/src/tui/document_view.cc b/src/tui/document_view.cc
new file mode 100644
index 0000000..ee74e53
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "document_view.hh"
+
+#include <algorithm>
+#include <utility>
+
+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<std::int64_t>( row );
+ return static_cast<std::size_t>( origin
+ + std::clamp( distance, -origin, static_cast<std::int64_t>( 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<DocumentStart>( 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<std::size_t>( 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<std::int64_t>( top_row_ ) - static_cast<std::int64_t>( 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<const HorizontalScroll> 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<std::size_t>( viewport_.rows ) ) {
+ set_top_row( point.row - static_cast<std::size_t>( 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<std::string> 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<std::size_t>( 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<std::size_t>( 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<std::int64_t>( 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<DocumentStart>( point ) ) {
+ snapshot_.cursor = layout_.first();
+ } else if ( const auto* object = std::get_if<ObjectPosition>( &point );
+ object && std::holds_alternative<BlockId>( object->object ) ) {
+ snapshot_.cursor = layout_.on_row( *location );
+ }
+ cancel_selection();
+ finish_move( false );
+ return true;
+}
+
+std::optional<HitTarget> 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<std::size_t>( viewport_.rows )
+ && cursor->column >= 0 && cursor->column < viewport_.columns ) {
+ frame.cursor = CellPoint { origin.row + static_cast<int>( cursor->row - top_row_ ),
+ origin.column + static_cast<int>( cursor->column ) };
+ }
+}
+
+} // namespace ricochet::tui::detail
diff --git a/src/tui/document_view.hh b/src/tui/document_view.hh
new file mode 100644
index 0000000..587f419
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "document_text.hh"
+#include "layout.hh"
+
+#include <chrono>
+
+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<std::string> selected_text() const;
+ void wheel( int steps );
+ bool locate( const DocumentPoint& point );
+ std::optional<HitTarget> 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<const HorizontalScroll> 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> drag_ {};
+};
+
+} // namespace ricochet::tui::detail
diff --git a/src/tui/layout.cc b/src/tui/layout.cc
index fa6eaa6..e12c8d0 100644
--- a/src/tui/layout.cc
+++ b/src/tui/layout.cc
@@ -84,6 +84,9 @@ void append_box( LayoutData& destination, LayoutData source, std::size_t row, st
glyph.position.row += row;
glyph.position.column += column;
glyph.line_start += column;
+ for ( auto& window : glyph.scrolls ) {
+ window.left += column;
+ }
glyph.clip_left += column;
glyph.clip_right += column;
destination.glyphs.push_back( std::move( glyph ) );
@@ -276,7 +279,9 @@ private:
.target = atom.target,
.clip_right = wrap ? width : std::numeric_limits<std::int64_t>::max(),
.decoration = atom.decoration,
- .fill = tab } );
+ .fill = tab,
+ .word_start = atom.glyph.word_start,
+ .word_end = atom.glyph.word_end } );
column += cells;
}
result.extent = std::max( result.extent, column );
@@ -309,6 +314,10 @@ private:
const auto maximum = std::max<std::int64_t>( 0, box.extent - width );
const auto shift = static_cast<std::int64_t>( std::min<std::uint64_t>( requested, maximum ) );
for ( auto& glyph : box.glyphs ) {
+ for ( auto& window : glyph.scrolls ) {
+ window.left -= shift;
+ }
+ glyph.scrolls.push_back( { block, 0, width, shift, maximum } );
glyph.position.column -= shift;
glyph.line_start -= shift;
glyph.clip_left = std::max<std::int64_t>( 0, glyph.clip_left - shift );
@@ -504,7 +513,7 @@ private:
{ result.rows - 1, std::min( result.extent, width ) },
width );
before.boundary_only = has_text;
- after.boundary_only = has_text;
+ after.boundary_only = true;
result.glyphs.push_back( std::move( before ) );
result.glyphs.push_back( std::move( after ) );
return result;
@@ -541,6 +550,21 @@ DocumentLayout::DocumentLayout( const Document& document,
if ( data_.glyphs.empty() ) {
data_.glyphs.push_back( anchor( DocumentStart {}, {}, columns_ ) );
}
+ for ( std::size_t index = 0; index < data_.glyphs.size(); ++index ) {
+ const auto& glyph = data_.glyphs[index];
+ if ( glyph.decoration || glyph.boundary_only ) {
+ continue;
+ }
+ // Wrapped object borders and hard-break anchors can repeat a logical point.
+ // Keep one reading stop, preferring actual text over its zero-width anchor.
+ if ( !stops_.empty() && data_.glyphs[stops_.back()].source.start == glyph.source.start ) {
+ if ( data_.glyphs[stops_.back()].columns == 0 ) {
+ stops_.back() = index;
+ }
+ } else {
+ stops_.push_back( index );
+ }
+ }
}
int DocumentLayout::columns() const
@@ -551,7 +575,7 @@ std::size_t DocumentLayout::rows() const
std::optional<LayoutPoint> DocumentLayout::locate( const DisplayPosition& position ) const
{
if ( std::holds_alternative<DocumentStart>( position.point ) ) {
- return LayoutPoint {};
+ return data_.glyphs[stops_.front()].position;
}
std::optional<LayoutPoint> fallback;
for ( const auto& glyph : data_.glyphs ) {
@@ -576,6 +600,12 @@ std::optional<LayoutPoint> DocumentLayout::locate( const DisplayPosition& positi
std::optional<LayoutHit> DocumentLayout::hit( LayoutPoint point ) const
{
+ // Blank space below the document means its last reading position, independent
+ // of the mouse column (especially when the final block is a table).
+ if ( point.row >= data_.rows ) {
+ const auto& glyph = data_.glyphs[stops_.back()];
+ return LayoutHit { last(), glyph.source, glyph.target };
+ }
const LayoutGlyph* nearest = nullptr;
auto distance
= std::tuple { std::numeric_limits<std::size_t>::max(), std::numeric_limits<std::int64_t>::max(), true };
@@ -613,32 +643,252 @@ std::optional<LayoutHit> DocumentLayout::hit( LayoutPoint point ) const
.target = nearest->target };
}
+DisplayPosition DocumentLayout::stop( std::size_t index ) const
+{ return { data_.glyphs[stops_[index]].source.start, WrapAffinity::NextLine }; }
+
+DisplayPosition DocumentLayout::first() const
+{ return stop( 0 ); }
+DisplayPosition DocumentLayout::last() const
+{ return stop( stops_.size() - 1 ); }
+
+std::size_t DocumentLayout::nearest_stop( LayoutPoint point ) const
+{
+ std::size_t nearest = 0;
+ auto distance = std::pair { std::numeric_limits<std::size_t>::max(), std::numeric_limits<std::int64_t>::max() };
+ for ( std::size_t index = 0; index < stops_.size(); ++index ) {
+ const auto& glyph = data_.glyphs[stops_[index]];
+ const auto row = glyph.position.row;
+ const auto right = glyph.position.column + std::max( 1, glyph.columns ) - 1;
+ const auto candidate = std::pair {
+ row > point.row ? row - point.row : point.row - row,
+ std::max( { glyph.position.column - point.column, point.column - right, std::int64_t { 0 } } ) };
+ if ( candidate < distance ) {
+ nearest = index;
+ distance = candidate;
+ }
+ }
+ return nearest;
+}
+
+DocumentLayout::ReadingPosition DocumentLayout::reading_position( const DisplayPosition& position ) const
+{
+ std::optional<std::size_t> endpoint;
+ for ( std::size_t index = 0; index < stops_.size(); ++index ) {
+ const auto& glyph = data_.glyphs[stops_[index]];
+ if ( glyph.source.start == position.point ) {
+ return { index, position.affinity == WrapAffinity::NextLine };
+ }
+ if ( glyph.source.end == position.point ) {
+ endpoint = index + 1;
+ }
+ }
+ if ( endpoint ) {
+ return { *endpoint, false };
+ }
+ // Structural block anchors have coordinates but are not extra character stops.
+ return { nearest_stop( locate( position ).value() ), true };
+}
+
+DisplayPosition DocumentLayout::adjacent( const DisplayPosition& position, int direction ) const
+{
+ const auto current = reading_position( position );
+ if ( direction < 0 ) {
+ return stop( current.index == 0 ? 0 : current.index - 1 );
+ }
+ return stop( std::min( stops_.size() - 1, current.index + static_cast<std::size_t>( current.at_start ) ) );
+}
+
+DisplayPosition DocumentLayout::word( const DisplayPosition& position, WordMotion motion ) const
+{
+ const auto current = reading_position( position );
+ if ( motion == WordMotion::Previous ) {
+ for ( auto index = current.index; index > 0; ) {
+ --index;
+ if ( data_.glyphs[stops_[index]].word_start ) {
+ return stop( index );
+ }
+ }
+ return first();
+ }
+ auto begin = current.index;
+ if ( current.at_start && ( motion == WordMotion::Next || data_.glyphs[stops_[begin]].word_end ) ) {
+ ++begin;
+ }
+ for ( auto index = begin; index < stops_.size(); ++index ) {
+ const auto& glyph = data_.glyphs[stops_[index]];
+ if ( motion == WordMotion::Next ? glyph.word_start : glyph.word_end ) {
+ return stop( index );
+ }
+ }
+ return last();
+}
+
+DisplayPosition DocumentLayout::on_row( LayoutPoint point ) const
+{ return stop( nearest_stop( point ) ); }
+
+DisplayPosition DocumentLayout::line_edge( const DisplayPosition& position, bool end ) const
+{
+ const auto row = locate( position ).value().row;
+ auto chosen = nearest_stop( { row, 0 } );
+ for ( std::size_t index = 0; index < stops_.size(); ++index ) {
+ const auto& glyph = data_.glyphs[stops_[index]];
+ const auto column = data_.glyphs[stops_[chosen]].position.column;
+ if ( glyph.position.row == row && ( end ? glyph.position.column > column : glyph.position.column < column ) ) {
+ chosen = index;
+ }
+ }
+ return stop( chosen );
+}
+
+const LayoutGlyph& DocumentLayout::cursor_glyph( const DisplayPosition& position ) const
+{
+ const auto current = reading_position( position );
+ const auto index = current.at_start || current.index == 0 ? current.index : current.index - 1;
+ return data_.glyphs[stops_[std::min( index, stops_.size() - 1 )]];
+}
+
+std::optional<HitTarget> DocumentLayout::target( const DisplayPosition& position ) const
+{ return cursor_glyph( position ).target; }
+
+std::optional<LayoutPoint> DocumentLayout::cursor_cell( const DisplayPosition& position ) const
+{
+ const auto& glyph = cursor_glyph( position );
+ auto point = locate( position ).value();
+ if ( glyph.fill ) {
+ // Any visible part of an expanded tab represents the same logical unit.
+ const auto left = std::max( glyph.position.column, glyph.clip_left );
+ const auto right = std::min( glyph.position.column + glyph.columns, glyph.clip_right );
+ if ( left >= right ) {
+ return std::nullopt;
+ }
+ point.column = std::clamp( point.column, left, right - 1 );
+ return point;
+ }
+ if ( point.column == glyph.clip_right && point.row == glyph.position.row ) {
+ // An insertion boundary or LF immediately after a full row has no cell.
+ // Use the preceding complete glyph; do not reveal a cursor further offscreen.
+ auto column = glyph.clip_right - 1;
+ for ( const auto index : stops_ ) {
+ const auto& candidate = data_.glyphs[index];
+ if ( candidate.position.row == point.row && candidate.columns > 0
+ && candidate.position.column >= glyph.clip_left
+ && candidate.position.column + candidate.columns == glyph.clip_right ) {
+ column = candidate.position.column;
+ }
+ }
+ point.column = column;
+ }
+ // In a one-cell viewport a wide glyph is clipped entirely. Its cursor occupies
+ // the blank fallback cell, never paints half of the character.
+ const auto width = point.column == glyph.position.column
+ ? std::min<std::int64_t>( std::max( 1, glyph.columns ), glyph.clip_right - glyph.clip_left )
+ : 1;
+ if ( point.column < glyph.clip_left || point.column >= glyph.clip_right
+ || point.column + width > glyph.clip_right ) {
+ return std::nullopt;
+ }
+ return point;
+}
+
+std::vector<HorizontalScroll> DocumentLayout::reveal( const DisplayPosition& position ) const
+{
+ const auto& glyph = cursor_glyph( position );
+ auto column = glyph.position.column;
+ const auto width = std::max( 1, glyph.columns );
+ std::vector<HorizontalScroll> result;
+ for ( const auto& window : glyph.scrolls ) {
+ const auto delta
+ = column < window.left
+ ? column - window.left
+ : std::max<std::int64_t>(
+ 0, column + std::min<std::int64_t>( width, window.width ) - window.left - window.width );
+ const auto offset = std::clamp( window.offset + delta, std::int64_t { 0 }, window.maximum );
+ if ( offset != window.offset ) {
+ result.push_back( { window.block, static_cast<std::size_t>( offset ) } );
+ column -= offset - window.offset;
+ }
+ }
+ return result;
+}
+
+std::vector<HorizontalScroll> DocumentLayout::pan( const DisplayPosition& position, int direction ) const
+{
+ for ( const auto& window : cursor_glyph( position ).scrolls ) {
+ if ( window.maximum > 0 ) {
+ const auto offset = std::clamp( window.offset + direction, std::int64_t { 0 }, window.maximum );
+ if ( offset == window.offset ) {
+ return {};
+ }
+ return { { window.block, static_cast<std::size_t>( offset ) } };
+ }
+ }
+ return {};
+}
+
+DocumentRange DocumentLayout::unit( const DisplayPosition& position ) const
+{
+ const auto reading = reading_position( position );
+ if ( !reading.at_start ) {
+ return { position.point, position.point };
+ }
+ return data_.glyphs[stops_[reading.index]].source;
+}
+
void DocumentLayout::append_frame( Frame& frame,
std::size_t first_row,
CellPoint origin,
- ScreenSize viewport ) const
+ ScreenSize viewport,
+ const DocumentText& content,
+ std::optional<DocumentRange> selection,
+ const DisplayPosition& cursor,
+ std::optional<HitTarget> focus ) const
{
+ const auto selected = selection ? content.offsets( *selection ) : ByteRange {};
+ const auto caret = cursor_cell( cursor );
for ( const auto& glyph : data_.glyphs ) {
- if ( glyph.text.empty() || glyph.position.row < first_row
+ if ( glyph.position.row < first_row
|| glyph.position.row - first_row >= static_cast<std::size_t>( std::max( 0, viewport.rows ) ) ) {
continue;
}
+ bool highlighted = false;
+ if ( selection && !glyph.decoration && !glyph.boundary_only ) {
+ const auto bytes = content.offsets( glyph.source );
+ highlighted = bytes.start < selected.end && selected.start < bytes.end;
+ // A control border has an object position but contributes no decoration to copy.
+ if ( bytes.start == bytes.end && std::holds_alternative<ObjectPosition>( glyph.source.start ) ) {
+ highlighted = selected.start <= bytes.start && bytes.start < selected.end;
+ }
+ }
+ const bool newline = glyph.line_break && highlighted;
+ if ( glyph.text.empty() && !newline ) {
+ continue;
+ }
+ auto style = glyph.style;
+ style.strong = style.strong || ( focus && glyph.target == focus );
const auto left = std::max<std::int64_t>( 0, glyph.clip_left );
const auto right = std::min<std::int64_t>( viewport.columns, glyph.clip_right );
const auto row = origin.row + static_cast<int>( glyph.position.row - first_row );
+ const bool at_cursor = caret && caret->row == glyph.position.row && caret->column >= glyph.position.column
+ && caret->column < glyph.position.column + std::max( 1, glyph.columns );
if ( glyph.fill ) {
// Expanded tabs are many blank cells but keep a single source range/hit unit.
for ( auto column = std::max( left, glyph.position.column );
column < std::min( right, glyph.position.column + glyph.columns );
++column ) {
- frame.glyphs.push_back(
- { .position = { row, origin.column + static_cast<int>( column ) }, .text = L" ", .style = glyph.style } );
+ frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( column ) },
+ .text = L" ",
+ .style = style,
+ .selected = highlighted,
+ .cursor = at_cursor && column == caret->column } );
}
- } else if ( glyph.position.column >= left && glyph.position.column + glyph.columns <= right ) {
+ } else if ( glyph.position.column >= left
+ && glyph.position.column + ( newline ? 1 : glyph.columns ) <= right ) {
frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( glyph.position.column ) },
- .width = glyph.columns,
- .text = glyph.text,
- .style = glyph.style } );
+ .width = newline ? 1 : glyph.columns,
+ .text = newline ? L" " : glyph.text,
+ .style = style,
+ .selected = highlighted,
+ .cursor = at_cursor } );
}
}
}
diff --git a/src/tui/layout.hh b/src/tui/layout.hh
index 5feb0cd..7e02e76 100644
--- a/src/tui/layout.hh
+++ b/src/tui/layout.hh
@@ -31,6 +31,8 @@
namespace ricochet::tui::detail {
+class DocumentText;
+
using HitTarget = std::variant<LinkId, ControlId>;
struct LayoutPoint
@@ -46,6 +48,22 @@ struct LayoutHit
std::optional<HitTarget> 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<ScrollWindow> scrolls {};
};
struct LayoutData
@@ -88,11 +110,40 @@ public:
std::size_t rows() const;
std::optional<LayoutPoint> locate( const DisplayPosition& position ) const;
std::optional<LayoutHit> 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<HitTarget> target( const DisplayPosition& position ) 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;
+ void append_frame( Frame& frame,
+ std::size_t first_row,
+ CellPoint origin,
+ ScreenSize viewport,
+ const DocumentText& content,
+ std::optional<DocumentRange> selection,
+ const DisplayPosition& cursor,
+ std::optional<HitTarget> 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<std::size_t> stops_ {};
};
} // namespace ricochet::tui::detail
diff --git a/src/tui/paint.hh b/src/tui/paint.hh
index 549718d..43bdf89 100644
--- a/src/tui/paint.hh
+++ b/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/src/tui/screen.cc b/src/tui/screen.cc
index 34a8542..99d4e36 100644
--- a/src/tui/screen.cc
+++ b/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/src/tui/session.cc b/src/tui/session.cc
index a596c87..069c938 100644
--- a/src/tui/session.cc
+++ b/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 <algorithm>
+#include <chrono>
#include <exception>
#include <map>
#include <string_view>
@@ -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<RequestId> navigation {};
- std::optional<Document> document {};
- std::optional<detail::DocumentLayout> layout {};
- std::optional<ViewSnapshot> view {};
+ std::optional<detail::DocumentView> view {};
std::optional<ActionFailed> failure {};
+ std::optional<RequestId> 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<ViewSnapshot> 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<TabId, TabState> retained;
std::vector<TabId> 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<DocumentPoint> point;
+ if ( const auto* name = std::get_if<std::string>( &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<DocumentPoint>( 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<ControlId>( *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<DocumentVersion> 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<TerminalClosed>( &event ) ) {
result.terminal_closed = *closed;
close();
} else if ( std::holds_alternative<Resize>( event ) ) {
+ stop_drag();
dirty_ = true;
+ } else if ( const auto* tick = std::get_if<Tick>( &event ) ) {
+ if ( auto* view = active_view() ) {
+ dirty_ = view->tick( tick->now ) || dirty_;
+ }
} else if ( std::holds_alternative<Paste>( 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<TextInput>( &event ) ) {
- if ( text->character == U'q' ) {
- request_exit();
- }
+ stop_drag();
+ browse_text( text->character );
} else if ( const auto* key = std::get_if<Key>( &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<Mouse>( &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<std::size_t>( body_rows )
- && cursor->column >= 0 && cursor->column < dimensions.columns ) {
- frame.cursor
- = CellPoint { 1 + static_cast<int>( cursor->row - first_row ), static_cast<int>( 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<std::chrono::milliseconds>( 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<TabId> tab_order_ {};
std::optional<TabId> active_ {};
std::optional<RequestId> exit_request_ {};
+ std::optional<KeyPrefix> prefix_ {};
std::uint64_t next_request_ { 1 };
std::vector<UserAction> actions_ {};
std::wstring notice_ {};
@@ -392,10 +783,10 @@ std::vector<UserAction> Session::take_actions()
std::optional<ViewSnapshot> 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/src/tui/text.cc b/src/tui/text.cc
index 14f0e09..b2c64b8 100644
--- a/src/tui/text.cc
+++ b/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/src/tui/text.hh b/src/tui/text.hh
index c2d4af9..3c0b103 100644
--- a/src/tui/text.hh
+++ b/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