aboutsummaryrefslogtreecommitdiff
path: root/ricochet/src/tui
diff options
context:
space:
mode:
authorRichard Qin <richard.qin.x@gmail.com>2026-10-30 02:24:12 +0800
committerRichard Qin <richard.qin.x@gmail.com>2026-10-30 02:24:12 +0800
commit464313d02b76385d3a2e8809a4595afabca88dae (patch)
tree6cbf81416b856ff15fdc4239de5e3624ef10b2cc /ricochet/src/tui
parentcf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e (diff)
parentbdefdb301db843e87aee8c6c35808d295bb66ee5 (diff)
downloadrcc-project-master.tar.gz
rcc-project-master.tar.xz
Merge remote-tracking branch 'ricochet-import/master'HEADmaster
Diffstat (limited to 'ricochet/src/tui')
-rw-r--r--ricochet/src/tui/CMakeLists.txt10
-rw-r--r--ricochet/src/tui/document.cc30
-rw-r--r--ricochet/src/tui/document_text.cc231
-rw-r--r--ricochet/src/tui/document_text.hh66
-rw-r--r--ricochet/src/tui/document_view.cc399
-rw-r--r--ricochet/src/tui/document_view.hh117
-rw-r--r--ricochet/src/tui/editor.cc629
-rw-r--r--ricochet/src/tui/editor.hh169
-rw-r--r--ricochet/src/tui/layout.cc939
-rw-r--r--ricochet/src/tui/layout.hh164
-rw-r--r--ricochet/src/tui/mailbox.cc93
-rw-r--r--ricochet/src/tui/mailbox.hh50
-rw-r--r--ricochet/src/tui/paint.hh48
-rw-r--r--ricochet/src/tui/screen.cc601
-rw-r--r--ricochet/src/tui/screen.hh60
-rw-r--r--ricochet/src/tui/session.cc1064
-rw-r--r--ricochet/src/tui/text.cc163
-rw-r--r--ricochet/src/tui/text.hh80
18 files changed, 4913 insertions, 0 deletions
diff --git a/ricochet/src/tui/CMakeLists.txt b/ricochet/src/tui/CMakeLists.txt
new file mode 100644
index 0000000..27820e8
--- /dev/null
+++ b/ricochet/src/tui/CMakeLists.txt
@@ -0,0 +1,10 @@
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(NCURSESW REQUIRED IMPORTED_TARGET ncursesw)
+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 editor.cc document_text.cc document.cc document_view.cc mailbox.cc screen.cc session.cc text.cc layout.cc)
+target_include_directories(ricochet_tui PUBLIC ${PROJECT_SOURCE_DIR}/include)
+target_compile_features(ricochet_tui PUBLIC cxx_std_23)
+target_link_libraries(ricochet_tui PRIVATE PkgConfig::NCURSESW PkgConfig::ICU Threads::Threads)
diff --git a/ricochet/src/tui/document.cc b/ricochet/src/tui/document.cc
new file mode 100644
index 0000000..335c6d6
--- /dev/null
+++ b/ricochet/src/tui/document.cc
@@ -0,0 +1,30 @@
+/*
+ * 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 "ricochet/tui/document.hh"
+
+#include <utility>
+
+namespace ricochet::tui {
+
+Document::Document( DocumentData data ) : data_( std::make_shared<const DocumentData>( std::move( data ) ) ) {}
+
+const DocumentData& Document::data() const noexcept
+{ return *data_; }
+
+} // namespace ricochet::tui
diff --git a/ricochet/src/tui/document_text.cc b/ricochet/src/tui/document_text.cc
new file mode 100644
index 0000000..dd0c64d
--- /dev/null
+++ b/ricochet/src/tui/document_text.cc
@@ -0,0 +1,231 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "document_text.hh"
+
+#include <algorithm>
+#include <limits>
+#include <type_traits>
+
+namespace ricochet::tui::detail {
+
+std::string control_display_text( const Control& control, const TextService& text )
+{
+ return std::visit(
+ [&control, &text]( const auto& value ) -> std::string {
+ using Value = std::decay_t<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, TextControl> ) {
+ if ( value.kind == TextControlKind::Password ) {
+ return std::string( text.analyze( value.value, true, false ).graphemes.size(), '*' );
+ }
+ return value.value;
+ } else if constexpr ( std::is_same_v<Value, Checkbox> || std::is_same_v<Value, RadioButton>
+ || std::is_same_v<Value, Button> ) {
+ return control.label;
+ } else if constexpr ( std::is_same_v<Value, SelectControl> ) {
+ std::string result;
+ for ( const auto& option : value.options ) {
+ if ( std::ranges::find( value.selected, option.id ) != value.selected.end() ) {
+ if ( !result.empty() ) {
+ result += ", ";
+ }
+ result += option.label;
+ }
+ }
+ return result;
+ } else {
+ return value.description;
+ }
+ },
+ control.content );
+}
+
+DocumentText::DocumentText( const Document& document, const TextService& text ) : service_( text )
+{
+ for ( const auto& control : document.data().controls ) {
+ definitions_.emplace( control.id, &control );
+ }
+ append_blocks( document.data().blocks, 0 );
+}
+
+void DocumentText::append_text( const Text& text )
+{
+ const auto start = text_.size();
+ text_ += text.utf8;
+ blocks_.emplace( text.id, ByteRange { start, text_.size() } );
+}
+
+void DocumentText::append_control( ControlId id )
+{
+ const auto& control = *definitions_.at( id );
+ const auto start = text_.size();
+ // State is semantic content when selection crosses the object's opening boundary.
+ if ( const auto* checkbox = std::get_if<Checkbox>( &control.content ) ) {
+ text_ += checkbox->checked ? "checked " : "unchecked ";
+ } else if ( const auto* radio = std::get_if<RadioButton>( &control.content ) ) {
+ text_ += radio->checked ? "selected " : "unselected ";
+ }
+ const auto display = text_.size();
+ text_ += control_display_text( control, service_ );
+ controls_.emplace( id, ControlSpan { { start, text_.size() }, { display, text_.size() } } );
+}
+
+void DocumentText::append_inline( std::span<const Inline> content )
+{
+ for ( const auto& item : content ) {
+ std::visit(
+ [this]( const auto& value ) {
+ using Value = std::decay_t<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, Text> ) {
+ append_text( value );
+ } else {
+ append_control( value.control );
+ }
+ },
+ item );
+ }
+}
+
+void DocumentText::append_blocks( std::span<const Block> blocks, std::size_t depth )
+{
+ for ( std::size_t index = 0; index < blocks.size(); ++index ) {
+ if ( index != 0 ) {
+ text_ += "\n\n";
+ }
+ append_block( blocks[index], depth );
+ }
+}
+
+void DocumentText::append_block( const Block& block, std::size_t depth )
+{
+ const auto start = text_.size();
+ std::visit(
+ [this, depth]( const auto& value ) {
+ using Value = std::decay_t<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, Paragraph> || std::is_same_v<Value, Heading> ) {
+ append_inline( value.content );
+ } else if constexpr ( std::is_same_v<Value, Preformatted> ) {
+ append_text( value.content );
+ } else if constexpr ( std::is_same_v<Value, Alternative> ) {
+ append_text( value.description );
+ } else if constexpr ( std::is_same_v<Value, ControlPlaceholder> ) {
+ append_control( value.control );
+ } else if constexpr ( std::is_same_v<Value, List> ) {
+ auto number = value.first_number;
+ for ( std::size_t index = 0; index < value.items.size(); ++index ) {
+ if ( index != 0 ) {
+ text_ += '\n';
+ }
+ const auto& item = value.items[index];
+ const auto prefix = text_.size();
+ text_.append( depth * 2, ' ' );
+ text_ += item.marker.value_or( value.kind == ListKind::Ordered ? std::to_string( number ) + "." : "-" );
+ text_ += ' ';
+ prefixes_.push_back( { prefix, text_.size() } );
+ append_blocks( item.content, depth + 1 );
+ // Match layout's saturating counter at the model's largest list number.
+ if ( number < std::numeric_limits<std::int64_t>::max() ) {
+ ++number;
+ }
+ }
+ } else if constexpr ( std::is_same_v<Value, Table> ) {
+ for ( std::size_t row = 0; row < value.rows.size(); ++row ) {
+ if ( row != 0 ) {
+ text_ += '\n';
+ }
+ const auto& cells = value.rows[row].cells;
+ for ( std::size_t cell = 0; cell < cells.size(); ++cell ) {
+ if ( cell != 0 ) {
+ text_ += '\t';
+ }
+ append_blocks( cells[cell].content, depth );
+ }
+ }
+ }
+ // Separators carry positions but no copied decoration.
+ },
+ block.content );
+ blocks_.emplace( block.id, ByteRange { start, text_.size() } );
+}
+
+std::size_t DocumentText::offset( const DocumentPoint& point ) const
+{
+ return std::visit(
+ [this]( const auto& value ) -> std::size_t {
+ using Value = std::decay_t<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, TextPosition> ) {
+ return blocks_.at( value.block ).start + value.byte_offset;
+ } else if constexpr ( std::is_same_v<Value, ControlTextPosition> ) {
+ return controls_.at( value.control ).display.start + value.byte_offset;
+ } else if constexpr ( std::is_same_v<Value, ObjectPosition> ) {
+ const auto span = std::visit(
+ [this]( const auto& id ) -> ByteRange {
+ using Id = std::decay_t<decltype( id )>;
+ if constexpr ( std::is_same_v<Id, BlockId> ) {
+ return blocks_.at( id );
+ } else {
+ return controls_.at( id ).object;
+ }
+ },
+ value.object );
+ return value.side == BoundarySide::Before ? span.start : span.end;
+ } else {
+ return 0;
+ }
+ },
+ point );
+}
+
+ByteRange DocumentText::offsets( const DocumentRange& range ) const
+{ return { offset( range.start ), offset( range.end ) }; }
+
+SelectionSnapshot DocumentText::select( const DocumentRange& anchor, const DocumentRange& active ) const
+{
+ const bool backward = offset( active.start ) < offset( anchor.start );
+ // A zero-width boundary can coincide with a nonempty endpoint unit. Keep the
+ // whole anchored unit even when the active endpoint comes back to that boundary.
+ return { .range = { backward ? active.start : anchor.start,
+ offset( active.end ) > offset( anchor.end ) ? active.end : anchor.end },
+ .direction = backward ? SelectionDirection::Backward : SelectionDirection::Forward,
+ .anchor = anchor,
+ .active = active };
+}
+
+std::string DocumentText::extract( const DocumentRange& range ) const
+{
+ auto bytes = offsets( range );
+ if ( bytes.start == bytes.end ) {
+ return {};
+ }
+ // Include a semantic marker only when selection includes the item's start.
+ // Inner prefixes expand first, including a surrounding item with a nested list.
+ for ( auto prefix = prefixes_.rbegin(); prefix != prefixes_.rend(); ++prefix ) {
+ if ( prefix->end == bytes.start ) {
+ bytes.start = prefix->start;
+ }
+ }
+ auto result = text_.substr( bytes.start, bytes.end - bytes.start );
+ // CRLF is one hard-break grapheme; copied hard breaks use one LF.
+ for ( auto position = result.find( "\r\n" ); position != std::string::npos;
+ position = result.find( "\r\n", position + 1 ) ) {
+ result.erase( position, 1 );
+ }
+ return result;
+}
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/document_text.hh b/ricochet/src/tui/document_text.hh
new file mode 100644
index 0000000..d490c9d
--- /dev/null
+++ b/ricochet/src/tui/document_text.hh
@@ -0,0 +1,66 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/tui/document.hh"
+#include "ricochet/tui/view.hh"
+#include "text.hh"
+
+#include <map>
+#include <span>
+#include <string>
+
+namespace ricochet::tui::detail {
+
+// Shared by layout and extraction: password positions address only this mask.
+std::string control_display_text( const Control& control, const TextService& text );
+
+// Logical reading-order text, independent of wrapping, clipping and decorations.
+// Owned together with one immutable document and its revision-zero control values.
+class DocumentText
+{
+public:
+ DocumentText( const Document& document, const TextService& text );
+ ByteRange offsets( const DocumentRange& range ) const;
+ SelectionSnapshot select( const DocumentRange& anchor, const DocumentRange& active ) const;
+ std::string extract( const DocumentRange& range ) const;
+
+private:
+ struct ControlSpan
+ {
+ ByteRange object {};
+ ByteRange display {};
+ };
+ std::size_t offset( const DocumentPoint& point ) const;
+ void append_blocks( std::span<const Block> blocks, std::size_t depth );
+ void append_block( const Block& block, std::size_t depth );
+ void append_inline( std::span<const Inline> content );
+ void append_text( const Text& text );
+ void append_control( ControlId id );
+
+ std::string text_ {};
+ std::map<BlockId, ByteRange> blocks_ {};
+ std::map<ControlId, ControlSpan> controls_ {};
+ std::map<ControlId, const Control*> definitions_ {};
+ // Prefix ends coincide with item starts; selecting that start includes its marker.
+ std::vector<ByteRange> prefixes_ {};
+ const TextService& service_;
+};
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/document_view.cc b/ricochet/src/tui/document_view.cc
new file mode 100644
index 0000000..13a5d5f
--- /dev/null
+++ b/ricochet/src/tui/document_view.cc
@@ -0,0 +1,399 @@
+/*
+ * 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 ); }
+
+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;
+ 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/ricochet/src/tui/document_view.hh b/ricochet/src/tui/document_view.hh
new file mode 100644
index 0000000..5f5e331
--- /dev/null
+++ b/ricochet/src/tui/document_view.hh
@@ -0,0 +1,117 @@
+/*
+ * 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
+};
+
+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
+{
+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;
+ // 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:
+ 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/ricochet/src/tui/editor.cc b/ricochet/src/tui/editor.cc
new file mode 100644
index 0000000..aa0545b
--- /dev/null
+++ b/ricochet/src/tui/editor.cc
@@ -0,0 +1,629 @@
+/*
+ * 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 "editor.hh"
+
+#include <algorithm>
+#include <limits>
+#include <utility>
+
+namespace ricochet::tui::detail {
+namespace {
+
+ScreenSize editor_size( ScreenSize viewport )
+{ return { std::max( 1, viewport.rows ), std::max( 1, viewport.columns ) }; }
+
+} // namespace
+
+TextEditor::TextEditor( std::string value, EditorOptions options, const TextService& text, ScreenSize viewport )
+ : options_( options ), text_( text ), viewport_( editor_size( viewport ) )
+{
+ state_.value = std::move( value );
+ state_.cursor = state_.value.size();
+ analyze();
+ desired_column_ = carets_.back().column;
+ reveal();
+}
+
+const std::string& TextEditor::value() const
+{ return state_.value; }
+std::uint64_t TextEditor::revision() const
+{ return revision_; }
+std::size_t TextEditor::cursor() const
+{ return state_.cursor; }
+
+ByteRange TextEditor::selection() const
+{
+ const auto anchor = state_.anchor.value_or( state_.cursor );
+ return { std::min( anchor, state_.cursor ), std::max( anchor, state_.cursor ) };
+}
+
+std::optional<std::string> TextEditor::copy_selection()
+{
+ killing_ = false;
+ prefix_.reset();
+ const auto range = selection();
+ if ( options_.kind == EditorKind::Password || range.start == range.end ) {
+ return std::nullopt;
+ }
+ return state_.value.substr( range.start, range.end - range.start );
+}
+
+std::optional<std::chrono::steady_clock::time_point> TextEditor::prefix_deadline() const
+{ return prefix_; }
+
+std::string TextEditor::normalize( std::string_view value ) const
+{
+ std::string result;
+ result.reserve( value.size() );
+ for ( std::size_t index = 0; index < value.size(); ++index ) {
+ const auto character = value[index];
+ if ( character == '\r' ) {
+ if ( index + 1 < value.size() && value[index + 1] == '\n' ) {
+ ++index;
+ }
+ result += options_.kind == EditorKind::MultiLine ? '\n' : ' ';
+ } else if ( character == '\n' && options_.kind != EditorKind::MultiLine ) {
+ result += ' ';
+ } else {
+ result += character;
+ }
+ }
+ return result;
+}
+
+std::size_t TextEditor::unit_index() const
+{
+ return static_cast<std::size_t>(
+ std::ranges::lower_bound(
+ analysis_.graphemes, state_.cursor, {}, []( const auto& glyph ) { return glyph.bytes.start; } )
+ - analysis_.graphemes.begin() );
+}
+
+std::size_t TextEditor::boundary( std::size_t index ) const
+{ return index == analysis_.graphemes.size() ? state_.value.size() : analysis_.graphemes[index].bytes.start; }
+
+std::size_t TextEditor::line_start() const
+{
+ const auto newline
+ = std::find_if( analysis_.graphemes.rbegin(), analysis_.graphemes.rend(), [this]( const auto& unit ) {
+ return unit.kind == TextUnitKind::LineBreak && unit.bytes.end <= state_.cursor;
+ } );
+ return newline == analysis_.graphemes.rend() ? 0 : newline->bytes.end;
+}
+
+std::size_t TextEditor::line_end() const
+{
+ const auto newline = std::ranges::find_if( analysis_.graphemes, [this]( const auto& unit ) {
+ return unit.kind == TextUnitKind::LineBreak && unit.bytes.start >= state_.cursor;
+ } );
+ return newline == analysis_.graphemes.end() ? state_.value.size() : newline->bytes.start;
+}
+
+void TextEditor::analyze()
+{
+ analysis_ = text_.analyze( state_.value, options_.kind == EditorKind::MultiLine, true );
+ layout();
+}
+
+void TextEditor::layout()
+{
+ glyphs_.clear();
+ carets_.assign( analysis_.graphemes.size() + 1, {} );
+ Point point;
+ for ( std::size_t index = 0; index < analysis_.graphemes.size(); ++index ) {
+ const auto& unit = analysis_.graphemes[index];
+ const bool password = options_.kind == EditorKind::Password;
+ if ( unit.kind == TextUnitKind::LineBreak && !password ) {
+ // A hard break after a full row stays on that row. Reuse the preceding
+ // complete glyph's cell for its insertion cursor instead of wrapping twice.
+ carets_[index] = point.column == viewport_.columns ? glyphs_.back().point : point;
+ glyphs_.push_back( { point, 0 } );
+ ++point.row;
+ point.column = 0;
+ } else {
+ const auto width_at = [&]( std::int64_t column ) {
+ const auto width = password ? 1
+ : unit.kind == TextUnitKind::Tab ? 8 - static_cast<int>( column % 8 )
+ : unit.columns;
+ return options_.kind == EditorKind::MultiLine ? std::min( width, viewport_.columns ) : width;
+ };
+ int width = width_at( point.column );
+ if ( options_.kind == EditorKind::MultiLine && point.column > 0
+ && point.column + width > viewport_.columns ) {
+ ++point.row;
+ point.column = 0;
+ width = width_at( 0 );
+ }
+ carets_[index] = point;
+ glyphs_.push_back( { point, width } );
+ point.column += width;
+ }
+ carets_[index + 1] = point;
+ }
+ // A full final soft row needs a real insertion cell after its final grapheme.
+ if ( options_.kind == EditorKind::MultiLine && point.column == viewport_.columns ) {
+ carets_.back() = { point.row + 1, 0 };
+ }
+}
+
+void TextEditor::reveal()
+{
+ const auto point = carets_[unit_index()];
+ const auto height = static_cast<std::size_t>( viewport_.rows );
+ const auto rows = carets_.back().row + 1;
+ top_row_ = std::min( top_row_, rows - std::min( rows, height ) );
+ if ( point.row < top_row_ ) {
+ top_row_ = point.row;
+ } else if ( point.row - top_row_ >= height ) {
+ top_row_ = point.row - height + 1;
+ }
+ if ( options_.kind == EditorKind::MultiLine ) {
+ left_column_ = 0;
+ } else {
+ left_column_ = std::min( left_column_, point.column );
+ left_column_ = std::max( left_column_, point.column - viewport_.columns + 1 );
+ }
+}
+
+void TextEditor::resize( ScreenSize viewport )
+{
+ const auto next = editor_size( viewport );
+ if ( next == viewport_ ) {
+ return;
+ }
+ viewport_ = next;
+ layout();
+ desired_column_ = carets_[unit_index()].column;
+ reveal();
+}
+
+std::size_t TextEditor::nearest( Point point ) const
+{
+ std::size_t chosen = 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 < carets_.size(); ++index ) {
+ const auto& candidate = carets_[index];
+ const auto delta = std::pair {
+ candidate.row > point.row ? candidate.row - point.row : point.row - candidate.row,
+ candidate.column > point.column ? candidate.column - point.column : point.column - candidate.column };
+ if ( delta < distance ) {
+ chosen = index;
+ distance = delta;
+ }
+ }
+ return boundary( chosen );
+}
+
+void TextEditor::move( std::size_t destination, bool extend, bool preserve_column )
+{
+ if ( extend || state_.selecting ) {
+ state_.anchor = state_.anchor.value_or( state_.cursor );
+ } else {
+ state_.anchor.reset();
+ }
+ state_.cursor = destination;
+ if ( !preserve_column ) {
+ desired_column_ = carets_[unit_index()].column;
+ }
+ reveal();
+}
+
+void TextEditor::replace( ByteRange range, std::string_view replacement )
+{
+ auto next = state_.value;
+ next.replace( range.start, range.end - range.start, replacement );
+ if ( next != state_.value ) {
+ past_.push_back( state_ );
+ future_.clear();
+ ++revision_;
+ }
+ state_.value = std::move( next );
+ state_.cursor = range.start + replacement.size();
+ state_.anchor.reset();
+ state_.selecting = false;
+ analyze();
+ // Insertion/deletion can join surrounding graphemes. Snap to the next boundary
+ // in the new text, never retain an offset inside a new combining/ZWJ cluster.
+ state_.cursor = boundary( unit_index() );
+ desired_column_ = carets_[unit_index()].column;
+ reveal();
+}
+
+void TextEditor::insert( std::u32string_view input )
+{
+ killing_ = false;
+ prefix_.reset();
+ if ( input.empty() ) {
+ return; // An empty paste must not delete an existing selection.
+ }
+ const auto value = normalize( text_.encode( input ) );
+ replace( selection(), value );
+}
+
+void TextEditor::replace_value( std::string value )
+{
+ killing_ = false;
+ prefix_.reset();
+ replace( { 0, state_.value.size() }, value );
+}
+
+void TextEditor::kill( ByteRange range )
+{
+ if ( range.start == range.end ) {
+ return; // Empty kills preserve both the buffer and the current kill chain.
+ }
+ auto removed = state_.value.substr( range.start, range.end - range.start );
+ if ( !killing_ ) {
+ killed_ = std::move( removed );
+ } else if ( range.end <= state_.cursor ) {
+ killed_.insert( 0, removed );
+ } else {
+ killed_ += removed;
+ }
+ replace( range, {} );
+ killing_ = true;
+}
+
+void TextEditor::undo( bool redo )
+{
+ auto& source = redo ? future_ : past_;
+ auto& destination = redo ? past_ : future_;
+ if ( source.empty() ) {
+ return;
+ }
+ destination.push_back( std::move( state_ ) );
+ state_ = std::move( source.back() );
+ source.pop_back();
+ ++revision_;
+ analyze();
+ desired_column_ = carets_[unit_index()].column;
+ reveal();
+}
+
+void TextEditor::execute( EditorCommand command, bool extend )
+{
+ prefix_.reset();
+ const bool killing = command == EditorCommand::KillToStart || command == EditorCommand::KillToEnd
+ || command == EditorCommand::KillWord;
+ killing_ = killing_ && killing;
+ const auto range = selection();
+ auto index = unit_index();
+ switch ( command ) {
+ case EditorCommand::Left:
+ move( !extend && !state_.selecting && range.start != range.end ? range.start
+ : boundary( index == 0 ? 0 : index - 1 ),
+ extend,
+ false );
+ break;
+ case EditorCommand::Right:
+ move( !extend && !state_.selecting && range.start != range.end
+ ? range.end
+ : boundary( std::min( index + 1, analysis_.graphemes.size() ) ),
+ extend,
+ false );
+ break;
+ case EditorCommand::Up:
+ case EditorCommand::Down: {
+ if ( options_.kind != EditorKind::MultiLine ) {
+ break;
+ }
+ auto row = carets_[index].row;
+ row = command == EditorCommand::Up ? ( row == 0 ? 0 : row - 1 ) : std::min( row + 1, carets_.back().row );
+ move( nearest( { row, desired_column_ } ), extend, true );
+ break;
+ }
+ case EditorCommand::LineStart:
+ move( line_start(), extend, false );
+ break;
+ case EditorCommand::LineEnd:
+ move( line_end(), extend, false );
+ break;
+ case EditorCommand::PreviousWord: {
+ const auto word = std::find_if( analysis_.words.rbegin(), analysis_.words.rend(), [this]( const auto& item ) {
+ return item.start < state_.cursor;
+ } );
+ move( word == analysis_.words.rend() ? 0 : word->start, extend, false );
+ break;
+ }
+ case EditorCommand::NextWord: {
+ const auto word
+ = std::ranges::find_if( analysis_.words, [this]( const auto& item ) { return item.end > state_.cursor; } );
+ move( word == analysis_.words.end() ? state_.value.size() : word->end, extend, false );
+ break;
+ }
+ case EditorCommand::Backspace:
+ replace( range.start != range.end ? range
+ : ByteRange { boundary( index == 0 ? 0 : index - 1 ), state_.cursor },
+ {} );
+ break;
+ case EditorCommand::Delete:
+ replace( range.start != range.end
+ ? range
+ : ByteRange { state_.cursor, boundary( std::min( index + 1, analysis_.graphemes.size() ) ) },
+ {} );
+ break;
+ case EditorCommand::KillToStart:
+ kill( range.start != range.end ? range : ByteRange { line_start(), state_.cursor } );
+ break;
+ case EditorCommand::KillToEnd:
+ kill( range.start != range.end ? range : ByteRange { state_.cursor, line_end() } );
+ break;
+ case EditorCommand::KillWord:
+ while ( index > 0 && analysis_.graphemes[index - 1].word == WordKind::Space ) {
+ --index;
+ }
+ while ( index > 0 && analysis_.graphemes[index - 1].word != WordKind::Space ) {
+ --index;
+ }
+ kill( range.start != range.end ? range : ByteRange { boundary( index ), state_.cursor } );
+ break;
+ case EditorCommand::Yank:
+ if ( !killed_.empty() ) {
+ replace( range, killed_ );
+ }
+ break;
+ case EditorCommand::BeginSelection:
+ state_.anchor = state_.cursor;
+ state_.selecting = true;
+ break;
+ case EditorCommand::SelectAll:
+ state_.anchor = 0;
+ state_.cursor = state_.value.size();
+ state_.selecting = false;
+ desired_column_ = carets_.back().column;
+ reveal();
+ break;
+ case EditorCommand::CancelSelection:
+ state_.anchor.reset();
+ state_.selecting = false;
+ break;
+ case EditorCommand::Clear:
+ replace( { 0, state_.value.size() }, {} );
+ break;
+ case EditorCommand::Newline:
+ if ( options_.kind == EditorKind::MultiLine ) {
+ replace( range, "\n" );
+ }
+ break;
+ case EditorCommand::Undo:
+ undo( false );
+ break;
+ case EditorCommand::Redo:
+ undo( true );
+ break;
+ }
+}
+
+void TextEditor::place( CellPoint point, bool extend )
+{
+ prefix_.reset();
+ killing_ = false;
+ // The owner routes body clicks/dragging; outside drag coordinates clamp to this viewport.
+ const Point location { top_row_ + static_cast<std::size_t>( std::clamp( point.row, 0, viewport_.rows - 1 ) ),
+ left_column_ + std::clamp( point.column, 0, viewport_.columns - 1 ) };
+ state_.selecting = false;
+ move( nearest( location ), extend, false );
+}
+
+EditorIntent TextEditor::key( const Key& input, std::chrono::steady_clock::time_point now )
+{
+ if ( prefix_ ) {
+ prefix_.reset();
+ if ( input.code == KeyCode::Character && input.modifiers == Modifiers { .control = true }
+ && input.character == U'u' ) {
+ execute( EditorCommand::Undo );
+ }
+ return EditorIntent::Consumed; // Mismatches never execute a second command.
+ }
+ const bool kill_key = input.code == KeyCode::Character && input.modifiers == Modifiers { .control = true }
+ && ( input.character == U'u' || input.character == U'k' || input.character == U'w' );
+ killing_ = killing_ && kill_key;
+ if ( input.code == KeyCode::Character && input.modifiers == Modifiers { .control = true } ) {
+ switch ( input.character ) {
+ case U'b':
+ execute( EditorCommand::Left );
+ break;
+ case U'f':
+ execute( EditorCommand::Right );
+ break;
+ case U'p':
+ case U'n':
+ if ( options_.kind == EditorKind::SingleLine && options_.history ) {
+ return input.character == U'p' ? EditorIntent::HistoryPrevious : EditorIntent::HistoryNext;
+ }
+ execute( input.character == U'p' ? EditorCommand::Up : EditorCommand::Down );
+ break;
+ case U'a':
+ execute( EditorCommand::LineStart );
+ break;
+ case U'e':
+ execute( EditorCommand::LineEnd );
+ break;
+ case U'd':
+ execute( EditorCommand::Delete );
+ break;
+ case U'u':
+ execute( EditorCommand::KillToStart );
+ break;
+ case U'k':
+ execute( EditorCommand::KillToEnd );
+ break;
+ case U'w':
+ execute( EditorCommand::KillWord );
+ break;
+ case U'y':
+ execute( EditorCommand::Yank );
+ break;
+ case U'_':
+ execute( EditorCommand::Undo );
+ break;
+ case U'x':
+ prefix_ = now + std::chrono::seconds( 1 );
+ break;
+ case U'l':
+ return EditorIntent::Redraw;
+ case U'c':
+ return EditorIntent::Cancel;
+ default:
+ return EditorIntent::Unhandled;
+ }
+ return EditorIntent::Consumed;
+ }
+ if ( input.code == KeyCode::Character && input.modifiers == Modifiers { .alt = true } ) {
+ switch ( input.character ) {
+ case U'b':
+ execute( EditorCommand::PreviousWord );
+ return EditorIntent::Consumed;
+ case U'f':
+ execute( EditorCommand::NextWord );
+ return EditorIntent::Consumed;
+ default:
+ return EditorIntent::Unhandled;
+ }
+ }
+ if ( input.modifiers != Modifiers {} && input.modifiers != Modifiers { .shift = true } ) {
+ return EditorIntent::Unhandled;
+ }
+ switch ( input.code ) {
+ case KeyCode::Left:
+ execute( EditorCommand::Left, input.modifiers.shift );
+ break;
+ case KeyCode::Right:
+ execute( EditorCommand::Right, input.modifiers.shift );
+ break;
+ case KeyCode::Up:
+ case KeyCode::Down:
+ if ( options_.kind == EditorKind::SingleLine && options_.history && !input.modifiers.shift ) {
+ return input.code == KeyCode::Up ? EditorIntent::HistoryPrevious : EditorIntent::HistoryNext;
+ }
+ execute( input.code == KeyCode::Up ? EditorCommand::Up : EditorCommand::Down, input.modifiers.shift );
+ break;
+ case KeyCode::Home:
+ execute( EditorCommand::LineStart, input.modifiers.shift );
+ break;
+ case KeyCode::End:
+ execute( EditorCommand::LineEnd, input.modifiers.shift );
+ break;
+ case KeyCode::Tab:
+ return input.modifiers.shift ? EditorIntent::BackTab : EditorIntent::Tab;
+ default:
+ if ( input.modifiers.shift ) {
+ return EditorIntent::Unhandled;
+ }
+ switch ( input.code ) {
+ case KeyCode::Backspace:
+ execute( EditorCommand::Backspace );
+ break;
+ case KeyCode::Delete:
+ execute( EditorCommand::Delete );
+ break;
+ case KeyCode::Enter:
+ return EditorIntent::Enter;
+ case KeyCode::Escape:
+ return EditorIntent::Escape;
+ case KeyCode::Function:
+ return input.function == 10 ? EditorIntent::Menu : EditorIntent::Unhandled;
+ default:
+ return EditorIntent::Unhandled;
+ }
+ break;
+ }
+ return EditorIntent::Consumed;
+}
+
+EditorIntent TextEditor::handle( const InputEvent& event, std::chrono::steady_clock::time_point now )
+{
+ if ( const auto* tick = std::get_if<Tick>( &event ) ) {
+ now = tick->now;
+ }
+ if ( prefix_ && now >= *prefix_ ) {
+ prefix_.reset();
+ }
+ if ( const auto* input = std::get_if<Key>( &event ) ) {
+ return key( *input, now );
+ }
+ if ( const auto* input = std::get_if<TextInput>( &event ) ) {
+ killing_ = false;
+ if ( prefix_ ) {
+ prefix_.reset();
+ } else {
+ insert( std::u32string_view( &input->character, 1 ) );
+ }
+ return EditorIntent::Consumed;
+ }
+ if ( const auto* paste = std::get_if<Paste>( &event ) ) {
+ insert( paste->text );
+ return EditorIntent::Consumed;
+ }
+ if ( std::holds_alternative<Tick>( event ) ) {
+ return EditorIntent::Consumed;
+ }
+ return EditorIntent::Unhandled;
+}
+
+void TextEditor::draw( Frame& frame, CellPoint origin ) const
+{
+ // Fields can overlay an already painted document. Clear their entire region so
+ // shortening the value cannot expose the old field text beneath the editor.
+ for ( int row = 0; row < viewport_.rows; ++row ) {
+ for ( int column = 0; column < viewport_.columns; ++column ) {
+ frame.glyphs.push_back( { .position = { origin.row + row, origin.column + column }, .text = L" " } );
+ }
+ }
+ const auto selected = selection();
+ const auto cursor_point = carets_[unit_index()];
+ for ( std::size_t index = 0; index < glyphs_.size(); ++index ) {
+ const auto& glyph = glyphs_[index];
+ if ( glyph.point.row < top_row_ || glyph.point.row - top_row_ >= static_cast<std::size_t>( viewport_.rows ) ) {
+ continue;
+ }
+ const auto& unit = analysis_.graphemes[index];
+ const bool password = options_.kind == EditorKind::Password;
+ const bool selected_unit = unit.bytes.start < selected.end && selected.start < unit.bytes.end;
+ const int row = origin.row + static_cast<int>( glyph.point.row - top_row_ );
+ const auto column = glyph.point.column - left_column_;
+ const bool at_cursor = cursor_point.row == glyph.point.row && cursor_point.column == glyph.point.column;
+ if ( unit.kind == TextUnitKind::Tab && !password ) {
+ for ( auto cell = std::max<std::int64_t>( 0, column );
+ cell < std::min<std::int64_t>( viewport_.columns, column + glyph.columns );
+ ++cell ) {
+ frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( cell ) },
+ .text = L" ",
+ .selected = selected_unit,
+ .cursor = at_cursor && cell == column } );
+ }
+ } else {
+ const bool newline = unit.kind == TextUnitKind::LineBreak;
+ const int width = newline ? 1 : glyph.columns;
+ if ( column < 0 || column + width > viewport_.columns || ( newline && !selected_unit ) ) {
+ continue;
+ }
+ const auto display = password ? L"*" : newline ? L" " : glyph.columns < unit.columns ? L"?" : unit.display;
+ frame.glyphs.push_back( { .position = { row, origin.column + static_cast<int>( column ) },
+ .width = width,
+ .text = display,
+ .selected = selected_unit,
+ .cursor = at_cursor } );
+ }
+ }
+ frame.cursor = CellPoint { origin.row + static_cast<int>( cursor_point.row - top_row_ ),
+ origin.column + static_cast<int>( cursor_point.column - left_column_ ) };
+}
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/editor.hh b/ricochet/src/tui/editor.hh
new file mode 100644
index 0000000..457b0ca
--- /dev/null
+++ b/ricochet/src/tui/editor.hh
@@ -0,0 +1,169 @@
+/*
+ * 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 "paint.hh"
+#include "text.hh"
+
+#include <chrono>
+#include <optional>
+
+namespace ricochet::tui::detail {
+
+enum class EditorKind : std::uint8_t
+{
+ SingleLine,
+ MultiLine,
+ Password
+};
+
+struct EditorOptions
+{
+ EditorKind kind { EditorKind::SingleLine };
+ // The owner supplies history; password and multiline editors never request it.
+ bool history { false };
+};
+
+enum class EditorCommand : std::uint8_t
+{
+ Left,
+ Right,
+ Up,
+ Down,
+ LineStart,
+ LineEnd,
+ PreviousWord,
+ NextWord,
+ Backspace,
+ Delete,
+ KillToStart,
+ KillToEnd,
+ KillWord,
+ Yank,
+ BeginSelection,
+ SelectAll,
+ CancelSelection,
+ Clear,
+ Newline,
+ Undo,
+ Redo
+};
+
+// Enter/Tab/Escape are context decisions, never implicit submit/finish operations.
+// The owner may translate Enter into Newline for a multiline field.
+// Unhandled keys stay with the owning editor context, never fall through to Browse.
+enum class EditorIntent : std::uint8_t
+{
+ Consumed,
+ Unhandled,
+ Enter,
+ Tab,
+ BackTab,
+ Escape,
+ Cancel,
+ Menu,
+ Redraw,
+ HistoryPrevious,
+ HistoryNext
+};
+
+// One editing session. Owners recreate it when changing fields; its text, undo
+// history and kill buffer are never shared with another editor or a view snapshot.
+class TextEditor
+{
+public:
+ TextEditor( std::string value, EditorOptions options, const TextService& text, ScreenSize viewport );
+ TextEditor( const TextEditor& ) = delete;
+ TextEditor& operator=( const TextEditor& ) = delete;
+ TextEditor( TextEditor&& ) = delete;
+ TextEditor& operator=( TextEditor&& ) = delete;
+ ~TextEditor() = default;
+
+ // Business value for the owning field; rendering and copying use the methods below.
+ const std::string& value() const;
+ std::uint64_t revision() const;
+ std::size_t cursor() const;
+ ByteRange selection() const;
+ std::optional<std::string> copy_selection();
+ std::optional<std::chrono::steady_clock::time_point> prefix_deadline() const;
+
+ void resize( ScreenSize viewport );
+ void insert( std::u32string_view input );
+ // Host history replacement is one undoable edit, not a new editing session.
+ void replace_value( std::string value );
+ void execute( EditorCommand command, bool extend = false );
+ EditorIntent handle( const InputEvent& event,
+ std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now() );
+ // Region-local mouse positioning, with extend=true while selecting/dragging.
+ void place( CellPoint point, bool extend );
+ void draw( Frame& frame, CellPoint origin ) const;
+
+private:
+ struct State
+ {
+ std::string value {};
+ std::size_t cursor { 0 };
+ std::optional<std::size_t> anchor {};
+ bool selecting { false };
+ };
+ struct Point
+ {
+ std::size_t row { 0 };
+ std::int64_t column { 0 };
+ };
+ struct Glyph
+ {
+ Point point {};
+ int columns { 0 };
+ };
+
+ std::string normalize( std::string_view value ) const;
+ std::size_t unit_index() const;
+ std::size_t boundary( std::size_t index ) const;
+ std::size_t line_start() const;
+ std::size_t line_end() const;
+ std::size_t nearest( Point point ) const;
+ void analyze();
+ void layout();
+ void reveal();
+ void move( std::size_t destination, bool extend, bool preserve_column );
+ void replace( ByteRange range, std::string_view replacement );
+ void kill( ByteRange range );
+ void undo( bool redo );
+ EditorIntent key( const Key& key, std::chrono::steady_clock::time_point now );
+
+ EditorOptions options_;
+ const TextService& text_;
+ ScreenSize viewport_;
+ State state_ {};
+ TextAnalysis analysis_ {};
+ std::vector<Glyph> glyphs_ {};
+ std::vector<Point> carets_ {};
+ std::size_t top_row_ { 0 };
+ std::int64_t left_column_ { 0 };
+ std::int64_t desired_column_ { 0 };
+ std::vector<State> past_ {};
+ std::vector<State> future_ {};
+ std::string killed_ {};
+ bool killing_ { false };
+ std::uint64_t revision_ { 0 };
+ std::optional<std::chrono::steady_clock::time_point> prefix_ {};
+};
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/layout.cc b/ricochet/src/tui/layout.cc
new file mode 100644
index 0000000..46bd626
--- /dev/null
+++ b/ricochet/src/tui/layout.cc
@@ -0,0 +1,939 @@
+/*
+ * 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 "layout.hh"
+#include "document_text.hh"
+
+#include <algorithm>
+#include <limits>
+#include <map>
+#include <numeric>
+#include <set>
+#include <stdexcept>
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <type_traits>
+#include <utility>
+
+namespace ricochet::tui::detail {
+namespace {
+
+constexpr int tab_columns = 8;
+constexpr std::int64_t cell_gap = 3;
+constexpr std::int64_t minimum_cell_columns = 4;
+
+TextStyle overlay( TextStyle base, TextStyle extra )
+{
+ return { base.emphasis || extra.emphasis,
+ base.strong || extra.strong,
+ base.code || extra.code,
+ base.subdued || extra.subdued,
+ base.warning || extra.warning };
+}
+
+struct Atom
+{
+ Grapheme glyph {};
+ DocumentRange source {};
+ TextStyle style {};
+ std::optional<HitTarget> target {};
+ bool decoration { false };
+};
+
+struct TextSpan
+{
+ const Text* leaf;
+ ByteRange bytes;
+};
+
+DocumentPoint text_point( std::span<const TextSpan> spans, std::size_t offset, bool end )
+{
+ for ( const auto& span : spans ) {
+ // At an inline seam an end belongs to the preceding leaf, a start to the next.
+ if ( offset < span.bytes.end || ( end && offset == span.bytes.end ) ) {
+ return TextPosition { span.leaf->id, offset - span.bytes.start };
+ }
+ }
+ const auto& last = spans.back();
+ return TextPosition { last.leaf->id, last.leaf->utf8.size() };
+}
+
+LayoutGlyph anchor( DocumentPoint point, LayoutPoint location, std::int64_t width )
+{ return { .position = location, .source = { point, point }, .clip_right = width }; }
+
+void append_box( LayoutData& destination, LayoutData source, std::size_t row, std::int64_t column )
+{
+ destination.rows = std::max( destination.rows, row + source.rows );
+ destination.extent = std::max( destination.extent, column + source.extent );
+ for ( auto& glyph : source.glyphs ) {
+ glyph.position.row += row;
+ glyph.position.column += column;
+ glyph.line_start += column;
+ for ( auto& window : glyph.scrolls ) {
+ window.left += column;
+ }
+ glyph.clip_left += column;
+ glyph.clip_right += column;
+ destination.glyphs.push_back( std::move( glyph ) );
+ }
+}
+
+class LayoutBuilder
+{
+public:
+ LayoutBuilder( const DocumentData& document, const TextService& text, std::span<const HorizontalScroll> scrolls )
+ : document_( document ), text_( text ), scrolls_( scrolls )
+ {
+ for ( const auto& control : document.controls ) {
+ controls_.emplace( control.id, &control );
+ }
+ }
+
+ LayoutData blocks( std::span<const Block> content, std::int64_t width, TextStyle style = {} ) const
+ {
+ LayoutData result;
+ std::size_t row = 0;
+ for ( const auto& block : content ) {
+ auto box = block_box( block, width, style );
+ const auto height = box.rows;
+ append_box( result, std::move( box ), row, 0 );
+ row += height;
+ }
+ return result;
+ }
+
+private:
+ void text_run( std::vector<Atom>& atoms,
+ std::span<const TextSpan> spans,
+ const std::string& utf8,
+ bool tabs,
+ TextStyle base ) const
+ {
+ const auto analysis = text_.analyze( utf8, true, tabs );
+ for ( const auto& glyph : analysis.graphemes ) {
+ Atom atom { .glyph = glyph,
+ .source
+ = { text_point( spans, glyph.bytes.start, false ), text_point( spans, glyph.bytes.end, true ) },
+ .style = base };
+ for ( const auto& span : spans ) {
+ // Only source portions intersecting this grapheme can contribute style/link.
+ if ( span.bytes.start >= glyph.bytes.end || span.bytes.end <= glyph.bytes.start ) {
+ continue;
+ }
+ const ByteRange local { std::max( span.bytes.start, glyph.bytes.start ) - span.bytes.start,
+ std::min( span.bytes.end, glyph.bytes.end ) - span.bytes.start };
+ for ( const auto& styled : span.leaf->styles ) {
+ if ( styled.bytes.start < local.end && local.start < styled.bytes.end ) {
+ atom.style = overlay( atom.style, styled.style );
+ }
+ }
+ for ( const auto& link : document_.links ) {
+ for ( const auto& range : link.ranges ) {
+ if ( range.block == span.leaf->id && range.bytes.start < local.end && local.start < range.bytes.end ) {
+ const HitTarget target = link.id;
+ // One grapheme cannot activate two links, including across leaf seams.
+ if ( atom.target && atom.target != target ) {
+ throw std::invalid_argument( "Overlapping links address the same grapheme" );
+ }
+ atom.target = target;
+ atom.style.emphasis = true;
+ }
+ }
+ }
+ }
+ atoms.push_back( std::move( atom ) );
+ }
+ // Empty text still has a real byte boundary; no fabricated display character.
+ if ( utf8.empty() ) {
+ const auto point = text_point( spans, 0, false );
+ atoms.push_back( { .glyph = { .columns = 0 }, .source = { point, point }, .style = base } );
+ }
+ }
+
+ void control_atoms( std::vector<Atom>& atoms, ControlId id, TextStyle base ) const
+ {
+ // A missing ID is a malformed document reference; map::at reports it directly.
+ const auto& control = *controls_.at( id );
+ const auto* input = std::get_if<TextControl>( &control.content );
+ const bool multiline = input && input->kind == TextControlKind::MultiLine;
+ const auto before = ObjectPosition { id, BoundarySide::Before };
+ const auto after = ObjectPosition { id, BoundarySide::After };
+ const auto style = overlay( base, { .subdued = control.disabled, .warning = control.error.has_value() } );
+ std::wstring_view opening = L"[";
+ std::wstring_view closing = L"]";
+ if ( const auto* checkbox = std::get_if<Checkbox>( &control.content ) ) {
+ opening = checkbox->checked ? L"[x] " : L"[ ] ";
+ closing = L"";
+ } else if ( const auto* radio = std::get_if<RadioButton>( &control.content ) ) {
+ opening = radio->checked ? L"(*) " : L"( ) ";
+ closing = L"";
+ }
+ for ( const auto character : opening ) {
+ atoms.push_back( { .glyph = { .display = std::wstring( 1, character ) },
+ .source = { before, before },
+ .style = style,
+ .target = id } );
+ }
+ const auto display = control_display_text( control, text_ );
+ const auto analysis = text_.analyze( display, multiline, false );
+ for ( const auto& glyph : analysis.graphemes ) {
+ atoms.push_back( { .glyph = glyph,
+ .source = { ControlTextPosition { id, {}, glyph.bytes.start },
+ ControlTextPosition { id, {}, glyph.bytes.end } },
+ .style = style,
+ .target = id } );
+ }
+ if ( display.empty() ) {
+ const auto point = ControlTextPosition { id, {}, 0 };
+ atoms.push_back( { .glyph = { .display = L" " }, .source = { point, point }, .style = style, .target = id } );
+ }
+ for ( const auto character : closing ) {
+ atoms.push_back( { .glyph = { .display = std::wstring( 1, character ), .break_after = true },
+ .source = { after, after },
+ .style = style,
+ .target = id } );
+ }
+ // Checkbox/radio controls have no closing bracket but still have an after boundary.
+ atoms.push_back( { .glyph = { .columns = 0, .break_after = true },
+ .source = { after, after },
+ .style = style,
+ .target = id } );
+ }
+
+ std::vector<Atom> inline_atoms( std::span<const Inline> content, TextStyle style ) const
+ {
+ std::vector<Atom> result;
+ std::vector<TextSpan> spans;
+ std::string utf8;
+ const auto flush = [&] {
+ if ( !spans.empty() ) {
+ text_run( result, spans, utf8, false, style );
+ spans.clear();
+ utf8.clear();
+ }
+ };
+ for ( const auto& item : content ) {
+ if ( const auto* text = std::get_if<Text>( &item ) ) {
+ const auto start = utf8.size();
+ utf8 += text->utf8;
+ spans.push_back( { text, { start, utf8.size() } } );
+ } else {
+ flush();
+ control_atoms( result, std::get<ControlPlaceholder>( item ).control, style );
+ }
+ }
+ flush();
+ return result;
+ }
+
+ LayoutData flow( std::span<const Atom> atoms, std::int64_t width, bool wrap ) const
+ {
+ LayoutData result;
+ std::size_t start = 0;
+ std::size_t row = 0;
+ while ( start < atoms.size() ) {
+ std::size_t end = start;
+ std::size_t opportunity = start;
+ std::int64_t column = 0;
+ while ( end < atoms.size() && atoms[end].glyph.kind != TextUnitKind::LineBreak ) {
+ const auto& glyph = atoms[end].glyph;
+ const int cells = glyph.kind == TextUnitKind::Tab ? tab_columns - column % tab_columns : glyph.columns;
+ if ( wrap && column + cells > width && end > start ) {
+ break;
+ }
+ column += cells;
+ ++end;
+ if ( glyph.break_after ) {
+ opportunity = end;
+ }
+ }
+ // Prefer a Unicode line boundary; an overlong word breaks at a grapheme.
+ if ( end < atoms.size() && atoms[end].glyph.kind != TextUnitKind::LineBreak && opportunity > start ) {
+ end = opportunity;
+ }
+ column = 0;
+ for ( auto index = start; index < end; ++index ) {
+ const auto& atom = atoms[index];
+ const bool tab = atom.glyph.kind == TextUnitKind::Tab;
+ const int cells = tab ? tab_columns - column % tab_columns : atom.glyph.columns;
+ result.glyphs.push_back( { .position = { row, column },
+ .columns = cells,
+ .text = atom.glyph.display,
+ .style = atom.style,
+ .source = atom.source,
+ .target = atom.target,
+ .clip_right = wrap ? width : std::numeric_limits<std::int64_t>::max(),
+ .decoration = atom.decoration,
+ .fill = tab,
+ .word_start = atom.glyph.word_start,
+ .word_end = atom.glyph.word_end } );
+ column += cells;
+ }
+ result.extent = std::max( result.extent, column );
+ if ( end < atoms.size() && atoms[end].glyph.kind == TextUnitKind::LineBreak ) {
+ const auto& atom = atoms[end++];
+ result.glyphs.push_back( { .position = { row, column },
+ .source = atom.source,
+ .target = atom.target,
+ .clip_right = width,
+ .line_break = true } );
+ // The endpoint of a hard LF belongs to the next line, including a
+ // trailing empty line; it must also be reachable by hit testing.
+ auto boundary = anchor( atom.source.end, { row + 1, 0 }, width );
+ boundary.target = atom.target;
+ result.glyphs.push_back( std::move( boundary ) );
+ result.rows = row + 2;
+ } else {
+ result.rows = row + 1;
+ }
+ start = end;
+ ++row;
+ }
+ return result;
+ }
+
+ void horizontal( LayoutData& box, BlockId block, std::int64_t width ) const
+ {
+ const auto scroll = std::ranges::find( scrolls_, block, &HorizontalScroll::block );
+ const auto requested = scroll == scrolls_.end() ? 0 : scroll->columns;
+ const auto maximum = std::max<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 );
+ glyph.clip_right = std::min( width, glyph.clip_right - shift );
+ }
+ }
+
+ LayoutData list_box( const List& list, std::int64_t width, TextStyle style ) const
+ {
+ LayoutData result;
+ std::size_t row = 0;
+ auto number = list.first_number;
+ for ( const auto& item : list.items ) {
+ const auto marker
+ = item.marker.value_or( list.kind == ListKind::Ordered ? std::to_string( number ) + "." : "-" );
+ const auto analysis = text_.analyze( marker, false, false );
+ const auto marker_width
+ = std::accumulate( analysis.graphemes.begin(),
+ analysis.graphemes.end(),
+ std::int64_t { 0 },
+ []( auto total, const auto& glyph ) { return total + glyph.columns; } );
+ // Deep nesting must leave at least one body cell; clipped markers stay decoration.
+ const auto indent = std::min( marker_width + 1, width - 1 );
+ auto body = blocks( item.content, width - indent, style );
+ const auto height = body.rows;
+ append_box( result, std::move( body ), row, indent );
+ std::int64_t column = 0;
+ for ( const auto& glyph : analysis.graphemes ) {
+ result.glyphs.push_back( { .position = { row, column },
+ .columns = glyph.columns,
+ .text = glyph.display,
+ .style = style,
+ .clip_right = indent,
+ .decoration = true } );
+ column += glyph.columns;
+ }
+ row += height;
+ // Ordered counters can start at the model's largest int64 value.
+ if ( number < std::numeric_limits<std::int64_t>::max() ) {
+ ++number;
+ }
+ }
+ return result;
+ }
+
+ LayoutData table_box( const Table& table, BlockId id, std::int64_t width, TextStyle style ) const
+ {
+ if ( table.columns == 0 ) {
+ return {};
+ }
+ std::vector<std::int64_t> widths( table.columns, 1 );
+ std::vector<std::int64_t> desired( table.columns, 1 );
+ for ( std::size_t row = 0; row < table.rows.size(); ++row ) {
+ for ( const auto& cell : table.rows[row].cells ) {
+ // Spans index the supplied grid. Reject zero/out-of-grid spans before indexing.
+ if ( cell.column >= table.columns || cell.column_span == 0 || cell.row_span == 0
+ || cell.column_span > table.columns - cell.column || cell.row_span > table.rows.size() - row ) {
+ throw std::invalid_argument( "Table cell span lies outside its resolved grid" );
+ }
+ const auto measure = blocks( cell.content, width, style );
+ const auto span = static_cast<std::int64_t>( cell.column_span );
+ const auto preferred = std::max<std::int64_t>( 1, ( measure.extent + span - 1 ) / span );
+ for ( auto column = cell.column; column < cell.column + cell.column_span; ++column ) {
+ desired[column] = std::max( desired[column], preferred );
+ widths[column] = std::max( widths[column], std::min( preferred, minimum_cell_columns ) );
+ }
+ }
+ }
+ const auto gaps = cell_gap * static_cast<std::int64_t>( table.columns - 1 );
+ auto remaining = width - gaps - std::accumulate( widths.begin(), widths.end(), std::int64_t { 0 } );
+ // Grow content-bearing columns toward their preferred width, within the viewport.
+ while ( remaining > 0 ) {
+ bool grew = false;
+ for ( std::size_t column = 0; column < widths.size() && remaining > 0; ++column ) {
+ if ( widths[column] < desired[column] ) {
+ ++widths[column];
+ --remaining;
+ grew = true;
+ }
+ }
+ if ( !grew ) {
+ break;
+ }
+ }
+ struct CellBox
+ {
+ const TableCell* cell;
+ std::size_t row;
+ LayoutData box;
+ };
+ std::vector<CellBox> cells;
+ std::vector<std::size_t> heights( table.rows.size(), 1 );
+ for ( std::size_t row = 0; row < table.rows.size(); ++row ) {
+ for ( const auto& cell : table.rows[row].cells ) {
+ const auto first = widths.begin() + static_cast<std::ptrdiff_t>( cell.column );
+ const auto last = first + static_cast<std::ptrdiff_t>( cell.column_span );
+ const auto cell_width = std::accumulate( first, last, std::int64_t { 0 } )
+ + cell_gap * static_cast<std::int64_t>( cell.column_span - 1 );
+ auto box = blocks( cell.content, cell_width, overlay( style, { .strong = cell.header } ) );
+ const auto begin = heights.begin() + static_cast<std::ptrdiff_t>( row );
+ const auto end = begin + static_cast<std::ptrdiff_t>( cell.row_span );
+ const auto height = std::accumulate( begin, end, std::size_t { 0 } );
+ if ( box.rows > height ) {
+ heights[row + cell.row_span - 1] += box.rows - height;
+ }
+ cells.push_back( { &cell, row, std::move( box ) } );
+ }
+ }
+ std::vector<std::size_t> row_offsets( heights.size() + 1, 0 );
+ std::partial_sum( heights.begin(), heights.end(), row_offsets.begin() + 1 );
+ std::vector<std::int64_t> column_offsets( widths.size(), 0 );
+ for ( std::size_t column = 1; column < widths.size(); ++column ) {
+ column_offsets[column] = column_offsets[column - 1] + widths[column - 1] + cell_gap;
+ }
+ LayoutData result;
+ result.extent = std::accumulate( widths.begin(), widths.end(), gaps );
+ result.rows = std::max<std::size_t>( 1, row_offsets.back() );
+ for ( auto& cell : cells ) {
+ const auto column = column_offsets[cell.cell->column];
+ append_box( result, std::move( cell.box ), row_offsets[cell.row], column );
+ if ( cell.cell->column != 0 ) {
+ for ( auto row = row_offsets[cell.row]; row < row_offsets[cell.row + cell.cell->row_span]; ++row ) {
+ result.glyphs.push_back( { .position = { row, column - 2 },
+ .columns = 1,
+ .text = L"|",
+ .style = { .subdued = true },
+ .clip_right = result.extent,
+ .decoration = true } );
+ }
+ }
+ }
+ // A nested preformatted block scrolls inside its cell; its natural extent
+ // must not enlarge the enclosing table's horizontal scroll range.
+ result.extent = std::accumulate( widths.begin(), widths.end(), gaps );
+ horizontal( result, id, width );
+ return result;
+ }
+
+ LayoutData block_box( const Block& block, std::int64_t width, TextStyle style ) const
+ {
+ auto result = std::visit(
+ [this, &block, width, style]( const auto& value ) -> LayoutData {
+ using Value = std::decay_t<decltype( value )>;
+ if constexpr ( std::is_same_v<Value, Paragraph> ) {
+ return flow( inline_atoms( value.content, style ), width, true );
+ } else if constexpr ( std::is_same_v<Value, Heading> ) {
+ return flow( inline_atoms( value.content, overlay( style, { .strong = true } ) ), width, true );
+ } else if constexpr ( std::is_same_v<Value, Preformatted> || std::is_same_v<Value, Alternative> ) {
+ const Text& leaf = [&]() -> const Text& {
+ if constexpr ( std::is_same_v<Value, Preformatted> ) {
+ return value.content;
+ } else {
+ return value.description;
+ }
+ }();
+ const TextSpan span { &leaf, { 0, leaf.utf8.size() } };
+ std::vector<Atom> atoms;
+ constexpr bool preformatted = std::is_same_v<Value, Preformatted>;
+ text_run(
+ atoms, std::span( &span, 1 ), leaf.utf8, preformatted, overlay( style, { .code = preformatted } ) );
+ auto box = flow( atoms, width, !preformatted );
+ if constexpr ( preformatted ) {
+ horizontal( box, block.id, width );
+ }
+ return box;
+ } else if constexpr ( std::is_same_v<Value, List> ) {
+ return list_box( value, width, style );
+ } else if constexpr ( std::is_same_v<Value, Table> ) {
+ return table_box( value, block.id, width, style );
+ } else if constexpr ( std::is_same_v<Value, ControlPlaceholder> ) {
+ std::vector<Atom> atoms;
+ control_atoms( atoms, value.control, style );
+ return flow( atoms, width, true );
+ } else {
+ LayoutData box;
+ box.extent = width;
+ for ( std::int64_t column = 0; column < width; ++column ) {
+ box.glyphs.push_back( { .position = { 0, column },
+ .columns = 1,
+ .text = L"-",
+ .style = { .subdued = true },
+ .clip_right = width,
+ .decoration = true } );
+ }
+ return box;
+ }
+ },
+ block.content );
+ const bool has_text
+ = std::ranges::any_of( result.glyphs, []( const auto& glyph ) { return !glyph.decoration; } );
+ auto before = anchor( ObjectPosition { block.id, BoundarySide::Before }, {}, width );
+ auto after = anchor( ObjectPosition { block.id, BoundarySide::After },
+ { result.rows - 1, std::min( result.extent, width ) },
+ width );
+ before.boundary_only = has_text;
+ after.boundary_only = true;
+ result.glyphs.push_back( std::move( before ) );
+ result.glyphs.push_back( std::move( after ) );
+ return result;
+ }
+
+ const DocumentData& document_;
+ const TextService& text_;
+ std::span<const HorizontalScroll> scrolls_;
+ std::map<ControlId, const Control*> controls_ {};
+};
+
+LayoutPoint end_point( const LayoutGlyph& glyph )
+{
+ // Object decorations and empty fields do not create a second text boundary.
+ if ( glyph.source.start == glyph.source.end ) {
+ return glyph.position;
+ }
+ if ( glyph.line_break ) {
+ return { glyph.position.row + 1, glyph.line_start };
+ }
+ return { glyph.position.row, glyph.position.column + glyph.columns };
+}
+
+} // namespace
+
+DocumentLayout::DocumentLayout( const Document& document,
+ const TextService& text,
+ int columns,
+ std::span<const HorizontalScroll> scrolls )
+ : columns_( std::max( 1, columns ) )
+ , data_( LayoutBuilder( document.data(), text, scrolls ).blocks( document.data().blocks, columns_ ) )
+{
+ // An empty document still has a location for its cursor/viewport anchor.
+ if ( data_.glyphs.empty() ) {
+ data_.glyphs.push_back( anchor( DocumentStart {}, {}, columns_ ) );
+ }
+ 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 );
+ }
+ }
+ 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
+{ return columns_; }
+std::size_t DocumentLayout::rows() const
+{ return data_.rows; }
+
+std::optional<LayoutPoint> DocumentLayout::locate( const DisplayPosition& position ) const
+{
+ if ( std::holds_alternative<DocumentStart>( position.point ) ) {
+ return data_.glyphs[stops_.front()].position;
+ }
+ std::optional<LayoutPoint> fallback;
+ for ( const auto& glyph : data_.glyphs ) {
+ if ( glyph.decoration ) {
+ continue;
+ }
+ if ( position.point == glyph.source.start ) {
+ if ( position.affinity == WrapAffinity::NextLine ) {
+ return glyph.position;
+ }
+ fallback = glyph.position;
+ }
+ if ( position.point == glyph.source.end ) {
+ if ( position.affinity == WrapAffinity::PreviousLine ) {
+ return end_point( glyph );
+ }
+ fallback = end_point( glyph );
+ }
+ }
+ return fallback;
+}
+
+std::optional<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 };
+ for ( const auto& glyph : data_.glyphs ) {
+ // Decorations and partially clipped wide glyphs have no independent text hit.
+ if ( glyph.decoration || glyph.boundary_only ) {
+ continue;
+ }
+ const auto left = std::max( glyph.position.column, glyph.clip_left );
+ const auto edge = std::min( glyph.position.column + glyph.columns, glyph.clip_right );
+ // Tab blanks may be clipped individually; a wide glyph remains indivisible.
+ if ( ( glyph.fill && left >= edge )
+ || ( !glyph.fill
+ && ( glyph.position.column < glyph.clip_left
+ || glyph.position.column + glyph.columns > glyph.clip_right ) ) ) {
+ continue;
+ }
+ const auto row_distance
+ = glyph.position.row > point.row ? glyph.position.row - point.row : point.row - glyph.position.row;
+ const auto right = std::max( left, edge - 1 );
+ const auto column_distance = std::max( { left - point.column, point.column - right, std::int64_t { 0 } } );
+ const auto candidate = std::tuple { row_distance, column_distance, glyph.columns == 0 };
+ if ( candidate < distance ) {
+ nearest = &glyph;
+ distance = candidate;
+ }
+ }
+ if ( !nearest ) {
+ return std::nullopt;
+ }
+ const bool after = !nearest->line_break && point.column >= nearest->position.column + nearest->columns;
+ return LayoutHit { .position = { after ? nearest->source.end : nearest->source.start,
+ after ? WrapAffinity::PreviousLine : WrapAffinity::NextLine },
+ .unit = nearest->source,
+ .target = nearest->target };
+}
+
+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<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 );
+ 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 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.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 = style,
+ .selected = highlighted,
+ .cursor = at_cursor && column == caret->column } );
+ }
+ } 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 = newline ? 1 : glyph.columns,
+ .text = newline ? L" " : glyph.text,
+ .style = style,
+ .selected = highlighted,
+ .cursor = at_cursor } );
+ }
+ }
+}
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/layout.hh b/ricochet/src/tui/layout.hh
new file mode 100644
index 0000000..f5db28c
--- /dev/null
+++ b/ricochet/src/tui/layout.hh
@@ -0,0 +1,164 @@
+/*
+ * 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 "paint.hh"
+#include "ricochet/tui/view.hh"
+#include "text.hh"
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <variant>
+#include <vector>
+
+namespace ricochet::tui::detail {
+
+class DocumentText;
+
+using HitTarget = std::variant<LinkId, ControlId>;
+
+struct LayoutPoint
+{
+ std::size_t row { 0 };
+ std::int64_t column { 0 };
+};
+
+struct LayoutHit
+{
+ DisplayPosition position {};
+ DocumentRange unit {};
+ std::optional<HitTarget> target {};
+};
+
+struct FocusStep
+{
+ DisplayPosition position {};
+ bool wrapped { false };
+};
+
+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 {};
+ int columns { 0 };
+ std::wstring text {};
+ TextStyle style {};
+ DocumentRange source {};
+ std::optional<HitTarget> target {};
+ // Clip bounds are in document-layout columns and also constrain hit testing.
+ std::int64_t clip_left { 0 };
+ std::int64_t clip_right { 0 };
+ // Structural boundaries locate anchors but do not displace nearby text hits.
+ bool boundary_only { false };
+ std::int64_t line_start { 0 };
+ 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
+{
+ // Reading order, including table cells in row/cell order; not screen order.
+ std::vector<LayoutGlyph> glyphs {};
+ std::size_t rows { 1 };
+ std::int64_t extent { 0 };
+};
+
+// A derived snapshot for one Document + initial control values + width/scrolls.
+// Editing will supply a separate draft snapshot; this stage uses revision zero.
+// No ncurses headers, terminal objects or business-module dependencies.
+class DocumentLayout
+{
+public:
+ DocumentLayout( const Document& document,
+ const TextService& text,
+ int columns,
+ std::span<const HorizontalScroll> scrolls );
+
+ int columns() const;
+ std::size_t rows() const;
+ std::optional<LayoutPoint> locate( const DisplayPosition& position ) const;
+ std::optional<LayoutHit> hit( LayoutPoint point ) 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<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;
+ 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;
+
+ 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/ricochet/src/tui/mailbox.cc b/ricochet/src/tui/mailbox.cc
new file mode 100644
index 0000000..89e2c19
--- /dev/null
+++ b/ricochet/src/tui/mailbox.cc
@@ -0,0 +1,93 @@
+/*
+ * 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 "mailbox.hh"
+
+#include <algorithm>
+#include <cerrno>
+#include <sys/eventfd.h>
+#include <system_error>
+#include <unistd.h>
+#include <utility>
+
+namespace ricochet::tui::detail {
+
+Mailbox::Mailbox() : event_fd_( eventfd( 0, EFD_CLOEXEC | EFD_NONBLOCK ) )
+{
+ if ( event_fd_ == -1 ) {
+ throw std::system_error( errno, std::generic_category(), "eventfd" );
+ }
+}
+
+Mailbox::~Mailbox()
+{ ::close( event_fd_ ); }
+
+PostResult Mailbox::post( HostUpdate update )
+{
+ const std::lock_guard lock( mutex_ );
+ // Closing rejects later producers while existing sender handles remain alive.
+ if ( closed_ ) {
+ return PostResult::Closed;
+ }
+ updates_.push_back( std::move( update ) );
+ int result;
+ do {
+ result = eventfd_write( event_fd_, 1 );
+ } while ( result == -1 && errno == EINTR );
+ // EAGAIN means the counter is already readable, so the loop is already awake.
+ if ( result == -1 && errno != EAGAIN ) {
+ const int error = errno;
+ updates_.pop_back();
+ throw std::system_error( error, std::generic_category(), "eventfd_write" );
+ }
+ return PostResult::Queued;
+}
+
+std::vector<HostUpdate> Mailbox::take( std::size_t limit )
+{
+ const std::lock_guard lock( mutex_ );
+ eventfd_t counter {};
+ int result;
+ do {
+ result = eventfd_read( event_fd_, &counter );
+ } while ( result == -1 && errno == EINTR );
+ // An empty queue has no wake token. Other read errors are transport failures.
+ if ( result == -1 && errno != EAGAIN ) {
+ throw std::system_error( errno, std::generic_category(), "eventfd_read" );
+ }
+ const auto count = std::min( limit, updates_.size() );
+ std::vector<HostUpdate> batch;
+ batch.reserve( count );
+ for ( std::size_t i = 0; i < count; ++i ) {
+ batch.push_back( std::move( updates_.front() ) );
+ updates_.pop_front();
+ }
+ return batch;
+}
+
+int Mailbox::descriptor() const noexcept
+{ return event_fd_; }
+
+void Mailbox::close() noexcept
+{
+ const std::lock_guard lock( mutex_ );
+ closed_ = true;
+ updates_.clear();
+}
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/mailbox.hh b/ricochet/src/tui/mailbox.hh
new file mode 100644
index 0000000..099c5aa
--- /dev/null
+++ b/ricochet/src/tui/mailbox.hh
@@ -0,0 +1,50 @@
+/*
+ * 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 "ricochet/tui/session.hh"
+
+#include <cstddef>
+#include <deque>
+#include <mutex>
+#include <vector>
+
+namespace ricochet::tui::detail {
+
+class Mailbox
+{
+public:
+ Mailbox();
+ ~Mailbox();
+ Mailbox( const Mailbox& ) = delete;
+ Mailbox& operator=( const Mailbox& ) = delete;
+
+ PostResult post( HostUpdate update );
+ std::vector<HostUpdate> take( std::size_t limit );
+ int descriptor() const noexcept;
+ void close() noexcept;
+
+private:
+ int event_fd_;
+ std::mutex mutex_ {};
+ std::deque<HostUpdate> updates_ {};
+ bool closed_ { false };
+};
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/paint.hh b/ricochet/src/tui/paint.hh
new file mode 100644
index 0000000..43bdf89
--- /dev/null
+++ b/ricochet/src/tui/paint.hh
@@ -0,0 +1,48 @@
+/*
+ * 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 "ricochet/tui/document.hh"
+#include "ricochet/tui/input.hh"
+
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace ricochet::tui::detail {
+
+// Layout supplies one printable spacing character and its combining marks per
+// glyph, with a measured width. Raw document text never goes directly to curses.
+struct PaintGlyph
+{
+ CellPoint position {};
+ int width { 1 };
+ std::wstring text {};
+ TextStyle style {};
+ bool selected { false };
+ bool cursor { false };
+};
+
+struct Frame
+{
+ std::vector<PaintGlyph> glyphs {};
+ std::optional<CellPoint> cursor {};
+};
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/screen.cc b/ricochet/src/tui/screen.cc
new file mode 100644
index 0000000..2bb9c85
--- /dev/null
+++ b/ricochet/src/tui/screen.cc
@@ -0,0 +1,601 @@
+/*
+ * 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 "screen.hh"
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cerrno>
+#include <climits>
+#include <csignal>
+#include <curses.h>
+#include <cwchar>
+#include <langinfo.h>
+#include <locale.h>
+#include <map>
+#include <mutex>
+#include <poll.h>
+#include <stdexcept>
+#include <string_view>
+#include <system_error>
+#include <type_traits>
+#include <unistd.h>
+#include <utility>
+
+namespace ricochet::tui::detail {
+
+namespace {
+
+using Clock = std::chrono::steady_clock;
+using namespace std::chrono_literals;
+
+std::mutex terminal_mutex;
+// atomic_flag is guaranteed lock-free. Clear means pending; test_and_set consumes
+// the signal without a read/reset race, including delivery on a worker thread.
+std::atomic_flag interrupt_clear = ATOMIC_FLAG_INIT;
+std::atomic_flag termination_clear = ATOMIC_FLAG_INIT;
+
+extern "C" void record_signal( int signal )
+{
+ switch ( signal ) {
+ case SIGINT:
+ interrupt_clear.clear( std::memory_order_relaxed );
+ break;
+ case SIGTERM:
+ case SIGHUP:
+ termination_clear.clear( std::memory_order_relaxed );
+ break;
+ default:
+ break;
+ }
+}
+
+std::unique_lock<std::mutex> acquire_terminal()
+{
+ std::unique_lock lock( terminal_mutex, std::try_to_lock );
+ // The non-reentrant ncurses build has process-global current-screen state.
+ if ( !lock.owns_lock() ) {
+ throw std::runtime_error( "A terminal session is already active" );
+ }
+ return lock;
+}
+
+class ThreadLocale
+{
+public:
+ ThreadLocale() : locale_( newlocale( LC_CTYPE_MASK, "", nullptr ), freelocale ), previous_( nullptr )
+ {
+ if ( !locale_ ) {
+ throw std::system_error( errno, std::generic_category(), "newlocale" );
+ }
+ previous_ = uselocale( locale_.get() );
+ if ( previous_ == nullptr ) {
+ throw std::system_error( errno, std::generic_category(), "uselocale" );
+ }
+ }
+
+ ~ThreadLocale() { uselocale( previous_ ); }
+ ThreadLocale( const ThreadLocale& ) = delete;
+ ThreadLocale& operator=( const ThreadLocale& ) = delete;
+
+private:
+ std::unique_ptr<std::remove_pointer_t<locale_t>, decltype( &freelocale )> locale_;
+ locale_t previous_;
+};
+
+class Signals
+{
+public:
+ Signals()
+ {
+ for ( std::size_t i = 0; i < numbers_.size(); ++i ) {
+ if ( sigaction( numbers_[i], nullptr, &previous_[i] ) == -1 ) {
+ throw std::system_error( errno, std::generic_category(), "Reading signal disposition" );
+ }
+ }
+ }
+
+ ~Signals()
+ {
+ for ( std::size_t i = 0; i < numbers_.size(); ++i ) {
+ sigaction( numbers_[i], &previous_[i], nullptr );
+ }
+ }
+
+ Signals( const Signals& ) = delete;
+ Signals& operator=( const Signals& ) = delete;
+
+ void prepare()
+ {
+ interrupt_clear.test_and_set( std::memory_order_relaxed );
+ termination_clear.test_and_set( std::memory_order_relaxed );
+ struct sigaction action {};
+ sigemptyset( &action.sa_mask );
+ action.sa_handler = SIG_DFL;
+ // newterm owns SIGWINCH during the session; restore the host's handler later.
+ if ( sigaction( SIGWINCH, &action, nullptr ) == -1 ) {
+ throw std::system_error( errno, std::generic_category(), "Preparing resize signal disposition" );
+ }
+ }
+
+ void install()
+ {
+ for ( const auto number : numbers_ ) {
+ if ( number == SIGWINCH ) {
+ continue; // Keep the resize handler installed by newterm.
+ }
+ struct sigaction action {};
+ sigemptyset( &action.sa_mask );
+ switch ( number ) {
+ case SIGTSTP:
+ case SIGPIPE:
+ // No suspend support; output errors must reach the normal cleanup path.
+ action.sa_handler = SIG_IGN;
+ break;
+ default:
+ action.sa_handler = record_signal;
+ break;
+ }
+ if ( sigaction( number, &action, nullptr ) == -1 ) {
+ throw std::system_error( errno, std::generic_category(), "Installing signal disposition" );
+ }
+ }
+ }
+
+private:
+ static constexpr std::array numbers_ { SIGINT, SIGTERM, SIGHUP, SIGTSTP, SIGWINCH, SIGPIPE };
+ std::array<struct sigaction, numbers_.size()> previous_ {};
+};
+
+struct ScreenDeleter
+{
+ std::FILE* output;
+ bool paste_enabled { false };
+
+ void operator()( SCREEN* screen ) const noexcept
+ {
+ // Only undo the extension this session enabled. The rest belongs to ncurses.
+ if ( paste_enabled ) {
+ std::fputs( "\x1b[?2004l", output );
+ std::fflush( output );
+ }
+ mousemask( 0, nullptr );
+ endwin();
+ delscreen( screen );
+ }
+};
+
+Key character_key( char32_t character, Modifiers modifiers )
+{ return { .code = KeyCode::Character, .modifiers = modifiers, .character = character }; }
+
+InputEvent character_event( char32_t character )
+{
+ // These aliases are indistinguishable in the traditional terminal encoding.
+ switch ( character ) {
+ case U'\r':
+ case U'\n':
+ return Key { .code = KeyCode::Enter };
+ case U'\t':
+ return Key { .code = KeyCode::Tab };
+ case U'\b':
+ case 127:
+ return Key { .code = KeyCode::Backspace };
+ case 27:
+ return Key { .code = KeyCode::Escape };
+ case 0:
+ return character_key( U' ', { .control = true } );
+ case 28:
+ return character_key( U'\\', { .control = true } );
+ case 29:
+ return character_key( U']', { .control = true } );
+ case 30:
+ return character_key( U'^', { .control = true } );
+ case 31:
+ return character_key( U'_', { .control = true } );
+ default:
+ break;
+ }
+ // The remaining C0 letters encode Ctrl+A through Ctrl+Z.
+ if ( character >= 1 && character <= 26 ) {
+ return character_key( U'a' + character - 1, { .control = true } );
+ }
+ return TextInput { character };
+}
+
+Mouse mouse_event( const MEVENT& mouse )
+{
+ return { .position = { mouse.y, mouse.x },
+ .modifiers = { ( mouse.bstate & BUTTON_SHIFT ) != 0,
+ ( mouse.bstate & BUTTON_CTRL ) != 0,
+ ( mouse.bstate & BUTTON_ALT ) != 0 },
+ .pressed = { ( mouse.bstate & BUTTON1_PRESSED ) != 0,
+ ( mouse.bstate & BUTTON2_PRESSED ) != 0,
+ ( mouse.bstate & BUTTON3_PRESSED ) != 0 },
+ .released = { ( mouse.bstate & BUTTON1_RELEASED ) != 0,
+ ( mouse.bstate & BUTTON2_RELEASED ) != 0,
+ ( mouse.bstate & BUTTON3_RELEASED ) != 0 },
+ .motion = ( mouse.bstate & REPORT_MOUSE_POSITION ) != 0,
+ .wheel_steps = static_cast<int>( ( mouse.bstate & BUTTON5_PRESSED ) != 0 )
+ - static_cast<int>( ( mouse.bstate & BUTTON4_PRESSED ) != 0 ) };
+}
+
+} // namespace
+
+class Screen::Impl
+{
+public:
+ Impl( std::FILE& input, std::FILE& output, TerminalOptions options )
+ : lock_( acquire_terminal() )
+ , locale_()
+ , signals_()
+ , screen_( nullptr, ScreenDeleter { &output } )
+ , input_fd_( fileno( &input ) )
+ , options_( options )
+ {
+ // This backend requires a terminal, not a pipe with EOF-looking input.
+ if ( isatty( input_fd_ ) != 1 || isatty( fileno( &output ) ) != 1 ) {
+ throw std::runtime_error( "The TUI requires terminal input and output" );
+ }
+ if ( std::string_view( nl_langinfo( CODESET ) ) != "UTF-8" ) {
+ throw std::runtime_error( "The TUI requires a UTF-8 locale" );
+ }
+ signals_.prepare();
+ use_env( false );
+ use_tioctl( true );
+ screen_.reset( newterm( nullptr, &output, &input ) );
+ // Unlike initscr, newterm reports failure without exiting the host process.
+ if ( !screen_ ) {
+ throw std::runtime_error( "ncurses newterm failed" );
+ }
+ // ncurses documents application signal handlers being installed after newterm.
+ signals_.install();
+ if ( raw() == ERR || noecho() == ERR || nonl() == ERR || meta( stdscr, true ) == ERR
+ || keypad( stdscr, true ) == ERR ) {
+ throw std::runtime_error( "ncurses input-mode setup failed" );
+ }
+ const auto delay = std::min<std::chrono::milliseconds::rep>( options.escape_delay.count(), INT_MAX );
+ if ( set_escdelay( static_cast<int>( delay ) ) == ERR ) {
+ throw std::runtime_error( "ncurses rejected the escape delay" );
+ }
+ options_.escape_delay = std::chrono::milliseconds( delay );
+ wtimeout( stdscr, 0 );
+ curs_set( 0 ); // ERR means cursor visibility cannot be changed on this terminal.
+ mouseinterval( 0 ); // Deliver press/release immediately; no click aggregation delay.
+ capabilities_.colors = has_colors() && start_color() != ERR;
+ if ( options.capture_mouse ) {
+ const auto mask = mousemask( ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr );
+ capabilities_.mouse_buttons = ( mask & BUTTON1_PRESSED ) != 0;
+ capabilities_.mouse_motion = ( mask & REPORT_MOUSE_POSITION ) != 0;
+ }
+ install_keys();
+ if ( options.bracketed_paste ) {
+ if ( define_key( "\x1b[200~", paste_key_ ) == ERR ) {
+ throw std::runtime_error( "ncurses could not register the paste delimiter" );
+ }
+ // Mark before writing so partial initialization also disables the extension.
+ screen_.get_deleter().paste_enabled = true;
+ if ( std::fputs( "\x1b[?2004h", &output ) == EOF || std::fflush( &output ) == EOF ) {
+ throw std::system_error( errno, std::generic_category(), "Enabling bracketed paste" );
+ }
+ capabilities_.bracketed_paste = true;
+ }
+ }
+
+ ScreenSize size() const { return { getmaxy( stdscr ), getmaxx( stdscr ) }; }
+
+ void install_keys()
+ {
+ keys_ = { { KEY_ENTER, { .code = KeyCode::Enter } },
+ { KEY_BACKSPACE, { .code = KeyCode::Backspace } },
+ { KEY_BTAB, { .code = KeyCode::Tab, .modifiers = { .shift = true } } },
+ { KEY_LEFT, { .code = KeyCode::Left } },
+ { KEY_RIGHT, { .code = KeyCode::Right } },
+ { KEY_UP, { .code = KeyCode::Up } },
+ { KEY_DOWN, { .code = KeyCode::Down } },
+ { KEY_HOME, { .code = KeyCode::Home } },
+ { KEY_END, { .code = KeyCode::End } },
+ { KEY_PPAGE, { .code = KeyCode::PageUp } },
+ { KEY_NPAGE, { .code = KeyCode::PageDown } },
+ { KEY_IC, { .code = KeyCode::Insert } },
+ { KEY_DC, { .code = KeyCode::Delete } },
+ { KEY_SLEFT, { .code = KeyCode::Left, .modifiers = { .shift = true } } },
+ { KEY_SRIGHT, { .code = KeyCode::Right, .modifiers = { .shift = true } } },
+ { KEY_SR, { .code = KeyCode::Up, .modifiers = { .shift = true } } },
+ { KEY_SF, { .code = KeyCode::Down, .modifiers = { .shift = true } } },
+ { KEY_SHOME, { .code = KeyCode::Home, .modifiers = { .shift = true } } },
+ { KEY_SEND, { .code = KeyCode::End, .modifiers = { .shift = true } } } };
+ for ( unsigned int n = 1; n <= 63; ++n ) {
+ keys_.emplace( KEY_F( n ), Key { .code = KeyCode::Function, .function = n } );
+ }
+ const std::array extended { std::pair { "kLFT3", Key { .code = KeyCode::Left, .modifiers = { .alt = true } } },
+ std::pair { "kRIT3", Key { .code = KeyCode::Right, .modifiers = { .alt = true } } },
+ std::pair { "kUP3", Key { .code = KeyCode::Up, .modifiers = { .alt = true } } },
+ std::pair { "kDN3", Key { .code = KeyCode::Down, .modifiers = { .alt = true } } } };
+ int code = paste_key_ + 1;
+ for ( const auto& [name, key] : extended ) {
+ const char* sequence = tigetstr( name );
+ // tigetstr distinguishes absent capabilities from a non-string capability.
+ if ( sequence == nullptr || sequence == reinterpret_cast<char*>( -1 ) ) {
+ continue;
+ }
+ if ( define_key( sequence, code ) == ERR ) {
+ throw std::runtime_error( "ncurses could not register an extended key" );
+ }
+ keys_.emplace( code++, key );
+ }
+ }
+
+ void append_event( InputEvent event, std::vector<InputEvent>& events )
+ {
+ // Decoding a function key can itself consume the remaining Escape deadline.
+ if ( escape_deadline_ && Clock::now() >= *escape_deadline_ ) {
+ events.emplace_back( Key { .code = KeyCode::Escape } );
+ escape_deadline_.reset();
+ }
+ // An Escape prefix combines only with a key arriving inside its deadline.
+ if ( escape_deadline_ ) {
+ if ( auto* text = std::get_if<TextInput>( &event ) ) {
+ event = character_key( text->character, { .alt = true } );
+ } else if ( auto* key = std::get_if<Key>( &event ); key != nullptr && key->code != KeyCode::Escape ) {
+ key->modifiers.alt = true;
+ } else {
+ events.emplace_back( Key { .code = KeyCode::Escape } );
+ }
+ escape_deadline_.reset();
+ }
+ if ( const auto* key = std::get_if<Key>( &event ); key != nullptr && key->code == KeyCode::Escape ) {
+ escape_deadline_ = Clock::now() + options_.escape_delay;
+ return;
+ }
+ events.push_back( std::move( event ) );
+ }
+
+ void append_character( char32_t character, std::vector<InputEvent>& events )
+ {
+ if ( paste_ ) {
+ paste_->push_back( character );
+ constexpr std::u32string_view end = U"\x1b[201~";
+ if ( paste_->ends_with( end ) ) {
+ paste_->resize( paste_->size() - end.size() );
+ events.emplace_back( Paste { std::move( *paste_ ) } );
+ paste_.reset();
+ if ( keypad( stdscr, true ) == ERR ) {
+ throw std::runtime_error( "ncurses could not leave paste input mode" );
+ }
+ }
+ } else {
+ append_event( character_event( character ), events );
+ }
+ }
+
+ void decode_byte( unsigned char byte, std::vector<InputEvent>& events )
+ {
+ // wget_wch keeps its conversion buffer local to one call; nodelay can split a
+ // character across calls. Retain the standard conversion state across steps.
+ // Evidence: ncurses/widechar/lib_get_wch.c, wget_wch's local count/buffer.
+ const bool had_partial = std::mbsinit( &text_state_ ) == 0;
+ const char input = static_cast<char>( byte );
+ wchar_t character {};
+ const auto converted = std::mbrtowc( &character, &input, 1, &text_state_ );
+ if ( converted == static_cast<std::size_t>( -2 ) ) {
+ return; // The standard library needs continuation bytes, possibly next step.
+ }
+ if ( converted == static_cast<std::size_t>( -1 ) ) {
+ text_state_ = {};
+ append_character( U'\uFFFD', events );
+ // Retry the offending byte as a new sequence so an ASCII character following
+ // an incomplete sequence is not lost. Reset state bounds this to one retry.
+ if ( had_partial ) {
+ decode_byte( byte, events );
+ }
+ return;
+ }
+ append_character( static_cast<char32_t>( character ), events );
+ }
+
+ InputBatch read_events( std::size_t limit )
+ {
+ InputBatch batch;
+ if ( !termination_clear.test_and_set( std::memory_order_relaxed ) ) {
+ batch.events.emplace_back( TerminalClosed { TerminalCloseReason::TerminationSignal } );
+ return batch;
+ }
+ // Check disconnection even when continuous host updates prevent an idle wait.
+ if ( const auto closed = wait( -1, 0ms ) ) {
+ batch.events.emplace_back( *closed );
+ return batch;
+ }
+ if ( !interrupt_clear.test_and_set( std::memory_order_relaxed ) ) {
+ batch.events.emplace_back( character_key( U'c', { .control = true } ) );
+ escape_deadline_.reset();
+ }
+ if ( escape_deadline_ && Clock::now() >= *escape_deadline_ ) {
+ batch.events.emplace_back( Key { .code = KeyCode::Escape } );
+ escape_deadline_.reset();
+ }
+ for ( std::size_t n = 0; n < limit; ++n ) {
+ errno = 0;
+ const int value = wgetch( stdscr );
+ if ( value == ERR ) {
+ // nodelay has no byte available; signals may interrupt the ncurses reader.
+ if ( errno != 0 && errno != EAGAIN && errno != EINTR ) {
+ throw std::system_error( errno, std::generic_category(), "ncurses input" );
+ }
+ break;
+ }
+ batch.consumed = true;
+ // wgetch returns bytes below KEY_MIN and decoded function keys above it.
+ if ( value >= KEY_MIN ) {
+ // Resize is out-of-band and can occur between UTF-8 continuation bytes.
+ // Other function keys interrupt an unfinished text sequence.
+ if ( value != KEY_RESIZE && std::mbsinit( &text_state_ ) == 0 ) {
+ text_state_ = {};
+ append_character( U'\uFFFD', batch.events );
+ }
+ switch ( value ) {
+ case KEY_RESIZE:
+ batch.events.emplace_back( Resize { size() } );
+ break;
+ case KEY_MOUSE: {
+ MEVENT mouse {};
+ if ( getmouse( &mouse ) == ERR ) {
+ throw std::runtime_error( "ncurses mouse event could not be read" );
+ }
+ append_event( mouse_event( mouse ), batch.events );
+ break;
+ }
+ case paste_key_:
+ // A preceding standalone Escape cannot become a modifier on pasted text.
+ if ( escape_deadline_ ) {
+ batch.events.emplace_back( Key { .code = KeyCode::Escape } );
+ escape_deadline_.reset();
+ }
+ paste_.emplace();
+ // Preserve literal escape sequences inside pasted text. Only ncurses
+ // reads bytes; the backend recognizes the protocol's end delimiter.
+ if ( keypad( stdscr, false ) == ERR ) {
+ throw std::runtime_error( "ncurses could not enter paste input mode" );
+ }
+ break;
+ default: {
+ const auto found = keys_.find( static_cast<int>( value ) );
+ if ( found != keys_.end() ) {
+ append_event( found->second, batch.events );
+ } else {
+ append_event( Key { .code = KeyCode::Unsupported }, batch.events );
+ }
+ break;
+ }
+ }
+ } else {
+ decode_byte( static_cast<unsigned char>( value ), batch.events );
+ }
+ }
+ return batch;
+ }
+
+ std::optional<TerminalClosed> wait( int wake_fd, std::chrono::milliseconds max_wait ) const
+ {
+ // The signal may arrive on a producer thread. This bound also lets wgetch
+ // observe ncurses' SIGWINCH flag when no terminal bytes have arrived.
+ auto delay = std::clamp( max_wait, 0ms, 50ms );
+ if ( escape_deadline_ ) {
+ delay = std::min(
+ delay, std::max( 0ms, std::chrono::ceil<std::chrono::milliseconds>( *escape_deadline_ - Clock::now() ) ) );
+ }
+ std::array<pollfd, 2> descriptors { pollfd { input_fd_, POLLIN, 0 }, pollfd { wake_fd, POLLIN, 0 } };
+ const int result = poll( descriptors.data(), descriptors.size(), static_cast<int>( delay.count() ) );
+ if ( result == -1 && errno != EINTR ) {
+ throw std::system_error( errno, std::generic_category(), "poll" );
+ }
+ if ( ( descriptors[0].revents & ( POLLHUP | POLLERR | POLLNVAL ) ) != 0 ) {
+ return TerminalClosed { TerminalCloseReason::Disconnected };
+ }
+ if ( ( descriptors[1].revents & ( POLLHUP | POLLERR | POLLNVAL ) ) != 0 ) {
+ throw std::runtime_error( "The UI wake channel was disconnected" );
+ }
+ return std::nullopt;
+ }
+
+ void draw( const Frame& frame, bool redraw ) const
+ {
+ const auto dimensions = size();
+ if ( werase( stdscr ) == ERR ) {
+ throw std::runtime_error( "ncurses could not clear its window buffer" );
+ }
+ for ( const auto& glyph : frame.glyphs ) {
+ const auto [row, column] = glyph.position;
+ // A resize can clip a previously prepared frame. Never wrap clipped glyphs.
+ if ( row < 0 || row >= dimensions.rows || column < 0 || column >= dimensions.columns
+ || glyph.width > dimensions.columns - column ) {
+ continue;
+ }
+ // setcchar would silently truncate longer combining sequences.
+ if ( glyph.text.size() >= CCHARW_MAX ) {
+ throw std::runtime_error( "Glyph exceeds the ncurses complex-character capacity" );
+ }
+ 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.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 ) {
+ throw std::runtime_error( "ncurses could not paint a glyph" );
+ }
+ }
+ // add_wchnstr does not advance or wrap the cursor at the bottom-right cell.
+ if ( frame.cursor && frame.cursor->row >= 0 && frame.cursor->row < dimensions.rows && frame.cursor->column >= 0
+ && frame.cursor->column < dimensions.columns ) {
+ if ( wmove( stdscr, frame.cursor->row, frame.cursor->column ) == ERR ) {
+ throw std::runtime_error( "ncurses could not position the cursor" );
+ }
+ curs_set( 1 );
+ } else {
+ curs_set( 0 );
+ }
+ // An explicit Ctrl+L repairs the physical screen, not just our virtual frame.
+ // clearok is consumed by wrefresh; ordinary frames retain batched updates.
+ if ( redraw ) {
+ clearok( stdscr, TRUE );
+ }
+ if ( redraw ? wrefresh( stdscr ) == ERR : ( wnoutrefresh( stdscr ) == ERR || doupdate() == ERR ) ) {
+ throw std::runtime_error( "ncurses screen refresh failed" );
+ }
+ }
+
+ std::unique_lock<std::mutex> lock_;
+ ThreadLocale locale_;
+ Signals signals_;
+ std::unique_ptr<SCREEN, ScreenDeleter> screen_;
+ int input_fd_;
+ TerminalOptions options_;
+ TerminalCapabilities capabilities_ {};
+ std::map<int, Key> keys_ {};
+ std::optional<Clock::time_point> escape_deadline_ {};
+ std::optional<std::u32string> paste_ {};
+ std::mbstate_t text_state_ {};
+ static constexpr int paste_key_ = 0x10000;
+};
+
+Screen::Screen( std::FILE& input, std::FILE& output, TerminalOptions options )
+ : impl_( std::make_unique<Impl>( input, output, options ) )
+{}
+
+Screen::~Screen() = default;
+
+std::size_t Screen::glyph_capacity()
+{ return CCHARW_MAX - 1; }
+
+ScreenSize Screen::size() const
+{ return impl_->size(); }
+
+TerminalCapabilities Screen::capabilities() const
+{ return impl_->capabilities_; }
+
+InputBatch Screen::read_events( std::size_t limit )
+{ return impl_->read_events( limit ); }
+
+std::optional<TerminalClosed> Screen::wait( int wake_fd, std::chrono::milliseconds max_wait )
+{ return impl_->wait( wake_fd, max_wait ); }
+
+void Screen::draw( const Frame& frame, bool redraw )
+{ impl_->draw( frame, redraw ); }
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/screen.hh b/ricochet/src/tui/screen.hh
new file mode 100644
index 0000000..0ff1c58
--- /dev/null
+++ b/ricochet/src/tui/screen.hh
@@ -0,0 +1,60 @@
+/*
+ * 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 "paint.hh"
+#include "ricochet/tui/session.hh"
+
+#include <chrono>
+#include <cstddef>
+#include <cstdio>
+#include <memory>
+#include <optional>
+#include <vector>
+
+namespace ricochet::tui::detail {
+
+struct InputBatch
+{
+ std::vector<InputEvent> events {};
+ bool consumed { false };
+};
+
+class Screen
+{
+public:
+ Screen( std::FILE& input, std::FILE& output, TerminalOptions options );
+ ~Screen();
+ Screen( const Screen& ) = delete;
+ Screen& operator=( const Screen& ) = delete;
+
+ ScreenSize size() const;
+ static std::size_t glyph_capacity();
+ TerminalCapabilities capabilities() const;
+ InputBatch read_events( std::size_t limit );
+ // Wait only polls descriptors; wgetch remains the sole terminal reader.
+ std::optional<TerminalClosed> wait( int wake_fd, std::chrono::milliseconds max_wait );
+ void draw( const Frame& frame, bool redraw = false );
+
+private:
+ class Impl;
+ std::unique_ptr<Impl> impl_;
+};
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/session.cc b/ricochet/src/tui/session.cc
new file mode 100644
index 0000000..09af2ce
--- /dev/null
+++ b/ricochet/src/tui/session.cc
@@ -0,0 +1,1064 @@
+/*
+ * 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 "ricochet/tui/session.hh"
+
+#include "document_view.hh"
+#include "editor.hh"
+#include "mailbox.hh"
+#include "screen.hh"
+
+#include <algorithm>
+#include <chrono>
+#include <exception>
+#include <map>
+#include <string_view>
+#include <utility>
+
+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<detail::DocumentView> view {};
+ std::optional<ActionFailed> failure {};
+ std::optional<RequestId> copy_request {};
+ std::string copy_feedback {};
+};
+
+enum class AddressEntry : std::uint8_t
+{
+ Empty,
+ Current,
+ Selected
+};
+
+int address_label_columns( ScreenSize dimensions )
+{ return std::min( 5, std::max( 0, dimensions.columns - 1 ) ); }
+
+struct AddressInput
+{
+ TabId tab;
+ std::optional<ViewSnapshot> restore;
+ detail::TextEditor editor;
+ bool dragging { false };
+ std::wstring notice {};
+
+ AddressInput( TabId source,
+ std::optional<ViewSnapshot> snapshot,
+ std::string value,
+ const detail::TextService& text,
+ ScreenSize dimensions )
+ : tab( source )
+ , restore( std::move( snapshot ) )
+ , editor( std::move( value ), {}, text, { 1, dimensions.columns - address_label_columns( dimensions ) } )
+ {}
+};
+
+// Only fixed ASCII chrome uses this helper. Document text goes through the
+// Unicode text service and document layout.
+void append_chrome( detail::Frame& frame, CellPoint start, std::wstring_view text, TextStyle style = {} )
+{
+ int column = start.column;
+ for ( const wchar_t character : text ) {
+ frame.glyphs.push_back(
+ { .position = { start.row, column++ }, .text = std::wstring( 1, character ), .style = style } );
+ }
+}
+
+} // namespace
+
+class Session::Impl
+{
+public:
+ Impl( std::FILE& input, std::FILE& output, TerminalOptions options )
+ : mailbox_( std::make_shared<detail::Mailbox>() )
+ , screen_( std::make_unique<detail::Screen>( input, output, options ) )
+ , capabilities_( screen_->capabilities() )
+ {}
+
+ ~Impl() { close(); }
+
+ void close() noexcept
+ {
+ mailbox_->close();
+ address_.reset();
+ screen_.reset();
+ }
+
+ void request_exit()
+ {
+ // A held key or repeated SIGINT must not create concurrent exit requests.
+ 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->snapshot() );
+ }
+ }
+ actions_.emplace_back( RequestExit { request, active_, std::move( views ) } );
+ exit_request_ = request;
+ dirty_ = true;
+ }
+
+ void apply( SyncTabs update, StepResult& )
+ {
+ // Cancel against the old tab map before a switch/removal destroys its view.
+ if ( address_
+ && ( update.active != address_->tab
+ || std::ranges::find( update.tabs, address_->tab, &TabInfo::id ) == update.tabs.end() ) ) {
+ finish_address();
+ }
+ if ( active_ != update.active ) {
+ stop_drag();
+ }
+ std::map<TabId, TabState> retained;
+ std::vector<TabId> order;
+ order.reserve( update.tabs.size() );
+ for ( auto& info : update.tabs ) {
+ order.push_back( info.id );
+ auto node = tabs_.extract( info.id );
+ if ( node.empty() ) {
+ const auto id = info.id;
+ retained.emplace( id, TabState { .info = std::move( info ) } );
+ } else {
+ node.mapped().info = std::move( info );
+ retained.insert( std::move( node ) );
+ }
+ }
+ tabs_ = std::move( retained );
+ tab_order_ = std::move( order );
+ if ( active_ != update.active ) {
+ prefix_.reset();
+ notice_.clear();
+ }
+ active_ = update.active;
+ dirty_ = true;
+ }
+
+ void apply( NavigationStarted update, StepResult& )
+ {
+ const auto found = tabs_.find( update.tab );
+ // Closed-tab results are stale; they must not recreate a tab.
+ if ( found == tabs_.end() ) {
+ return;
+ }
+ found->second.navigation = update.request;
+ found->second.info.loading_task = update.task;
+ found->second.failure.reset();
+ dirty_ = true;
+ }
+
+ void apply( CommitDocument update, StepResult& )
+ {
+ const auto found = tabs_.find( update.tab );
+ if ( found == tabs_.end() || found->second.navigation != update.navigation ) {
+ return;
+ }
+ auto& tab = found->second;
+ const auto version = update.document.data().version;
+ 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 };
+ 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 {} ) {
+ 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();
+ const auto dimensions = screen_->size();
+ tab.view.emplace( std::move( update.document ),
+ std::move( snapshot ),
+ text_,
+ ScreenSize { dimensions.rows - 2, dimensions.columns } );
+ if ( address_ && address_->tab == update.tab ) {
+ // Address editing survives a background commit, but cancellation must now
+ // return to the newly committed page, never restore the old view snapshot.
+ address_->restore.reset();
+ }
+ 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& 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;
+ return;
+ }
+ if ( !update.tab ) {
+ return;
+ }
+ const auto found = tabs_.find( *update.tab );
+ if ( found == tabs_.end() || found->second.navigation != update.request ) {
+ return;
+ }
+ found->second.info.loading_task.reset();
+ found->second.failure = std::move( update );
+ dirty_ = true;
+ }
+
+ void apply( ExitDecision update, StepResult& )
+ {
+ if ( exit_request_ != update.request ) {
+ return;
+ }
+ exit_request_.reset();
+ if ( update.approved ) {
+ close();
+ }
+ dirty_ = true;
+ }
+
+ // 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& )
+ {
+ 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 ) ); }
+
+ std::size_t receive( StepResult& result )
+ {
+ // A finite batch prevents continuous producers from starving terminal input.
+ auto updates = mailbox_->take( 64 );
+ const auto count = updates.size();
+ for ( auto& update : updates ) {
+ std::visit( [this, &result]( auto message ) { apply( std::move( message ), result ); }, std::move( update ) );
+ if ( !screen_ ) {
+ break; // An approved exit ends this batch before any further UI work.
+ }
+ }
+ 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 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();
+ 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 open_address( AddressEntry entry )
+ {
+ const auto found = active_ ? tabs_.find( *active_ ) : tabs_.end();
+ if ( found == tabs_.end() ) {
+ notice_ = L"No active tab";
+ dirty_ = true;
+ return;
+ }
+ auto& tab = found->second;
+ if ( tab.view && tab.view->snapshot().selection ) {
+ return; // Address shortcuts belong to Browse, not the document selection mode.
+ }
+ stop_drag();
+ const auto snapshot = tab.view ? std::optional( tab.view->snapshot() ) : std::nullopt;
+ address_.emplace(
+ tab.info.id, snapshot, entry == AddressEntry::Empty ? "" : tab.info.display_url, text_, screen_->size() );
+ if ( entry == AddressEntry::Selected ) {
+ address_->editor.execute( detail::EditorCommand::SelectAll );
+ }
+ prefix_.reset();
+ notice_.clear();
+ dirty_ = true;
+ }
+
+ void finish_address()
+ {
+ auto& tab = tabs_.at( address_->tab );
+ const auto& restore = address_->restore;
+ if ( restore && tab.view && tab.view->snapshot().scope == restore->scope
+ && tab.view->snapshot().draft_revision == restore->draft_revision ) {
+ // Copy the shared document before emplace destroys the current view.
+ auto document = tab.view->document();
+ const auto dimensions = screen_->size();
+ tab.view.emplace(
+ std::move( document ), *restore, text_, ScreenSize { dimensions.rows - 2, dimensions.columns } );
+ }
+ address_.reset();
+ notice_.clear();
+ dirty_ = true;
+ }
+
+ void submit_address()
+ {
+ const auto& value = address_->editor.value();
+ if ( text_.analyze( value, false, false ).whitespace_only ) {
+ address_->notice = L"Enter an address | Esc cancel";
+ dirty_ = true;
+ return;
+ }
+ const RequestId request { next_request_++ };
+ actions_.emplace_back( OpenLocation { request, address_->tab, value, OpenTarget::CurrentTab } );
+ auto& tab = tabs_.at( address_->tab );
+ // A host rejection may precede NavigationStarted, just as for OpenLink.
+ tab.navigation = request;
+ tab.failure.reset();
+ tab.copy_feedback.clear();
+ finish_address();
+ }
+
+ void address_mouse( const Mouse& mouse )
+ {
+ const auto dimensions = screen_->size();
+ const auto label = address_label_columns( dimensions );
+ const CellPoint point { 0, mouse.position.column - label };
+ if ( address_->dragging ) {
+ if ( mouse.released.left || mouse.motion || mouse.pressed.left ) {
+ address_->editor.place( point, true );
+ address_->dragging = !mouse.released.left;
+ dirty_ = true;
+ }
+ return;
+ }
+ if ( mouse.pressed.left && mouse.position.row == 0 && mouse.position.column >= label
+ && mouse.position.column < dimensions.columns
+ && ( mouse.modifiers == Modifiers {} || mouse.modifiers == Modifiers { .shift = true } ) ) {
+ address_->editor.place( point, mouse.modifiers.shift );
+ address_->dragging = true;
+ dirty_ = true;
+ }
+ // Clicks and wheel events outside the input never reach the document underneath.
+ }
+
+ void address_input( const InputEvent& event )
+ {
+ if ( const auto* mouse = std::get_if<Mouse>( &event ) ) {
+ address_mouse( *mouse );
+ return;
+ }
+ const auto prefix = address_->editor.prefix_deadline();
+ const auto intent = address_->editor.handle( event );
+ if ( std::holds_alternative<Tick>( event ) ) {
+ dirty_ = dirty_ || prefix != address_->editor.prefix_deadline();
+ return;
+ }
+ address_->dragging = false;
+ address_->notice.clear();
+ dirty_ = true;
+ switch ( intent ) {
+ case detail::EditorIntent::Enter:
+ submit_address();
+ break;
+ case detail::EditorIntent::Escape:
+ case detail::EditorIntent::Cancel:
+ finish_address();
+ break;
+ case detail::EditorIntent::Redraw:
+ redraw_ = true;
+ break;
+ case detail::EditorIntent::Menu:
+ address_->notice = L"Menu unavailable | Enter open | Esc cancel";
+ break;
+ default:
+ break; // Tab/history/unbound keys have no second recipient in this mode.
+ }
+ }
+
+ void draw_address( detail::Frame& frame, ScreenSize dimensions )
+ {
+ const auto label = address_label_columns( dimensions );
+ append_chrome( frame, { 0, 0 }, std::wstring_view( L"URL: " ).substr( 0, label ), { .strong = true } );
+ address_->editor.resize( { 1, dimensions.columns - label } );
+ address_->editor.draw( frame, { 0, label } );
+ if ( dimensions.rows > 1 ) {
+ append_chrome( frame, { dimensions.rows - 1, 0 }, std::wstring( dimensions.columns, L' ' ) );
+ std::wstring_view status
+ = capabilities_.bracketed_paste
+ ? L"Address | Enter open | Esc cancel"
+ : L"Enter open | Esc cancel | Paste boundaries unavailable; multiline paste may confirm";
+ if ( !address_->notice.empty() ) {
+ status = address_->notice;
+ }
+ if ( address_->editor.prefix_deadline() ) {
+ status = L"Ctrl+X ... | Ctrl+U undo | Esc cancel prefix";
+ }
+ append_chrome( frame, { dimensions.rows - 1, 0 }, status );
+ }
+ }
+
+ 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'o':
+ open_address( AddressEntry::Empty );
+ break;
+ case U'O':
+ open_address( AddressEntry::Current );
+ break;
+ 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'l':
+ open_address( AddressEntry::Selected );
+ break;
+ 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.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::Function:
+ if ( key.function == 6 ) {
+ open_address( AddressEntry::Selected );
+ }
+ break;
+ case KeyCode::Enter:
+ activate_link();
+ break;
+ 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();
+ if ( address_ ) {
+ address_->dragging = false;
+ const auto dimensions = screen_->size();
+ address_->editor.resize( { 1, dimensions.columns - address_label_columns( dimensions ) } );
+ }
+ dirty_ = true;
+ } else if ( address_ ) {
+ address_input( event );
+ } 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 ) ) {
+ stop_drag();
+ prefix_.reset();
+ notice_ = L"Enter an input field before pasting";
+ dirty_ = true;
+ } else if ( const auto* text = std::get_if<TextInput>( &event ) ) {
+ stop_drag();
+ browse_text( text->character );
+ } else if ( const auto* key = std::get_if<Key>( &event ) ) {
+ stop_drag();
+ browse_key( *key );
+ } else if ( const auto* mouse = std::get_if<Mouse>( &event ) ) {
+ browse_mouse( *mouse );
+ }
+ }
+
+ 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()
+ {
+ detail::Frame frame;
+ const auto dimensions = screen_->size();
+ // With fewer than three rows, header/body/footer cannot coexist.
+ if ( dimensions.rows < 3 ) {
+ 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 | 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";
+ }
+ if ( active_ ) {
+ const auto found = tabs_.find( *active_ );
+ if ( found != tabs_.end() ) {
+ 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";
+ }
+ if ( found->second.failure ) {
+ status = L"Navigation failed | q quit";
+ }
+ }
+ }
+ // 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";
+ }
+ if ( exit_request_ ) {
+ status = L"Waiting for exit decision";
+ }
+ 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 );
+ }
+ }
+ if ( address_ ) {
+ draw_address( frame, dimensions );
+ }
+ screen_->draw( frame, std::exchange( redraw_, false ) );
+ dirty_ = false;
+ }
+
+ StepResult step( std::chrono::milliseconds max_wait )
+ {
+ StepResult result;
+ // Repeated step/close calls are part of the closed-session contract.
+ if ( !screen_ ) {
+ result.running = false;
+ return result;
+ }
+ try {
+ expire_prefix();
+ const auto received = receive( result );
+ if ( screen_ ) {
+ const bool input = read_input( result );
+ if ( screen_ && received == 0 && !input && !dirty_ ) {
+ auto wait = max_wait;
+ const auto deadline = address_ ? address_->editor.prefix_deadline()
+ : prefix_ ? std::optional( prefix_->deadline )
+ : std::nullopt;
+ if ( deadline ) {
+ wait = std::min( wait,
+ std::max( std::chrono::milliseconds::zero(),
+ std::chrono::ceil<std::chrono::milliseconds>( *deadline - Clock::now() ) ) );
+ }
+ const auto closed = screen_->wait( mailbox_->descriptor(), wait );
+ if ( closed ) {
+ dispatch( *closed, result );
+ } else {
+ receive( result );
+ if ( screen_ ) {
+ read_input( result );
+ }
+ }
+ }
+ }
+ 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();
+ }
+ } catch ( const std::exception& error ) {
+ // Restore the terminal before the host can print or present the failure.
+ close();
+ result.error = error.what();
+ }
+ result.running = screen_ != nullptr;
+ return result;
+ }
+
+ std::shared_ptr<detail::Mailbox> mailbox_;
+ std::unique_ptr<detail::Screen> screen_;
+ TerminalCapabilities capabilities_;
+ detail::TextService text_ { detail::Screen::glyph_capacity() };
+ std::map<TabId, TabState> tabs_ {};
+ std::vector<TabId> tab_order_ {};
+ std::optional<TabId> active_ {};
+ std::optional<RequestId> exit_request_ {};
+ std::optional<KeyPrefix> prefix_ {};
+ std::optional<AddressInput> address_ {};
+ std::uint64_t next_request_ { 1 };
+ std::vector<UserAction> actions_ {};
+ std::wstring notice_ {};
+ bool dirty_ { true };
+ bool redraw_ { false };
+};
+
+UpdateSender::UpdateSender( std::shared_ptr<detail::Mailbox> mailbox ) : mailbox_( std::move( mailbox ) ) {}
+
+PostResult UpdateSender::post_update( HostUpdate update ) const
+{ return mailbox_->post( std::move( update ) ); }
+
+Session::Session( TerminalOptions options ) : Session( *stdin, *stdout, options ) {}
+
+Session::Session( std::FILE& input, std::FILE& output, TerminalOptions options )
+ : impl_( std::make_unique<Impl>( input, output, options ) )
+{}
+
+Session::~Session() = default;
+
+UpdateSender Session::sender() const
+{ return UpdateSender( impl_->mailbox_ ); }
+
+PostResult Session::post_update( HostUpdate update )
+{ return impl_->mailbox_->post( std::move( update ) ); }
+
+StepResult Session::step( std::chrono::milliseconds max_wait )
+{ return impl_->step( max_wait ); }
+
+std::vector<UserAction> Session::take_actions()
+{ return std::exchange( impl_->actions_, {} ); }
+
+std::optional<ViewSnapshot> Session::snapshot( TabId tab ) const
+{
+ const auto found = impl_->tabs_.find( tab );
+ if ( found == impl_->tabs_.end() || !found->second.view ) {
+ return std::nullopt;
+ }
+ return found->second.view->snapshot();
+}
+
+TerminalCapabilities Session::capabilities() const
+{ return impl_->capabilities_; }
+
+void Session::close() noexcept
+{ impl_->close(); }
+
+} // namespace ricochet::tui
diff --git a/ricochet/src/tui/text.cc b/ricochet/src/tui/text.cc
new file mode 100644
index 0000000..ba24af2
--- /dev/null
+++ b/ricochet/src/tui/text.cc
@@ -0,0 +1,163 @@
+/*
+ * 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 "text.hh"
+
+#include <climits>
+#include <cwchar>
+#include <memory>
+#include <stdexcept>
+#include <unicode/ubrk.h>
+#include <unicode/uchar.h>
+#include <unicode/unistr.h>
+#include <unicode/utext.h>
+#include <utility>
+
+namespace ricochet::tui::detail {
+namespace {
+
+using UnicodeText = std::unique_ptr<UText, decltype( &utext_close )>;
+using Breaks = std::unique_ptr<UBreakIterator, decltype( &ubrk_close )>;
+
+Breaks breaks_for( UBreakIteratorType type, UText& text )
+{
+ UErrorCode status = U_ZERO_ERROR;
+ Breaks breaks( ubrk_open( type, "root", nullptr, 0, &status ), &ubrk_close );
+ if ( U_FAILURE( status ) ) {
+ throw std::runtime_error( u_errorName( status ) );
+ }
+ ubrk_setUText( breaks.get(), &text, &status );
+ if ( U_FAILURE( status ) ) {
+ throw std::runtime_error( u_errorName( status ) );
+ }
+ return breaks;
+}
+
+WordKind word_kind( UChar32 codepoint )
+{
+ if ( u_isUWhiteSpace( codepoint ) ) {
+ return WordKind::Space;
+ }
+ if ( u_hasBinaryProperty( codepoint, UCHAR_IDEOGRAPHIC ) ) {
+ return WordKind::Ideograph;
+ }
+ if ( codepoint == '_' || u_isalnum( codepoint ) ) {
+ return WordKind::Word;
+ }
+ return WordKind::Punctuation;
+}
+
+} // namespace
+
+TextService::TextService( std::size_t glyph_capacity ) : glyph_capacity_( glyph_capacity ) {}
+
+std::string TextService::encode( std::u32string_view input ) const
+{
+ // ICU's UTF-32 conversion takes a signed 32-bit length, like its break iterator.
+ if ( input.size() > INT32_MAX ) {
+ throw std::length_error( "Input exceeds ICU conversion length capacity" );
+ }
+ const std::vector<UChar32> codepoints( input.begin(), input.end() );
+ // fromUTF32 requires a non-null pointer even for a zero-length input.
+ const UChar32 empty = 0;
+ const auto converted = icu::UnicodeString::fromUTF32( codepoints.empty() ? &empty : codepoints.data(),
+ static_cast<int32_t>( codepoints.size() ) );
+ if ( converted.isBogus() ) {
+ throw std::runtime_error( "ICU could not convert editor input" );
+ }
+ std::string result;
+ converted.toUTF8String( result );
+ return result;
+}
+
+TextAnalysis TextService::analyze( std::string_view utf8, bool line_breaks, bool tabs ) const
+{
+ // ICU BreakIterator exposes int32_t native offsets even with a UTF-8 UText.
+ if ( utf8.size() > INT32_MAX ) {
+ throw std::length_error( "Text exceeds ICU break-iterator offset capacity" );
+ }
+ UErrorCode status = U_ZERO_ERROR;
+ UnicodeText text(
+ utext_openUTF8( nullptr, utf8.empty() ? "" : utf8.data(), static_cast<int64_t>( utf8.size() ), &status ),
+ &utext_close );
+ if ( U_FAILURE( status ) ) {
+ throw std::runtime_error( u_errorName( status ) );
+ }
+ auto characters = breaks_for( UBRK_CHARACTER, *text );
+ auto lines = breaks_for( UBRK_LINE, *text );
+ TextAnalysis result;
+ int32_t begin = ubrk_first( characters.get() );
+ int32_t line_end = ubrk_next( lines.get() );
+ WordKind previous = WordKind::Space;
+ for ( int32_t end = ubrk_next( characters.get() ); end != UBRK_DONE;
+ begin = end, end = ubrk_next( characters.get() ) ) {
+ while ( line_end != UBRK_DONE && line_end < end ) {
+ line_end = ubrk_next( lines.get() );
+ }
+ Grapheme glyph { .bytes = { static_cast<std::size_t>( begin ), static_cast<std::size_t>( end ) },
+ .break_after = line_end == end };
+ utext_setNativeIndex( text.get(), begin );
+ const auto first = utext_next32( text.get() );
+ result.whitespace_only = result.whitespace_only && u_isUWhiteSpace( first );
+ glyph.word = word_kind( first );
+ const auto source = utf8.substr( glyph.bytes.start, glyph.bytes.end - glyph.bytes.start );
+ if ( line_breaks && ( source == "\n" || source == "\r\n" ) ) {
+ glyph.kind = TextUnitKind::LineBreak;
+ glyph.columns = 0;
+ } else if ( tabs && source == "\t" ) {
+ glyph.kind = TextUnitKind::Tab;
+ glyph.display = L" ";
+ } else {
+ const int width = ::wcwidth( static_cast<wchar_t>( first ) );
+ glyph.display.push_back( static_cast<wchar_t>( first ) );
+ bool representable = width > 0 && !u_hasBinaryProperty( first, UCHAR_DEFAULT_IGNORABLE_CODE_POINT )
+ && !u_hasBinaryProperty( first, UCHAR_EMOJI_PRESENTATION );
+ while ( utext_getNativeIndex( text.get() ) < end ) {
+ const auto codepoint = utext_next32( text.get() );
+ result.whitespace_only = result.whitespace_only && u_isUWhiteSpace( codepoint );
+ glyph.display.push_back( static_cast<wchar_t>( codepoint ) );
+ // ncurses accepts one spacing character followed by nonspacing marks.
+ // Joiners, selectors and extra spacing characters use one visible fallback.
+ representable = representable && ::wcwidth( static_cast<wchar_t>( codepoint ) ) == 0
+ && u_charType( codepoint ) == U_NON_SPACING_MARK
+ && !u_hasBinaryProperty( codepoint, UCHAR_DEFAULT_IGNORABLE_CODE_POINT );
+ }
+ if ( !representable || glyph.display.size() > glyph_capacity_ ) {
+ glyph.display = L"?";
+ glyph.columns = 1;
+ } else {
+ glyph.columns = width;
+ }
+ }
+ 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 ) );
+ }
+ return result;
+}
+
+} // namespace ricochet::tui::detail
diff --git a/ricochet/src/tui/text.hh b/ricochet/src/tui/text.hh
new file mode 100644
index 0000000..99fd7c9
--- /dev/null
+++ b/ricochet/src/tui/text.hh
@@ -0,0 +1,80 @@
+/*
+ * 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 "ricochet/tui/position.hh"
+
+#include <cstddef>
+#include <cstdint>
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace ricochet::tui::detail {
+
+enum class TextUnitKind : std::uint8_t
+{
+ Glyph,
+ LineBreak,
+ Tab
+};
+enum class WordKind : std::uint8_t
+{
+ Space,
+ Word,
+ Ideograph,
+ Punctuation
+};
+
+struct Grapheme
+{
+ ByteRange bytes {};
+ std::wstring display {};
+ int columns { 1 };
+ TextUnitKind kind { TextUnitKind::Glyph };
+ WordKind word { WordKind::Punctuation };
+ bool break_after { false };
+ bool word_start { false };
+ bool word_end { false };
+};
+
+struct TextAnalysis
+{
+ std::vector<Grapheme> graphemes {};
+ // All codepoints, including combining marks; an empty input is whitespace-only.
+ bool whitespace_only { true };
+ // Vim-style reading units: letters/numbers/underscore, punctuation runs,
+ // individual ideographs. Whitespace is excluded; offsets remain UTF-8 bytes.
+ std::vector<ByteRange> words {};
+};
+
+// Called on the UI thread in the same UTF-8 locale as Screen. ICU owns Unicode
+// segmentation/properties; POSIX wcwidth owns terminal widths. No curses types.
+class TextService
+{
+public:
+ explicit TextService( std::size_t glyph_capacity );
+ TextAnalysis analyze( std::string_view utf8, bool line_breaks, bool tabs ) const;
+ std::string encode( std::u32string_view input ) const;
+
+private:
+ std::size_t glyph_capacity_;
+};
+
+} // namespace ricochet::tui::detail