aboutsummaryrefslogtreecommitdiff
path: root/ricochet/src/tui/session.cc
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/session.cc
parentcf58b3b4dfa0dbbd0ca492484ca241b320c4ba9e (diff)
parentbdefdb301db843e87aee8c6c35808d295bb66ee5 (diff)
downloadrcc-project-464313d02b76385d3a2e8809a4595afabca88dae.tar.gz
rcc-project-464313d02b76385d3a2e8809a4595afabca88dae.tar.xz
Merge remote-tracking branch 'ricochet-import/master'HEADmaster
Diffstat (limited to 'ricochet/src/tui/session.cc')
-rw-r--r--ricochet/src/tui/session.cc1064
1 files changed, 1064 insertions, 0 deletions
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