diff options
| author | Richard Qin <richard.qin.x@gmail.com> | 2026-10-17 03:37:11 +0800 |
|---|---|---|
| committer | Richard Qin <richard.qin.x@gmail.com> | 2026-10-17 03:37:11 +0800 |
| commit | 754006847fcfdaa68a59a2929d6c9020dd2616ed (patch) | |
| tree | 9a3e8e07c60dfbdbb363754555f08ba632e581fd | |
| parent | eb6509c6f3eddbd00ac59d89892209518ede53f5 (diff) | |
| download | rwebbrowser-754006847fcfdaa68a59a2929d6c9020dd2616ed.tar.gz rwebbrowser-754006847fcfdaa68a59a2929d6c9020dd2616ed.tar.xz | |
feat(tui): add ncurses backend and session loop
| -rw-r--r-- | include/ricochet/tui/input.hh | 119 | ||||
| -rw-r--r-- | include/ricochet/tui/session.hh | 110 | ||||
| -rw-r--r-- | src/CMakeLists.txt | 12 | ||||
| -rw-r--r-- | src/tui/CMakeLists.txt | 8 | ||||
| -rw-r--r-- | src/tui/mailbox.cc | 93 | ||||
| -rw-r--r-- | src/tui/mailbox.hh | 50 | ||||
| -rw-r--r-- | src/tui/screen.cc | 592 | ||||
| -rw-r--r-- | src/tui/screen.hh | 76 | ||||
| -rw-r--r-- | src/tui/session.cc | 380 |
9 files changed, 1437 insertions, 3 deletions
diff --git a/include/ricochet/tui/input.hh b/include/ricochet/tui/input.hh new file mode 100644 index 0000000..df5b458 --- /dev/null +++ b/include/ricochet/tui/input.hh @@ -0,0 +1,119 @@ +/* + * 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 <chrono> +#include <cstdint> +#include <string> +#include <variant> + +namespace ricochet::tui { + +struct ScreenSize +{ + int rows { 0 }; + int columns { 0 }; + bool operator==( const ScreenSize& ) const = default; +}; + +struct CellPoint +{ + int row { 0 }; + int column { 0 }; +}; + +struct Modifiers +{ + bool shift { false }; + bool control { false }; + bool alt { false }; + bool operator==( const Modifiers& ) const = default; +}; + +enum class KeyCode : std::uint8_t +{ + Character, + Escape, + Enter, + Tab, + Backspace, + Delete, + Insert, + Left, + Right, + Up, + Down, + Home, + End, + PageUp, + PageDown, + Function, + Unsupported +}; + +struct Key +{ + KeyCode code { KeyCode::Unsupported }; + Modifiers modifiers {}; + char32_t character { 0 }; + unsigned int function { 0 }; +}; + +struct TextInput +{ char32_t character { 0 }; }; + +struct Paste +{ std::u32string text {}; }; + +struct MouseButtons +{ + bool left { false }; + bool middle { false }; + bool right { false }; +}; + +struct Mouse +{ + // Screen coordinates; the view router converts these to region-local coordinates. + CellPoint position {}; + Modifiers modifiers {}; + MouseButtons pressed {}; + MouseButtons released {}; + bool motion { false }; + int wheel_steps { 0 }; // Positive scrolls down. +}; + +struct Resize +{ ScreenSize size {}; }; + +struct Tick +{ std::chrono::steady_clock::time_point now {}; }; + +enum class TerminalCloseReason : std::uint8_t +{ + Disconnected, + TerminationSignal +}; + +struct TerminalClosed +{ TerminalCloseReason reason { TerminalCloseReason::Disconnected }; }; + +using InputEvent = std::variant<Key, TextInput, Paste, Mouse, Resize, Tick, TerminalClosed>; + +} // namespace ricochet::tui diff --git a/include/ricochet/tui/session.hh b/include/ricochet/tui/session.hh new file mode 100644 index 0000000..9968178 --- /dev/null +++ b/include/ricochet/tui/session.hh @@ -0,0 +1,110 @@ +/* + * 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/input.hh" +#include "ricochet/tui/messages.hh" + +#include <chrono> +#include <cstdio> +#include <memory> +#include <optional> +#include <string> +#include <vector> + +namespace ricochet::tui { + +namespace detail { +class Mailbox; +} + +struct TerminalOptions +{ + bool capture_mouse { true }; + // Explicit host opt-in: terminfo does not establish bracketed-paste support. + bool bracketed_paste { false }; + std::chrono::milliseconds escape_delay { 25 }; +}; + +struct TerminalCapabilities +{ + bool colors { false }; + bool mouse_buttons { false }; + bool mouse_motion { false }; + bool bracketed_paste { false }; +}; + +enum class PostResult : std::uint8_t +{ + Queued, + Closed +}; + +// A sender can outlive the UI session. Posting is thread-safe; it never calls curses. +class UpdateSender +{ +public: + PostResult post_update( HostUpdate update ) const; + +private: + friend class Session; + explicit UpdateSender( std::shared_ptr<detail::Mailbox> mailbox ); + std::shared_ptr<detail::Mailbox> mailbox_; +}; + +struct StepResult +{ + bool running { true }; + // Content editing/layout handlers are subsequent milestones. Until they exist, + // unsupported updates are returned intact rather than silently acknowledged. + std::vector<HostUpdate> unhandled_updates {}; + std::optional<TerminalClosed> terminal_closed {}; + std::optional<std::string> error {}; +}; + +class Session +{ +public: + explicit Session( TerminalOptions options = {} ); + // Streams must remain open until destruction. Construction, step, close and + // destruction run on the same UI thread; only one terminal session may exist. + Session( std::FILE& input, std::FILE& output, TerminalOptions options = {} ); + ~Session(); + + Session( const Session& ) = delete; + Session& operator=( const Session& ) = delete; + Session( Session&& ) = delete; + Session& operator=( Session&& ) = delete; + + UpdateSender sender() const; + PostResult post_update( HostUpdate update ); + // max_wait bounds idle waiting. Decoding a partial terminal key sequence can + // additionally wait for escape_delay; processing batches is bounded separately. + StepResult step( std::chrono::milliseconds max_wait ); + std::vector<UserAction> take_actions(); + std::optional<ViewSnapshot> snapshot( TabId tab ) const; + TerminalCapabilities capabilities() const; + void close() noexcept; + +private: + class Impl; + std::unique_ptr<Impl> impl_; +}; + +} // namespace ricochet::tui diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e46ac9a..fece630 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,13 +1,19 @@ -file(GLOB_RECURSE ALL_SOURCES CONFIGURE_DEPENDS "*.cc") +add_subdirectory(tui) + +file(GLOB_RECURSE CORE_SOURCES CONFIGURE_DEPENDS + "core/*.cc" + "net/*.cc" + "parser/*.cc" + "render/*.cc" +) set(MAIN_SRC "${CMAKE_CURRENT_SOURCE_DIR}/main.cc") -list(REMOVE_ITEM ALL_SOURCES ${MAIN_SRC}) find_package(OpenSSL REQUIRED) message(STATUS "Found OpenSSL Version: ${OPENSSL_VERSION}") message(STATUS "OpenSSL Include dir: ${OPENSSL_INCLUDE_DIR}") -add_library(ricochet_core STATIC ${ALL_SOURCES}) +add_library(ricochet_core STATIC ${CORE_SOURCES}) target_include_directories(ricochet_core PUBLIC ${PROJECT_SOURCE_DIR}/include) target_link_libraries(ricochet_core PUBLIC diff --git a/src/tui/CMakeLists.txt b/src/tui/CMakeLists.txt new file mode 100644 index 0000000..908448e --- /dev/null +++ b/src/tui/CMakeLists.txt @@ -0,0 +1,8 @@ +find_package(PkgConfig REQUIRED) +pkg_check_modules(NCURSESW REQUIRED IMPORTED_TARGET ncursesw) +find_package(Threads REQUIRED) + +add_library(ricochet_tui STATIC document.cc mailbox.cc screen.cc session.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 Threads::Threads) diff --git a/src/tui/mailbox.cc b/src/tui/mailbox.cc new file mode 100644 index 0000000..89e2c19 --- /dev/null +++ b/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/src/tui/mailbox.hh b/src/tui/mailbox.hh new file mode 100644 index 0000000..099c5aa --- /dev/null +++ b/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/src/tui/screen.cc b/src/tui/screen.cc new file mode 100644 index 0000000..43582cd --- /dev/null +++ b/src/tui/screen.cc @@ -0,0 +1,592 @@ +/* + * 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 ) 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 ); + 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 ); + } + if ( 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; + +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 ) +{ impl_->draw( frame ); } + +} // namespace ricochet::tui::detail diff --git a/src/tui/screen.hh b/src/tui/screen.hh new file mode 100644 index 0000000..f0df278 --- /dev/null +++ b/src/tui/screen.hh @@ -0,0 +1,76 @@ +/* + * 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/session.hh" + +#include <chrono> +#include <cstddef> +#include <cstdio> +#include <memory> +#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 {}; +}; + +struct Frame +{ + std::vector<PaintGlyph> glyphs {}; + std::optional<CellPoint> cursor {}; +}; + +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; + 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 ); + +private: + class Impl; + std::unique_ptr<Impl> impl_; +}; + +} // namespace ricochet::tui::detail diff --git a/src/tui/session.cc b/src/tui/session.cc new file mode 100644 index 0000000..38d501e --- /dev/null +++ b/src/tui/session.cc @@ -0,0 +1,380 @@ +/* + * 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 "mailbox.hh" +#include "screen.hh" + +#include <algorithm> +#include <exception> +#include <map> +#include <string_view> +#include <utility> + +namespace ricochet::tui { + +namespace { + +struct TabState +{ + TabInfo info {}; + std::optional<RequestId> navigation {}; + std::optional<Document> document {}; + std::optional<ViewSnapshot> view {}; + std::optional<ActionFailed> failure {}; +}; + +// Only fixed ASCII chrome uses this helper. Document text goes through the +// forthcoming Unicode layout service, not a second ad-hoc text renderer. +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(); + screen_.reset(); + } + + void request_exit() + { + // A held key or repeated SIGINT must not create concurrent exit requests. + if ( exit_request_ ) { + return; + } + 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 ); + } + } + actions_.emplace_back( RequestExit { request, active_, std::move( views ) } ); + exit_request_ = request; + dirty_ = true; + } + + void apply( SyncTabs update, StepResult& ) + { + 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 ); + 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.document && tab.document->data().version.document == version.document + && version.revision < tab.document->data().version.revision ) { + return; + } + const DocumentScope scope { update.tab, version }; + tab.view = ViewSnapshot { .scope = scope }; + // Initial drafts are revision zero. A history view referring to edited values + // cannot be restored without the corresponding draft snapshot/mapping. + if ( update.restore && update.restore->scope == scope && update.restore->draft_revision == DraftRevision {} ) { + tab.view = std::move( update.restore ); + } + tab.info.title = update.document.data().metadata.title; + tab.info.display_url = update.document.data().metadata.display_url; + tab.info.loading_task.reset(); + tab.document = std::move( update.document ); + tab.failure.reset(); + dirty_ = true; + } + + void apply( ActionFailed update, StepResult& ) + { + 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; + } + + // These messages require document-view/editor state, which this backend stage + // does not implement. Returning ownership keeps the incomplete boundary explicit. + void apply( UpdateDocument update, StepResult& result ) + { result.unhandled_updates.emplace_back( std::move( update ) ); } + + void apply( LocateView update, StepResult& result ) + { result.unhandled_updates.emplace_back( std::move( update ) ); } + + void apply( 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; + } + + 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 ) ) { + dirty_ = true; + } else if ( std::holds_alternative<Paste>( event ) ) { + // The baseline session is Browse: a paste never executes its text as keys. + notice_ = L"Enter an input field before pasting"; + dirty_ = true; + } else if ( const auto* text = std::get_if<TextInput>( &event ) ) { + if ( text->character == U'q' ) { + request_exit(); + } + } else if ( const auto* key = std::get_if<Key>( &event ) ) { + if ( key->code == KeyCode::Character && key->modifiers == Modifiers { .control = true } ) { + switch ( key->character ) { + case U'c': + request_exit(); + break; + case U'z': + notice_ = L"Suspend is unavailable"; + dirty_ = true; + break; + default: + break; + } + } + } + // Mouse/navigation/editor bindings belong to the upcoming document-view layer. + } + + bool read_input( StepResult& result ) + { + const auto batch = screen_->read_events( 64 ); + for ( const auto& event : batch.events ) { + dispatch( event, result ); + if ( !screen_ ) { + break; + } + } + 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 ) { + append_chrome( frame, { 0, 0 }, L"Resize | q quit" ); + } else { + append_chrome( frame, { 0, 0 }, L"Ricochet", { .strong = true } ); + std::wstring_view status = L"Browse | q quit"; + if ( !capabilities_.bracketed_paste ) { + status = L"Paste boundaries unavailable; multiline paste may confirm input | q quit"; + } + if ( !notice_.empty() ) { + status = notice_; + } + if ( active_ ) { + const auto found = tabs_.find( *active_ ); + if ( found != tabs_.end() ) { + if ( found->second.info.loading_task ) { + status = L"Loading | q quit"; + } + if ( found->second.failure ) { + status = L"Navigation failed | q quit"; + } + } + } + if ( exit_request_ ) { + status = L"Waiting for exit decision"; + } + append_chrome( frame, { dimensions.rows - 1, 0 }, status ); + } + screen_->draw( frame ); + 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 { + const auto received = receive( result ); + if ( screen_ ) { + const bool input = read_input( result ); + if ( screen_ && received == 0 && !input && !dirty_ ) { + const auto closed = screen_->wait( mailbox_->descriptor(), max_wait ); + if ( closed ) { + dispatch( *closed, result ); + } else { + receive( result ); + if ( screen_ ) { + read_input( 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_; + std::map<TabId, TabState> tabs_ {}; + std::vector<TabId> tab_order_ {}; + std::optional<TabId> active_ {}; + std::optional<RequestId> exit_request_ {}; + std::uint64_t next_request_ { 1 }; + std::vector<UserAction> actions_ {}; + std::wstring notice_ {}; + bool dirty_ { true }; +}; + +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() ) { + return std::nullopt; + } + return found->second.view; +} + +TerminalCapabilities Session::capabilities() const +{ return impl_->capabilities_; } + +void Session::close() noexcept +{ impl_->close(); } + +} // namespace ricochet::tui |
