aboutsummaryrefslogtreecommitdiff
path: root/ricochet/src
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
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')
-rw-r--r--ricochet/src/CMakeLists.txt25
-rw-r--r--ricochet/src/core/browser.cc66
-rw-r--r--ricochet/src/core/form_handler.cc64
-rw-r--r--ricochet/src/core/license.cc69
-rw-r--r--ricochet/src/core/local_data.cc171
-rw-r--r--ricochet/src/core/page_loader.cc78
-rw-r--r--ricochet/src/core/page_processor.cc97
-rw-r--r--ricochet/src/core/utils.cc85
-rw-r--r--ricochet/src/main.cc73
-rw-r--r--ricochet/src/net/config.cc64
-rw-r--r--ricochet/src/net/cookie_jar.cc124
-rw-r--r--ricochet/src/net/http_client.cc212
-rw-r--r--ricochet/src/net/http_utils.cc93
-rw-r--r--ricochet/src/net/ssl_socket.cc118
-rw-r--r--ricochet/src/net/sys/address.cc207
-rw-r--r--ricochet/src/net/sys/file_descriptor.cc213
-rw-r--r--ricochet/src/net/sys/socket.cc286
-rw-r--r--ricochet/src/net/url_parser.cc87
-rw-r--r--ricochet/src/parser/html_lexer.cc119
-rw-r--r--ricochet/src/parser/tree_builder.cc85
-rw-r--r--ricochet/src/render/input_renderer.cc82
-rw-r--r--ricochet/src/render/renderer.cc123
-rw-r--r--ricochet/src/render/table_renderer.cc147
-rw-r--r--ricochet/src/render/utils.cc135
-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
42 files changed, 7736 insertions, 0 deletions
diff --git a/ricochet/src/CMakeLists.txt b/ricochet/src/CMakeLists.txt
new file mode 100644
index 0000000..fece630
--- /dev/null
+++ b/ricochet/src/CMakeLists.txt
@@ -0,0 +1,25 @@
+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")
+
+find_package(OpenSSL REQUIRED)
+message(STATUS "Found OpenSSL Version: ${OPENSSL_VERSION}")
+message(STATUS "OpenSSL Include dir: ${OPENSSL_INCLUDE_DIR}")
+
+add_library(ricochet_core STATIC ${CORE_SOURCES})
+target_include_directories(ricochet_core PUBLIC ${PROJECT_SOURCE_DIR}/include)
+target_link_libraries(ricochet_core
+ PUBLIC
+ OpenSSL::SSL
+ OpenSSL::Crypto
+)
+
+add_executable(ricochet ${MAIN_SRC})
+target_link_libraries(ricochet PRIVATE ricochet_core)
diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc
new file mode 100644
index 0000000..c413a0d
--- /dev/null
+++ b/ricochet/src/core/browser.cc
@@ -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/>.
+ */
+
+#include "ricochet/core/browser.hh"
+
+#include <fstream>
+
+#include "ricochet/core/local_data.hh"
+
+namespace ricochet::core {
+
+std::string Browser::format_url( std::string input )
+{
+ if ( input.empty() ) {
+ return input;
+ }
+
+ if ( input.starts_with( "http://" ) || input.starts_with( "https://" ) ) {
+ return input;
+ }
+
+ const bool is_domain = input.contains( '.' ) && !input.contains( ' ' );
+ if ( is_domain ) {
+ input.insert( 0, "https://" );
+ return input;
+ }
+
+ for ( char& ch : input ) {
+ if ( ch == ' ' ) {
+ ch = '+';
+ }
+ }
+ return "https://lite.duckduckgo.com/lite/?q=" + input;
+}
+
+std::string Browser::get_configured_homepage()
+{
+ std::ifstream file( get_config_path( "config.txt" ) );
+ if ( file.is_open() ) {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t eq = line.find( '=' );
+ if ( eq != std::string::npos && line.substr( 0, eq ) == "homepage" ) {
+ return line.substr( eq + 1 );
+ }
+ }
+ }
+ return "https://wikipedia.org";
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/form_handler.cc b/ricochet/src/core/form_handler.cc
new file mode 100644
index 0000000..6100a30
--- /dev/null
+++ b/ricochet/src/core/form_handler.cc
@@ -0,0 +1,64 @@
+/*
+ * 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/core/form_handler.hh"
+#include "ricochet/core/utils.hh"
+
+namespace ricochet::core {
+
+InputAction submit_form( const render::InputField& target_input,
+ const std::vector<render::InputField>& inputs,
+ const std::string& current_url,
+ std::vector<HttpRequest>& history,
+ std::size_t& history_idx )
+{
+ std::string body;
+ for ( const auto& in : inputs ) {
+ if ( in.action == target_input.action && !in.name.empty() && in.type != "submit" && in.type != "button" ) {
+ if ( ( in.type == "checkbox" || in.type == "radio" ) && !in.checked ) {
+ continue;
+ }
+ std::string encoded = in.value;
+ for ( char& ch : encoded ) {
+ if ( ch == ' ' ) {
+ ch = '+';
+ }
+ }
+ if ( !body.empty() ) {
+ body += "&";
+ }
+ body += in.name + "=" + encoded;
+ }
+ }
+ const std::string action
+ = target_input.action.empty() ? current_url : normalize_url( target_input.action, current_url );
+ HttpRequest next_req { .url = action, .method = "GET", .body = "" };
+ if ( target_input.method == "post" || target_input.method == "POST" ) {
+ next_req.method = "POST";
+ next_req.body = body;
+ } else {
+ const char sep = action.contains( '?' ) ? '&' : '?';
+ next_req.url += sep + body;
+ }
+ history.resize( history_idx + 1 );
+ history.push_back( next_req );
+ history_idx++;
+ return InputAction::Navigate;
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/license.cc b/ricochet/src/core/license.cc
new file mode 100644
index 0000000..587a942
--- /dev/null
+++ b/ricochet/src/core/license.cc
@@ -0,0 +1,69 @@
+/*
+ * 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/core/license.hh"
+#include <iostream>
+
+namespace ricochet::core {
+
+void print_license_notice()
+{
+ std::cout << "Ricochet Copyright (C) 2026 Your Name\n"
+ << "Ricochet comes with ABSOLUTELY NO WARRANTY; for details type 'show w'.\n"
+ << "This is free software, and you are welcome to redistribute it\n"
+ << "under certain conditions; type 'show c' for details.\n\n";
+}
+
+void show_warranty()
+{
+ std::cout << R"(
+[ Ricochet - WARRANTY INFORMATION ]
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER
+PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
+EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+
+THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.
+SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY
+SERVICING, REPAIR OR CORRECTION.
+)" << '\n';
+}
+
+void show_copying()
+{
+ std::cout << R"(
+[ Ricochet - COPYING CONDITIONS ]
+
+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/>.
+)" << '\n';
+}
+
+} // namespace ricochet::core \ No newline at end of file
diff --git a/ricochet/src/core/local_data.cc b/ricochet/src/core/local_data.cc
new file mode 100644
index 0000000..9115844
--- /dev/null
+++ b/ricochet/src/core/local_data.cc
@@ -0,0 +1,171 @@
+/*
+ * 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/core/local_data.hh"
+#include <cstdlib>
+#include <fstream>
+#include <sys/stat.h>
+#include <vector>
+
+namespace ricochet::core {
+
+std::string get_config_path( const std::string& filename )
+{
+ const char* home = std::getenv( "HOME" ); // NOLINT(concurrency-mt-unsafe)
+ const std::string dir = ( home != nullptr ) ? std::string( home ) + "/.ricochet" : ".ricochet";
+ static_cast<void>( mkdir( dir.c_str(), 0755 ) );
+ return dir + "/" + filename;
+}
+
+void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& url )
+{
+ if ( url == "ricochet://bookmarks" ) {
+ return;
+ }
+ std::ofstream file( get_config_path( "bookmarks.txt" ), std::ios::app );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string safe_title = title;
+ if ( safe_title.empty() ) {
+ safe_title = url;
+ }
+ for ( char& c : safe_title ) {
+ if ( c == '\n' || c == '\r' || c == '\t' ) {
+ c = ' ';
+ }
+ }
+ file << url << '\t' << safe_title << '\n';
+}
+
+std::string generate_bookmarks_html()
+{
+ std::ifstream file( get_config_path( "bookmarks.txt" ) );
+ std::string html = "<html><head><title>My Bookmarks</title></head><body><h1>Saved Bookmarks</h1><ul>";
+ if ( !file.is_open() ) {
+ html += "<li>No bookmarks found. Press 'b' on any page to add one!</li>";
+ } else {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t tab_pos = line.find( '\t' );
+ if ( tab_pos != std::string::npos ) {
+ const std::string url = line.substr( 0, tab_pos );
+ const std::string title = line.substr( tab_pos + 1 );
+ html += "<li><a href=\"";
+ html += url;
+ html += "\">[Link] ";
+ html += title;
+ html += "</a></li>";
+ }
+ }
+ }
+ html += "</ul></body></html>";
+ return html;
+}
+
+void append_to_history( const std::string& url, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& title )
+{
+ if ( url.starts_with( "ricochet://" ) ) {
+ return;
+ }
+ std::ofstream file( get_config_path( "history.txt" ), std::ios::app );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string safe_title = title;
+ if ( safe_title.empty() ) {
+ safe_title = url;
+ }
+ for ( char& c : safe_title ) {
+ if ( c == '\n' || c == '\r' || c == '\t' ) {
+ c = ' ';
+ }
+ }
+ file << url << '\t' << safe_title << '\n';
+}
+
+std::string generate_history_html()
+{
+ std::ifstream file( get_config_path( "history.txt" ) );
+ std::string html = "<html><head><title>History</title></head><body><h1>Browsing History</h1><ul>";
+ if ( !file.is_open() ) {
+ html += "<li>No history found. Start browsing!</li>";
+ } else {
+ std::vector<std::string> lines;
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ lines.push_back( std::move( line ) );
+ }
+
+ std::size_t count = 0;
+ for ( auto it = lines.rbegin(); it != lines.rend() && count < 100; ++it, ++count ) {
+ const std::size_t tab_pos = it->find( '\t' );
+ if ( tab_pos != std::string::npos ) {
+ const std::string url = it->substr( 0, tab_pos );
+ const std::string title = it->substr( tab_pos + 1 );
+ html += "<li><a href=\"";
+ html += url;
+ html += "\">[Link] ";
+ html += title;
+ html += "</a></li>";
+ }
+ }
+ }
+ html += "</ul></body></html>";
+ return html;
+}
+
+std::string generate_help_html()
+{
+ return R"(<html>
+ <head><title>Ricochet Help Manual</title></head>
+ <body>
+ <h1>Ricochet User Manual</h1>
+ <h2>1. Navigation & Browsing</h2>
+ <ul>
+ <li><b>j / k</b> : Scroll down / up</li>
+ <li><b>H / L</b> : History backward / forward</li>
+ <li><b>/</b> : Open prompt to enter a new URL</li>
+ </ul>
+ <h2>2. Page Interaction</h2>
+ <ul>
+ <li><b>f</b> : Toggle Link Hints (type the two letters to follow a link instantly)</li>
+ <li><b>n</b> : Find in page (search for text)</li>
+ <li><b>N</b> : Jump to the next search match</li>
+ <li><b>i</b> : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)</li>
+ </ul>
+ <h2>3. Bookmarks & History</h2>
+ <ul>
+ <li><b>b</b> : Bookmark the current page</li>
+ <li><b>B</b> : View all saved bookmarks</li>
+ <li><b>h</b> : View browsing history</li>
+ </ul>
+ <h2>4. System</h2>
+ <ul>
+ <li><b>?</b> : Show this help manual</li>
+ <li><b>q</b> : Quit Ricochet</li>
+ </ul>
+ </body>
+ </html>)";
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/page_loader.cc b/ricochet/src/core/page_loader.cc
new file mode 100644
index 0000000..b9a5da5
--- /dev/null
+++ b/ricochet/src/core/page_loader.cc
@@ -0,0 +1,78 @@
+/*
+ * 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/core/page_loader.hh"
+#include "ricochet/core/local_data.hh"
+#include "ricochet/core/page_processor.hh"
+#include "ricochet/core/utils.hh"
+#include "ricochet/parser/html_lexer.hh"
+#include "ricochet/parser/tree_builder.hh"
+#include "ricochet/render/renderer.hh"
+
+namespace ricochet::core {
+
+PageData load_page( const HttpRequest& req )
+{
+ std::string html_body;
+ if ( req.url == "ricochet://bookmarks" ) {
+ html_body = generate_bookmarks_html();
+ } else if ( req.url == "ricochet://history" ) {
+ html_body = generate_history_html();
+ } else if ( req.url == "ricochet://help" ) {
+ html_body = generate_help_html();
+ } else {
+ const net::HttpClient client;
+ auto response_result = client.fetch( req.url, req.method, req.body );
+ if ( !response_result.has_value() ) {
+ return { .lines
+ = { "\033[1;31m[!] Failed to load:\033[0m " + req.url, "", "Reason: " + response_result.error() },
+ .links = {},
+ .inputs = {},
+ .title = "Error" };
+ }
+
+ const auto& resp = response_result.value();
+ std::string c_type = resp.content_type;
+ for ( char& c : c_type ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ const bool is_text
+ = c_type.starts_with( "text/" ) || c_type.contains( "xml" ) || c_type.contains( "json" ) || c_type.empty();
+
+ if ( !is_text ) {
+ return process_binary_download( req, resp );
+ }
+ html_body = response_result.value().body;
+ }
+
+ const parser::HtmlLexer lexer;
+ const parser::TreeBuilder builder;
+ const render::Renderer renderer;
+
+ const auto dom_root = builder.build( lexer.tokenize( html_body ) );
+ const render::RenderResult result = renderer.render( dom_root );
+ const std::string title = extract_title( dom_root );
+
+ return {
+ .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title };
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/page_processor.cc b/ricochet/src/core/page_processor.cc
new file mode 100644
index 0000000..00728a9
--- /dev/null
+++ b/ricochet/src/core/page_processor.cc
@@ -0,0 +1,97 @@
+/*
+ * 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/core/page_processor.hh"
+#include <fstream>
+#include <ranges>
+#include <vector>
+
+namespace ricochet::core {
+
+std::string extract_title( const parser::DomNode& root_node )
+{
+ std::vector<const parser::DomNode*> stack;
+ stack.push_back( &root_node );
+
+ while ( !stack.empty() ) {
+ const parser::DomNode* current = stack.back();
+ stack.pop_back();
+
+ std::string base_tag = current->tag_name;
+ const std::size_t space_pos = base_tag.find( ' ' );
+ if ( space_pos != std::string::npos ) {
+ base_tag = base_tag.substr( 0, space_pos );
+ }
+
+ for ( char& c : base_tag ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ if ( base_tag == "title" ) {
+ std::string full_title;
+ for ( const auto& child : current->children ) {
+ if ( child.tag_name.empty() ) {
+ full_title += child.text_content;
+ }
+ }
+ if ( !full_title.empty() ) {
+ return full_title;
+ }
+ }
+
+ for ( const auto& child : std::ranges::reverse_view( current->children ) ) {
+ stack.push_back( &child );
+ }
+ }
+ return "";
+}
+
+PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp )
+{
+ std::string fname = "download.dat";
+ const std::size_t slash = req.url.rfind( '/' );
+ if ( slash != std::string::npos && slash + 1 < req.url.length() ) {
+ fname = req.url.substr( slash + 1 );
+ const std::size_t q_pos = fname.find( '?' );
+ if ( q_pos != std::string::npos ) {
+ fname = fname.substr( 0, q_pos );
+ }
+ }
+ if ( fname.empty() ) {
+ fname = "download.dat";
+ }
+
+ std::ofstream out( fname, std::ios::binary );
+ out.write( resp.body.data(), static_cast<std::streamsize>( resp.body.size() ) );
+ out.close();
+
+ return { .lines = { "\033[1;32m[ Download Complete ]\033[0m",
+ "",
+ "Saved File : " + fname,
+ "File Type : " + resp.content_type,
+ "File Size : " + std::to_string( resp.body.size() ) + " bytes",
+ "",
+ "Press 'H' to return to the previous page." },
+ .links = {},
+ .inputs = {},
+ .title = "Download Manager" };
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/core/utils.cc b/ricochet/src/core/utils.cc
new file mode 100644
index 0000000..a410c8a
--- /dev/null
+++ b/ricochet/src/core/utils.cc
@@ -0,0 +1,85 @@
+/*
+ * 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/core/utils.hh"
+
+namespace ricochet::core {
+
+std::vector<std::string> split_into_lines( const std::string& text )
+{
+ std::vector<std::string> lines;
+ std::size_t start = 0;
+ while ( start < text.length() ) {
+ const std::size_t end = text.find( '\n', start );
+ if ( end == std::string::npos ) {
+ lines.push_back( text.substr( start ) );
+ break;
+ }
+ lines.push_back( text.substr( start, end - start ) );
+ start = end + 1;
+ }
+ return lines;
+}
+
+std::string normalize_url( const std::string& target, const std::string& current_url )
+{
+ if ( target.starts_with( "/" ) ) {
+ if ( target.starts_with( "//" ) ) {
+ return "https:" + target;
+ }
+ const std::size_t slash_pos = current_url.find( '/', 8 );
+ const std::string domain
+ = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos );
+ return domain + target;
+ }
+ if ( !target.starts_with( "http" ) ) {
+ const std::size_t last_slash = current_url.rfind( '/' );
+ if ( last_slash > 7 ) {
+ return current_url.substr( 0, last_slash + 1 ) + target;
+ }
+ return current_url + "/" + target;
+ }
+ return target;
+}
+
+std::string strip_ddg_tracker( std::string target )
+{
+ const std::size_t uddg_pos = target.find( "uddg=" );
+ if ( uddg_pos != std::string::npos ) {
+ const std::size_t end_pos = target.find( '&', uddg_pos );
+ const std::string encoded = target.substr( uddg_pos + 5, end_pos - ( uddg_pos + 5 ) );
+ std::string decoded;
+ for ( std::size_t i = 0; i < encoded.length(); ++i ) {
+ if ( encoded[i] == '%' && i + 2 < encoded.length() ) {
+ const std::string hex = encoded.substr( i + 1, 2 );
+ try {
+ decoded += static_cast<char>( std::stoi( hex, nullptr, 16 ) );
+ } catch ( const std::exception& ) {
+ (void)0;
+ }
+ i += 2;
+ } else {
+ decoded += encoded[i];
+ }
+ }
+ return decoded;
+ }
+ return target;
+}
+
+} // namespace ricochet::core
diff --git a/ricochet/src/main.cc b/ricochet/src/main.cc
new file mode 100644
index 0000000..d27ab55
--- /dev/null
+++ b/ricochet/src/main.cc
@@ -0,0 +1,73 @@
+/*
+ * 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 <iostream>
+#include <span>
+#include <string_view>
+#include <vector>
+
+#include "ricochet/core/browser.hh"
+#include "ricochet/core/license.hh"
+
+namespace {
+
+void print_usage( std::string_view program_name )
+{
+ std::cout << "Usage: " << program_name << " [URL]\n"
+ << "Example: " << program_name << " https://wikipedia.org\n";
+}
+
+} // namespace
+
+int main( int argc, char* argv[] )
+{
+ const std::span<char*> args_view( argv, static_cast<size_t>( argc ) );
+ const std::vector<std::string_view> args( args_view.begin(), args_view.end() );
+
+ ricochet::core::print_license_notice();
+
+ if ( args.size() >= 2 && args[1] == "show" ) {
+ if ( args.size() >= 3 && args[2] == "w" ) {
+ ricochet::core::show_warranty();
+ return 0;
+ }
+ if ( args.size() >= 3 && args[2] == "c" ) {
+ ricochet::core::show_copying();
+ return 0;
+ }
+ std::cerr << "Error: Invalid argument for show command.\n";
+ print_usage( args[0] );
+ return 1;
+ }
+
+ std::string start_url;
+ if ( args.size() < 2 ) {
+ start_url = ricochet::core::Browser::get_configured_homepage();
+ } else {
+ start_url = std::string( args[1] );
+ }
+
+ start_url = ricochet::core::Browser::format_url( std::move( start_url ) );
+
+ try {
+ return ricochet::core::Browser::run( start_url );
+ } catch ( const std::exception& e ) {
+ std::cerr << "Critical Error: " << e.what() << "\n";
+ return 1;
+ }
+} \ No newline at end of file
diff --git a/ricochet/src/net/config.cc b/ricochet/src/net/config.cc
new file mode 100644
index 0000000..6f257df
--- /dev/null
+++ b/ricochet/src/net/config.cc
@@ -0,0 +1,64 @@
+/*
+ * 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/net/config.hh"
+
+#include <cstdlib>
+#include <fstream>
+#include <sys/stat.h>
+
+namespace ricochet::net {
+
+std::string get_config_path( const std::string& filename )
+{
+ const char* home = std::getenv( "HOME" ); // NOLINT(concurrency-mt-unsafe)
+ const std::string dir = ( home != nullptr ) ? std::string( home ) + "/.ricochet" : ".ricochet";
+ static_cast<void>( mkdir( dir.c_str(), 0755 ) );
+ return dir + "/" + filename;
+}
+
+BrowserConfig& get_browser_config()
+{
+ static BrowserConfig config { .user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ .homepage = "https://wikipedia.org" };
+ static bool loaded = false;
+ if ( !loaded ) {
+ std::ifstream file( get_config_path( "config.txt" ) );
+ if ( file.is_open() ) {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t eq = line.find( '=' );
+ if ( eq == std::string::npos ) {
+ continue;
+ }
+ const std::string key = line.substr( 0, eq );
+ const std::string val = line.substr( eq + 1 );
+ if ( key == "user_agent" ) {
+ config.user_agent = val;
+ } else if ( key == "homepage" ) {
+ config.homepage = val;
+ }
+ }
+ }
+ loaded = true;
+ }
+ return config;
+}
+
+} // namespace ricochet::net
diff --git a/ricochet/src/net/cookie_jar.cc b/ricochet/src/net/cookie_jar.cc
new file mode 100644
index 0000000..1328511
--- /dev/null
+++ b/ricochet/src/net/cookie_jar.cc
@@ -0,0 +1,124 @@
+/*
+ * 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/net/cookie_jar.hh"
+#include "ricochet/net/config.hh"
+
+#include <fstream>
+
+namespace ricochet::net {
+
+void load_cookies_from_file( CookieJar& jar )
+{
+ std::ifstream file( get_config_path( "cookies.txt" ) );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t t1 = line.find( '\t' );
+ if ( t1 == std::string::npos ) {
+ continue;
+ }
+ const std::size_t t2 = line.find( '\t', t1 + 1 );
+ if ( t2 != std::string::npos ) {
+ jar[line.substr( 0, t1 )][line.substr( t1 + 1, t2 - t1 - 1 )] = line.substr( t2 + 1 );
+ }
+ }
+}
+
+CookieJar& get_cookie_jar()
+{
+ static CookieJar jar;
+ static bool initialized = false;
+ if ( !initialized ) {
+ load_cookies_from_file( jar );
+ initialized = true;
+ }
+ return jar;
+}
+
+void save_cookies_to_file( const CookieJar& jar )
+{
+ std::ofstream file( get_config_path( "cookies.txt" ), std::ios::trunc );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ for ( const auto& [domain, cookies] : jar ) {
+ for ( const auto& [k, v] : cookies ) {
+ file << domain << '\t' << k << '\t' << v << '\n';
+ }
+ }
+}
+
+void update_cookies( const std::string& raw_response, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& host )
+{
+ std::size_t pos = 0;
+ bool updated = false;
+ while ( ( pos = raw_response.find( "Set-Cookie: ", pos ) ) != std::string::npos ) {
+ pos += 12;
+ const std::size_t end_pos = raw_response.find( '\r', pos );
+ if ( end_pos == std::string::npos ) {
+ break;
+ }
+
+ const std::string cookie_line = raw_response.substr( pos, end_pos - pos );
+ const std::size_t semi_pos = cookie_line.find( ';' );
+ const std::string cookie_pair
+ = ( semi_pos == std::string::npos ) ? cookie_line : cookie_line.substr( 0, semi_pos );
+
+ const std::size_t eq_pos = cookie_pair.find( '=' );
+ if ( eq_pos != std::string::npos ) {
+ const std::string key = cookie_pair.substr( 0, eq_pos );
+ const std::string val = cookie_pair.substr( eq_pos + 1 );
+ get_cookie_jar()[host][key] = val;
+ updated = true;
+ }
+ }
+ if ( updated ) {
+ save_cookies_to_file( get_cookie_jar() );
+ }
+}
+
+std::string build_cookie_header( const std::string& host )
+{
+ const auto& jar = get_cookie_jar();
+ auto it = jar.find( host );
+ if ( it == jar.end() || it->second.empty() ) {
+ return "";
+ }
+
+ std::string header = "Cookie: ";
+ bool first = true;
+ for ( const auto& [k, v] : it->second ) {
+ if ( !first ) {
+ header += "; ";
+ }
+ header += k;
+ header += "=";
+ header += v;
+ first = false;
+ }
+ header += "\r\n";
+ return header;
+}
+
+} // namespace ricochet::net
diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc
new file mode 100644
index 0000000..4a10c02
--- /dev/null
+++ b/ricochet/src/net/http_client.cc
@@ -0,0 +1,212 @@
+/*
+ * 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/net/http_client.hh"
+
+#include "ricochet/net/config.hh"
+#include "ricochet/net/cookie_jar.hh"
+#include "ricochet/net/http_utils.hh"
+#include "ricochet/net/ssl_socket.hh"
+#include "ricochet/net/sys/address.hh"
+#include "ricochet/net/sys/socket.hh"
+#include "ricochet/net/url_parser.hh"
+
+#include <cstddef>
+#include <format>
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+namespace {
+
+struct FetchResult
+{
+ std::string raw_response {};
+ std::string error {};
+};
+
+FetchResult do_network_request( const std::string& host,
+ const std::string& service, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& path, // NOLINT(bugprone-easily-swappable-parameters)
+ bool is_https,
+ const std::string& method, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& body,
+ const std::string& cookie_header ) // NOLINT(bugprone-easily-swappable-parameters)
+{
+ FetchResult res;
+ try {
+ const Address server_addr( host, service );
+ std::string request = std::format( "{} {} HTTP/1.1\r\n"
+ "Host: {}\r\n"
+ "User-Agent: {}\r\n"
+ "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n"
+ "Accept-Language: en-US,en;q=0.9\r\n"
+ "DNT: 1\r\n"
+ "{}"
+ "Connection: close\r\n",
+ method,
+ path,
+ host,
+ get_browser_config().user_agent,
+ cookie_header );
+ if ( !body.empty() ) {
+ request += std::format( "Content-Length: {}\r\n"
+ "Content-Type: application/x-www-form-urlencoded\r\n",
+ body.length() );
+ }
+ request += "\r\n" + body;
+
+ if ( is_https ) {
+ SSLSocket ssl_socket;
+ ssl_socket.connect( server_addr, host );
+ for ( size_t bw = 0; bw < request.size(); ) {
+ bw += ssl_socket.write( request.substr( bw ) );
+ }
+ while ( !ssl_socket.eof() ) {
+ std::string buffer;
+ ssl_socket.read( buffer );
+ res.raw_response += buffer;
+ }
+ } else {
+ TCPSocket tcp_socket;
+ tcp_socket.connect( server_addr );
+ for ( size_t bw = 0; bw < request.size(); ) {
+ bw += tcp_socket.write( request.substr( bw ) );
+ }
+ while ( !tcp_socket.eof() ) {
+ std::string buffer;
+ tcp_socket.read( buffer );
+ res.raw_response += buffer;
+ }
+ }
+ } catch ( const std::exception& e ) {
+ res.error = std::string( "Network Error: " ) + e.what();
+ }
+ return res;
+}
+
+void parse_response_headers( const std::string& raw_response, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& host, // NOLINT(bugprone-easily-swappable-parameters)
+ HttpResponse& response,
+ std::string& headers )
+{
+ const auto header_end = raw_response.find( "\r\n\r\n" );
+ headers = ( header_end != std::string::npos ) ? raw_response.substr( 0, header_end ) : raw_response;
+ update_cookies( headers, host );
+
+ if ( header_end != std::string::npos ) {
+ response.body = raw_response.substr( header_end + 4 );
+ std::size_t ct_pos = headers.find( "Content-Type: " );
+ if ( ct_pos == std::string::npos ) {
+ ct_pos = headers.find( "content-type: " );
+ }
+ if ( ct_pos != std::string::npos ) {
+ ct_pos += 14;
+ const std::size_t end_pos = headers.find( '\r', ct_pos );
+ if ( end_pos != std::string::npos ) {
+ response.content_type = headers.substr( ct_pos, end_pos - ct_pos );
+ }
+ }
+ if ( raw_response.size() >= 12 ) {
+ try {
+ response.status_code = std::stoi( raw_response.substr( 9, 3 ) );
+ } catch ( ... ) {
+ response.status_code = 0;
+ }
+ }
+ } else {
+ response.body = raw_response;
+ response.status_code = 0;
+ }
+}
+
+void sanitize_response_body( HttpResponse& response, const std::string& raw_response )
+{
+ if ( raw_response.contains( "Transfer-Encoding: chunked" ) ) {
+ response.body = dechunk( response.body );
+ }
+ std::string ct_lower = response.content_type;
+ for ( char& c : ct_lower ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ if ( ct_lower.starts_with( "text/" ) || ct_lower.contains( "xml" ) ) {
+ response.body = remove_html_comments( response.body );
+ response.body = remove_tag_blocks( response.body, "script" );
+ response.body = remove_tag_blocks( response.body, "style" );
+ }
+}
+
+} // namespace
+
+std::expected<HttpResponse, std::string>
+HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static)
+ std::string_view url, // NOLINT(bugprone-easily-swappable-parameters)
+ std::string_view method, // NOLINT(bugprone-easily-swappable-parameters)
+ std::string_view body ) const // NOLINT(bugprone-easily-swappable-parameters)
+{
+ std::string current_url( url );
+ std::string current_method( method );
+ std::string current_body( body );
+
+ for ( int redirect_count = 0; redirect_count < 5; ++redirect_count ) {
+ const auto parsed_url = parse_url( current_url );
+ if ( !parsed_url.has_value() ) {
+ return std::unexpected( parsed_url.error() );
+ }
+
+ const auto [host, path] = parsed_url.value();
+ const bool is_https = current_url.starts_with( "https://" );
+ const std::string service = is_https ? "https" : "http";
+
+ const std::string cookie_header = build_cookie_header( host );
+
+ const FetchResult net_res
+ = do_network_request( host, service, path, is_https, current_method, current_body, cookie_header );
+ if ( !net_res.error.empty() ) {
+ return std::unexpected( net_res.error );
+ }
+ const std::string& raw_response = net_res.raw_response;
+
+ HttpResponse response;
+ std::string headers;
+ parse_response_headers( raw_response, host, response, headers );
+
+ if ( response.status_code >= 300 && response.status_code < 400 ) {
+ const std::string next_url = extract_redirect_url( headers, is_https, host );
+ if ( !next_url.empty() ) {
+ current_url = next_url;
+ if ( response.status_code == 301 || response.status_code == 302 || response.status_code == 303 ) {
+ current_method = "GET";
+ current_body.clear();
+ }
+ continue;
+ }
+ }
+
+ sanitize_response_body( response, raw_response );
+ return response;
+ }
+
+ return std::unexpected( "Too many redirects" );
+}
+
+} // namespace ricochet::net \ No newline at end of file
diff --git a/ricochet/src/net/http_utils.cc b/ricochet/src/net/http_utils.cc
new file mode 100644
index 0000000..98f6dae
--- /dev/null
+++ b/ricochet/src/net/http_utils.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 "ricochet/net/http_utils.hh"
+
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+std::string dechunk( std::string_view body )
+{
+ std::string result;
+ std::size_t pos = 0;
+ while ( pos < body.size() ) {
+ auto line_end = body.find( "\r\n", pos );
+ if ( line_end == std::string_view::npos ) {
+ break;
+ }
+
+ const std::string len_str( body.substr( pos, line_end - pos ) );
+ unsigned int chunk_size = 0;
+ try {
+ chunk_size = std::stoul( len_str, nullptr, 16 );
+ } catch ( ... ) {
+ break;
+ }
+
+ if ( chunk_size == 0 ) {
+ break;
+ }
+
+ pos = line_end + 2;
+ if ( pos + chunk_size > body.size() ) {
+ break;
+ }
+ result.append( body.substr( pos, chunk_size ) );
+
+ pos += chunk_size + 2;
+ }
+ return result;
+}
+
+std::string remove_html_comments( std::string html )
+{
+ std::size_t start_pos = 0;
+ while ( ( start_pos = html.find( "<!--", start_pos ) ) != std::string::npos ) {
+ auto end_pos = html.find( "-->", start_pos );
+ if ( end_pos != std::string::npos ) {
+ html.erase( start_pos, end_pos - start_pos + 3 );
+ } else {
+ html.erase( start_pos );
+ break;
+ }
+ }
+ return html;
+}
+
+std::string remove_tag_blocks( std::string html, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& tag_name )
+{
+ const std::string open_tag = "<" + tag_name;
+ const std::string close_tag = "</" + tag_name + ">";
+ std::size_t start_pos = 0;
+
+ while ( ( start_pos = html.find( open_tag, start_pos ) ) != std::string::npos ) {
+ const std::size_t end_pos = html.find( close_tag, start_pos );
+ if ( end_pos != std::string::npos ) {
+ html.erase( start_pos, end_pos - start_pos + close_tag.length() );
+ } else {
+ html.erase( start_pos );
+ break;
+ }
+ }
+ return html;
+}
+
+} // namespace ricochet::net
diff --git a/ricochet/src/net/ssl_socket.cc b/ricochet/src/net/ssl_socket.cc
new file mode 100644
index 0000000..a31b564
--- /dev/null
+++ b/ricochet/src/net/ssl_socket.cc
@@ -0,0 +1,118 @@
+/*
+ * 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/net/ssl_socket.hh"
+#include <iostream>
+#include <openssl/err.h>
+#include <openssl/ssl.h>
+#include <stdexcept>
+
+namespace ricochet::net {
+
+// --- RAII Deleters ---
+
+void SslCtxDeleter::operator()( SSL_CTX* ptr ) const
+{
+ if ( ptr != nullptr ) {
+ SSL_CTX_free( ptr );
+ }
+}
+
+void SslDeleter::operator()( SSL* ptr ) const
+{
+ if ( ptr != nullptr ) {
+ SSL_free( ptr );
+ }
+}
+
+// SSLSocket Implementation
+
+SSLSocket::SSLSocket()
+{
+ const SSL_METHOD* method = TLS_client_method();
+ ctx_.reset( SSL_CTX_new( method ) );
+ if ( !ctx_ ) {
+ throw std::runtime_error( "Failed to create SSL_CTX" );
+ }
+
+ if ( SSL_CTX_set_default_verify_paths( ctx_.get() ) != 1 ) {
+ throw std::runtime_error( "Failed to load default CA certificates" );
+ }
+}
+
+void SSLSocket::connect( const Address& address, const std::string& hostname )
+{
+ // TCP Connect
+ underlying_socket_.connect( address );
+
+ ssl_.reset( SSL_new( ctx_.get() ) );
+ if ( !ssl_ ) {
+ throw std::runtime_error( "Failed to create SSL object" );
+ }
+
+ // Setup SNI (Server Name Indication)
+ if ( SSL_set_tlsext_host_name( ssl_.get(), hostname.c_str() ) != 1 ) {
+ // Some old servers do not support SNI, here we can ignore the error or throw an exception, depending on the
+ // requirements
+ std::cerr << "Warning: Failed to set SNI" << '\n';
+ }
+
+ SSL_set_fd( ssl_.get(), underlying_socket_.fd_num() );
+
+ if ( SSL_connect( ssl_.get() ) != 1 ) {
+ throw std::runtime_error( "TLS Handshake failed!" );
+ }
+}
+
+std::size_t SSLSocket::write( std::string_view data )
+{
+ if ( !ssl_ ) {
+ return 0;
+ }
+
+ const int bytes_written = SSL_write( ssl_.get(), data.data(), static_cast<int>( data.size() ) );
+ if ( bytes_written <= 0 ) {
+ throw std::runtime_error( "SSL write failed" );
+ }
+ return static_cast<std::size_t>( bytes_written );
+}
+
+void SSLSocket::read( std::string& buffer )
+{
+ if ( !ssl_ ) {
+ return;
+ }
+
+ std::array<char, 4096> buf {};
+ // Read decrypted data from the encrypted channel
+ const int bytes_read = SSL_read( ssl_.get(), buf.data(), static_cast<int>( buf.size() ) );
+
+ if ( bytes_read > 0 ) {
+ buffer.append( buf.data(), static_cast<std::size_t>( bytes_read ) );
+ } else {
+ // Read 0 or negative, representing the server closed the connection (EOF)
+ eof_ = true;
+ }
+}
+
+bool SSLSocket::eof() const
+{
+ return eof_;
+}
+
+} // namespace ricochet::net \ No newline at end of file
diff --git a/ricochet/src/net/sys/address.cc b/ricochet/src/net/sys/address.cc
new file mode 100644
index 0000000..48f22ac
--- /dev/null
+++ b/ricochet/src/net/sys/address.cc
@@ -0,0 +1,207 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @notice This file was adapted from the Minnow networking framework,
+ * originally developed for Stanford University's CS144 course.
+ * It has been incorporated into the Ricochet project as foundational
+ * system utility code.
+ * ---------------------------------------------------------------------------
+ */
+
+#include "ricochet/net/sys/address.hh"
+
+#include "ricochet/net/sys/exception.hh"
+
+#include <array>
+#include <cstring>
+#include <linux/if_packet.h>
+#include <memory>
+#include <netdb.h>
+#include <stdexcept>
+#include <sys/socket.h>
+#include <system_error>
+
+using namespace std;
+
+//! Converts Raw to `sockaddr *`.
+Address::Raw::operator sockaddr*()
+{
+ return reinterpret_cast<sockaddr*>( &storage ); // NOLINT(*-reinterpret-cast)
+}
+
+//! Converts Raw to `const sockaddr *`.
+Address::Raw::operator const sockaddr*() const
+{
+ return reinterpret_cast<const sockaddr*>( &storage ); // NOLINT(*-reinterpret-cast)
+}
+
+//! \param[in] addr points to a raw socket address
+//! \param[in] size is `addr`'s length
+Address::Address( const sockaddr* addr, const size_t size ) : size_( size )
+{
+ // make sure proposed sockaddr can fit
+ if ( size > sizeof( address_.storage ) ) {
+ throw runtime_error( "invalid sockaddr size" );
+ }
+
+ memcpy( &address_.storage, addr, size );
+}
+
+//! Error category for getaddrinfo and getnameinfo failures.
+class gai_error_category : public error_category
+{
+public:
+ //! The name of the wrapped error
+ const char* name() const noexcept override { return "gai_error_category"; }
+ //! \brief An error message
+ //! \param[in] return_value the error return value from [getaddrinfo(3)](\ref man3::getaddrinfo)
+ //! or [getnameinfo(3)](\ref man3::getnameinfo)
+ string message( const int return_value ) const noexcept override { return gai_strerror( return_value ); }
+};
+
+//! \param[in] node is the hostname or dotted-quad address
+//! \param[in] service is the service name or numeric string
+//! \param[in] hints are criteria for resolving the supplied name
+Address::Address( const string& node, const string& service, const addrinfo& hints ) : size_()
+{
+ // prepare for the answer
+ addrinfo* resolved_address = nullptr;
+
+ // look up the name or names
+ const int gai_ret = getaddrinfo( node.c_str(), service.c_str(), &hints, &resolved_address );
+ if ( gai_ret != 0 ) {
+ throw tagged_error( gai_error_category(), "getaddrinfo(" + node + ", " + service + ")", gai_ret );
+ }
+
+ // if success, should always have at least one entry
+ if ( resolved_address == nullptr ) {
+ throw runtime_error( "getaddrinfo returned successfully but with no results" );
+ }
+
+ // put resolved_address in a wrapper so it will get freed if we have to throw an exception
+ auto addrinfo_deleter = []( addrinfo* const x ) { freeaddrinfo( x ); };
+ unique_ptr<addrinfo, decltype( addrinfo_deleter )> wrapped_address( resolved_address, move( addrinfo_deleter ) );
+
+ // assign to our private members (making sure size fits)
+ *this = Address( wrapped_address->ai_addr, wrapped_address->ai_addrlen );
+}
+
+//! \brief Build a `struct addrinfo` containing hints for [getaddrinfo(3)](\ref man3::getaddrinfo)
+//! \param[in] ai_flags is the value of the `ai_flags` field in the [struct addrinfo](\ref man3::getaddrinfo)
+//! \param[in] ai_family is the value of the `ai_family` field in the [struct addrinfo](\ref
+//! man3::getaddrinfo)
+namespace {
+inline addrinfo make_hints( int ai_flags, int ai_family ) // NOLINT(*-swappable-parameters)
+{
+ addrinfo hints {}; // value initialized to all zeros
+ hints.ai_flags = ai_flags;
+ hints.ai_family = ai_family;
+ return hints;
+}
+} // namespace
+
+//! \param[in] hostname to resolve
+//! \param[in] service name (from `/etc/services`, e.g., "http" is port 80)
+Address::Address( const string& hostname, const string& service )
+ : Address( hostname, service, make_hints( AI_ALL, AF_INET ) )
+{}
+
+//! \param[in] ip address as a dotted quad ("1.1.1.1")
+//! \param[in] port number
+Address::Address( const string& ip, const uint16_t port )
+ // tell getaddrinfo that we don't want to resolve anything
+ : Address( ip, ::to_string( port ), make_hints( AI_NUMERICHOST | AI_NUMERICSERV, AF_INET ) )
+{}
+
+// accessors
+pair<string, uint16_t> Address::ip_port() const
+{
+ if ( address_.storage.ss_family != AF_INET and address_.storage.ss_family != AF_INET6 ) {
+ throw runtime_error( "Address::ip_port() called on non-Internet address" );
+ }
+
+ array<char, NI_MAXHOST> ip {};
+ array<char, NI_MAXSERV> port {};
+
+ const int gni_ret = getnameinfo( static_cast<const sockaddr*>( address_ ),
+ size_,
+ ip.data(),
+ ip.size(),
+ port.data(),
+ port.size(),
+ NI_NUMERICHOST | NI_NUMERICSERV );
+ if ( gni_ret != 0 ) {
+ throw tagged_error( gai_error_category(), "getnameinfo", gni_ret );
+ }
+
+ return { ip.data(), stoi( port.data() ) };
+}
+
+string Address::to_string() const
+{
+ if ( address_.storage.ss_family == AF_INET or address_.storage.ss_family == AF_INET6 ) {
+ const auto ip_and_port = ip_port();
+ return ip_and_port.first + ":" + ::to_string( ip_and_port.second );
+ }
+
+ return "(non-Internet address)";
+}
+
+uint32_t Address::ipv4_numeric() const
+{
+ if ( address_.storage.ss_family != AF_INET or size_ != sizeof( sockaddr_in ) ) {
+ throw runtime_error( "ipv4_numeric called on non-IPV4 address" );
+ }
+
+ sockaddr_in ipv4_addr {};
+ memcpy( &ipv4_addr, &address_.storage, size_ );
+
+ return be32toh( ipv4_addr.sin_addr.s_addr );
+}
+
+Address Address::from_ipv4_numeric( const uint32_t ip_address )
+{
+ sockaddr_in ipv4_addr {};
+ ipv4_addr.sin_family = AF_INET;
+ ipv4_addr.sin_addr.s_addr = htobe32( ip_address );
+
+ return { reinterpret_cast<sockaddr*>( &ipv4_addr ), sizeof( ipv4_addr ) }; // NOLINT(*-reinterpret-cast)
+}
+
+// equality
+bool Address::operator==( const Address& other ) const
+{
+ if ( size_ != other.size_ ) {
+ return false;
+ }
+
+ return 0 == memcmp( &address_, &other.address_, size_ );
+}
+
+// address families that correspond to each sockaddr type
+template<typename sockaddr_type>
+constexpr int sockaddr_family = -1;
+
+template<>
+constexpr int sockaddr_family<sockaddr_in> = AF_INET;
+
+template<>
+constexpr int sockaddr_family<sockaddr_in6> = AF_INET6;
+
+template<>
+constexpr int sockaddr_family<sockaddr_ll> = AF_PACKET;
+
+// safely cast the address to its underlying sockaddr type
+template<typename sockaddr_type>
+const sockaddr_type* Address::as() const
+{
+ const sockaddr* raw { address_ };
+ if ( sizeof( sockaddr_type ) < size() or raw->sa_family != sockaddr_family<sockaddr_type> ) {
+ throw runtime_error( "Address::as() conversion failure" );
+ }
+
+ return reinterpret_cast<const sockaddr_type*>( raw ); // NOLINT(*-reinterpret-cast)
+}
+
+template const sockaddr_in* Address::as<sockaddr_in>() const;
+template const sockaddr_in6* Address::as<sockaddr_in6>() const;
+template const sockaddr_ll* Address::as<sockaddr_ll>() const;
diff --git a/ricochet/src/net/sys/file_descriptor.cc b/ricochet/src/net/sys/file_descriptor.cc
new file mode 100644
index 0000000..b765308
--- /dev/null
+++ b/ricochet/src/net/sys/file_descriptor.cc
@@ -0,0 +1,213 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @notice This file was adapted from the Minnow networking framework,
+ * originally developed for Stanford University's CS144 course.
+ * It has been incorporated into the Ricochet project as foundational
+ * system utility code.
+ * ---------------------------------------------------------------------------
+ */
+
+#include "ricochet/net/sys/file_descriptor.hh"
+
+#include "ricochet/net/sys/exception.hh"
+
+#include <fcntl.h>
+#include <iostream>
+#include <stdexcept>
+#include <sys/types.h>
+#include <sys/uio.h>
+#include <unistd.h>
+
+using namespace std;
+
+size_t FileDescriptor::FDWrapper::CheckFDSystemCall( string_view what, ssize_t return_value ) const
+{
+ if ( return_value >= 0 ) {
+ return return_value;
+ }
+
+ if ( non_blocking_ and ( errno == EAGAIN or errno == EWOULDBLOCK or errno == EINPROGRESS ) ) {
+ return 0;
+ }
+
+ throw unix_error { what };
+}
+
+size_t FileDescriptor::FDWrapper::CheckRead( string_view what, ssize_t return_value )
+{
+ if ( return_value == 0 ) {
+ eof_ = true;
+ }
+
+ return CheckFDSystemCall( what, return_value );
+}
+
+size_t FileDescriptor::CheckFDSystemCall( string_view what, ssize_t return_value ) const
+{
+ if ( not internal_fd_ ) {
+ throw runtime_error( "internal error: missing internal_fd_" );
+ }
+ return internal_fd_->CheckFDSystemCall( what, return_value );
+}
+
+size_t FileDescriptor::CheckRead( string_view what, ssize_t return_value )
+{
+ if ( not internal_fd_ ) {
+ throw runtime_error( "internal error: missing internal_fd_" );
+ }
+ return internal_fd_->CheckRead( what, return_value );
+}
+
+// fd is the file descriptor number returned by [open(2)](\ref man2::open) or similar
+FileDescriptor::FDWrapper::FDWrapper( int fd ) : fd_( fd )
+{
+ if ( fd < 0 ) {
+ throw runtime_error( "invalid fd number:" + to_string( fd ) );
+ }
+
+ const int flags = CheckSystemCall( "fcntl", fcntl( fd, F_GETFL ) ); // NOLINT(*-vararg)
+ non_blocking_ = flags & O_NONBLOCK; // NOLINT(*-bitwise)
+}
+
+void FileDescriptor::FDWrapper::close()
+{
+ CheckSystemCall( "close", ::close( fd_ ) );
+ eof_ = closed_ = true;
+}
+
+FileDescriptor::FDWrapper::~FDWrapper()
+{
+ try {
+ if ( closed_ ) {
+ return;
+ }
+ close();
+ } catch ( const exception& e ) {
+ // don't throw an exception from the destructor
+ cerr << "Exception destructing FDWrapper: " << e.what() << "\n";
+ }
+}
+
+// fd is the file descriptor number returned by [open(2)](\ref man2::open) or similar
+FileDescriptor::FileDescriptor( int fd ) : internal_fd_( make_shared<FDWrapper>( fd ) ) {}
+
+// Private constructor used by duplicate()
+FileDescriptor::FileDescriptor( shared_ptr<FDWrapper> other_shared_ptr ) : internal_fd_( move( other_shared_ptr ) )
+{}
+
+// returns a copy of this FileDescriptor
+FileDescriptor FileDescriptor::duplicate() const
+{
+ return FileDescriptor { internal_fd_ };
+}
+
+// Read into a single buffer of given length (which if empty will be resized to a reasonable value before reading).
+// Afer the read, the buffer will be resized to match whatever was read.
+void FileDescriptor::read( string& buffer )
+{
+ if ( buffer.empty() ) {
+ buffer.resize( kReadBufferSize );
+ }
+
+ const size_t bytes_read = CheckRead( "read", ::read( fd_num(), buffer.data(), buffer.size() ) );
+ register_read();
+
+ if ( bytes_read > buffer.size() ) {
+ throw runtime_error( "read() read more than requested" );
+ }
+
+ buffer.resize( bytes_read );
+}
+
+// Read into a vector of buffers (if all empty, the last one will be resized to a reasonable value).
+// After the read, the buffers will be resized to match whatever was read.
+void FileDescriptor::read( vector<string>& buffers )
+{
+ if ( buffers.empty() ) {
+ throw runtime_error( "FileDescriptor::read called with no buffers" );
+ }
+
+ if ( buffers.back().empty() ) {
+ buffers.back().resize( kReadBufferSize );
+ }
+
+ static thread_local vector<iovec> iovecs;
+ const size_t total_size = to_iovecs( buffers, iovecs );
+
+ const size_t bytes_read
+ = CheckRead( "readv", readv( fd_num(), iovecs.data(), static_cast<int>( iovecs.size() ) ) );
+ register_read();
+
+ if ( bytes_read > total_size ) {
+ throw runtime_error( "read() read more than requested" );
+ }
+
+ size_t remaining_size = bytes_read;
+ for ( auto& buf : buffers ) {
+ if ( remaining_size >= buf.size() ) {
+ remaining_size -= buf.size();
+ } else {
+ buf.resize( remaining_size );
+ remaining_size = 0;
+ }
+ }
+}
+
+void FileDescriptor::write_all( string_view buffer )
+{
+ if ( not blocking() ) {
+ throw runtime_error( "write_all requires a blocking file descriptor" );
+ // otherwise, this could call write on a non-blocking fd that isn't "ready" for writing
+ }
+
+ while ( not buffer.empty() ) {
+ buffer.remove_prefix( write( buffer ) );
+ }
+}
+
+size_t FileDescriptor::write( string_view buffer )
+{
+ const size_t bytes_written = CheckFDSystemCall( "write", ::write( fd_num(), buffer.data(), buffer.size() ) );
+ register_write();
+
+ if ( bytes_written == 0 and not buffer.empty() ) {
+ throw runtime_error( "write returned 0 given non-empty input buffer" );
+ }
+
+ if ( bytes_written > buffer.size() ) {
+ throw runtime_error( "write wrote more than length of input buffer" );
+ }
+
+ return bytes_written;
+}
+
+size_t FileDescriptor::write( const std::vector<iovec>& iovecs, size_t total_size )
+{
+ const size_t bytes_written
+ = CheckFDSystemCall( "writev", ::writev( fd_num(), iovecs.data(), static_cast<int>( iovecs.size() ) ) );
+ register_write();
+
+ if ( bytes_written == 0 and total_size != 0 ) {
+ throw runtime_error( "writev returned 0 given non-empty input buffer" );
+ }
+
+ if ( bytes_written > total_size ) {
+ throw runtime_error( "writev wrote more than length of input buffer" );
+ }
+
+ return bytes_written;
+}
+
+void FileDescriptor::set_blocking( bool blocking )
+{
+ int flags = CheckSystemCall( "fcntl", fcntl( fd_num(), F_GETFL ) ); // NOLINT(*-vararg)
+ if ( blocking ) {
+ flags ^= ( flags & O_NONBLOCK ); // NOLINT(*-bitwise)
+ } else {
+ flags |= O_NONBLOCK; // NOLINT(*-bitwise)
+ }
+
+ CheckSystemCall( "fcntl", fcntl( fd_num(), F_SETFL, flags ) ); // NOLINT(*-vararg)
+
+ internal_fd_->non_blocking_ = not blocking;
+}
diff --git a/ricochet/src/net/sys/socket.cc b/ricochet/src/net/sys/socket.cc
new file mode 100644
index 0000000..701704f
--- /dev/null
+++ b/ricochet/src/net/sys/socket.cc
@@ -0,0 +1,286 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @notice This file was adapted from the Minnow networking framework,
+ * originally developed for Stanford University's CS144 course.
+ * It has been incorporated into the Ricochet project as foundational
+ * system utility code.
+ * ---------------------------------------------------------------------------
+ */
+
+#include "ricochet/net/sys/socket.hh"
+
+#include "ricochet/net/sys/exception.hh"
+
+#include <linux/if_packet.h>
+#include <stdexcept>
+#include <unistd.h>
+
+using namespace std;
+
+// default constructor for socket of (subclassed) domain and type
+//! \param[in] domain is as described in [socket(7)](\ref man7::socket), probably `AF_INET` or `AF_UNIX`
+//! \param[in] type is as described in [socket(7)](\ref man7::socket)
+Socket::Socket( const int domain, const int type, const int protocol )
+ : FileDescriptor( CheckSystemCall( "socket", socket( domain, type, protocol ) ) )
+{}
+
+// construct from file descriptor
+//! \param[in] fd is the FileDescriptor from which to construct
+//! \param[in] domain is `fd`'s domain; throws std::runtime_error if wrong value is supplied
+//! \param[in] type is `fd`'s type; throws std::runtime_error if wrong value is supplied
+//! \param[in] protocol is `fd`'s protocol; throws std::runtime_error if wrong value is supplied
+Socket::Socket( FileDescriptor&& fd, int domain, int type, int protocol ) // NOLINT(*-swappable-parameters)
+ : FileDescriptor( move( fd ) )
+{
+ int actual_value {};
+ socklen_t len {};
+
+ // verify domain
+ len = getsockopt( SOL_SOCKET, SO_DOMAIN, actual_value );
+ if ( ( len != sizeof( actual_value ) ) or ( actual_value != domain ) ) {
+ throw runtime_error( "socket domain mismatch" );
+ }
+
+ // verify type
+ len = getsockopt( SOL_SOCKET, SO_TYPE, actual_value );
+ if ( ( len != sizeof( actual_value ) ) or ( actual_value != type ) ) {
+ throw runtime_error( "socket type mismatch" );
+ }
+
+ // verify protocol
+ len = getsockopt( SOL_SOCKET, SO_PROTOCOL, actual_value );
+ if ( ( len != sizeof( actual_value ) ) or ( actual_value != protocol ) ) {
+ throw runtime_error( "socket protocol mismatch" );
+ }
+}
+
+// get the local or peer address the socket is connected to
+//! \param[in] name_of_function is the function to call (string passed to CheckSystemCall())
+//! \param[in] function is a pointer to the function
+//! \returns the requested Address
+Address Socket::get_address( const string& name_of_function,
+ const function<int( int, sockaddr*, socklen_t* )>& function ) const
+{
+ Address::Raw address;
+ socklen_t size = sizeof( address );
+
+ CheckSystemCall( name_of_function, function( fd_num(), address, &size ) );
+
+ return Address { address, size };
+}
+
+//! \returns the local Address of the socket
+Address Socket::local_address() const
+{
+ return get_address( "getsockname", getsockname );
+}
+
+//! \returns the socket's peer's Address
+Address Socket::peer_address() const
+{
+ return get_address( "getpeername", getpeername );
+}
+
+// bind socket to a specified local address (usually to listen/accept)
+//! \param[in] address is a local Address to bind
+void Socket::bind( const Address& address )
+{
+ CheckSystemCall( "bind", ::bind( fd_num(), address.raw(), address.size() ) );
+}
+
+void Socket::bind_to_device( const string_view device_name )
+{
+ setsockopt( SOL_SOCKET, SO_BINDTODEVICE, device_name );
+}
+
+// connect socket to a specified peer address
+//! \param[in] address is the peer's Address
+void Socket::connect( const Address& address )
+{
+ CheckFDSystemCall( "connect", ::connect( fd_num(), address.raw(), address.size() ) );
+}
+
+// shut down a socket in the specified way
+//! \param[in] how can be `SHUT_RD`, `SHUT_WR`, or `SHUT_RDWR`; see [shutdown(2)](\ref man2::shutdown)
+void Socket::shutdown( const int how )
+{
+ CheckSystemCall( "shutdown", ::shutdown( fd_num(), how ) );
+ switch ( how ) {
+ case SHUT_RD:
+ register_read();
+ break;
+ case SHUT_WR:
+ register_write();
+ break;
+ case SHUT_RDWR:
+ register_read();
+ register_write();
+ break;
+ default:
+ throw runtime_error( "Socket::shutdown() called with invalid `how`" );
+ }
+}
+
+void DatagramSocket::recv( Address& source_address, string& payload )
+{
+ if ( payload.empty() ) {
+ payload.resize( kReadBufferSize );
+ }
+
+ Address::Raw raw_source_address;
+ socklen_t namelen = sizeof( raw_source_address );
+ const size_t recv_len = CheckFDSystemCall(
+ "recvfrom", ::recvfrom( fd_num(), payload.data(), payload.size(), MSG_TRUNC, raw_source_address, &namelen ) );
+ register_read();
+
+ if ( recv_len > payload.size() ) {
+ throw runtime_error( "recvfrom (oversized datagram of length " + to_string( recv_len ) + ")" );
+ }
+ if ( namelen <= 0 || namelen > sizeof( raw_source_address ) ) {
+ throw runtime_error( "recvfrom gave invalid namelen" );
+ }
+
+ source_address = { raw_source_address, namelen };
+}
+
+void DatagramSocket::recv( Address& source_address, vector<string>& payloads )
+{
+ if ( payloads.empty() ) {
+ throw runtime_error( "DatagramSocket::recv called with no payload buffers" );
+ }
+
+ if ( payloads.back().empty() ) {
+ payloads.back().resize( kReadBufferSize );
+ }
+
+ // set up iovecs
+ static thread_local vector<iovec> iovecs;
+ const size_t total_size = to_iovecs( payloads, iovecs );
+
+ // set up msghdr
+ Address::Raw raw_source_address;
+ msghdr message { .msg_name = &raw_source_address,
+ .msg_namelen = sizeof( raw_source_address ),
+ .msg_iov = iovecs.data(),
+ .msg_iovlen = iovecs.size(),
+ .msg_control = nullptr,
+ .msg_controllen {},
+ .msg_flags {} };
+ const size_t recv_len = CheckFDSystemCall( "recvmsg", ::recvmsg( fd_num(), &message, MSG_TRUNC ) );
+ register_read();
+ if ( recv_len > total_size ) {
+ throw runtime_error( "recvmsg (oversized datagram of length " + to_string( recv_len ) + ")" );
+ }
+ if ( message.msg_flags & MSG_TRUNC ) {
+ throw runtime_error( "recvmsg (oversized datagram indicated only by MSG_TRUNC)" );
+ }
+ if ( message.msg_namelen <= 0 || message.msg_namelen > sizeof( raw_source_address ) ) {
+ throw runtime_error( "recvmsg gave invalid namelen" );
+ }
+ source_address = { raw_source_address, message.msg_namelen };
+
+ size_t remaining_size = recv_len;
+ for ( auto& buf : payloads ) {
+ if ( remaining_size >= buf.size() ) {
+ remaining_size -= buf.size();
+ } else {
+ buf.resize( remaining_size );
+ remaining_size = 0;
+ }
+ }
+}
+
+void DatagramSocket::send( string_view payload, const optional<Address>& destination )
+{
+ const size_t bytes_sent = CheckFDSystemCall( "sendto",
+ ::sendto( fd_num(),
+ payload.data(),
+ payload.size(),
+ 0,
+ destination.has_value() ? destination->raw() : nullptr,
+ destination.has_value() ? destination->size() : 0 ) );
+ register_write();
+ if ( bytes_sent != payload.size() ) {
+ throw runtime_error( "sendto sent some length other than that of payload" );
+ }
+}
+
+void DatagramSocket::send( vector<iovec>& iovecs, size_t total_size, const optional<Address>& destination )
+{
+ const msghdr message { .msg_name = destination.has_value() // NOLINTNEXTLINE(*-const-cast)
+ ? static_cast<void*>( const_cast<sockaddr*>( destination->raw() ) )
+ : nullptr,
+ .msg_namelen = destination.has_value() ? destination->size() : 0,
+ .msg_iov = iovecs.data(),
+ .msg_iovlen = iovecs.size(),
+ .msg_control = nullptr,
+ .msg_controllen {},
+ .msg_flags {} };
+ const size_t bytes_sent = CheckFDSystemCall( "sendmsg", ::sendmsg( fd_num(), &message, 0 ) );
+ register_write();
+ if ( bytes_sent != total_size ) {
+ throw runtime_error( "sendmsg sent some length other than that of payload" );
+ }
+}
+
+// mark the socket as listening for incoming connections
+//! \param[in] backlog is the number of waiting connections to queue (see [listen(2)](\ref man2::listen))
+void TCPSocket::listen( const int backlog )
+{
+ CheckSystemCall( "listen", ::listen( fd_num(), backlog ) );
+}
+
+// accept a new incoming connection
+//! \returns a new TCPSocket connected to the peer.
+//! \note This function blocks until a new connection is available
+TCPSocket TCPSocket::accept()
+{
+ register_read();
+ return TCPSocket( FileDescriptor( CheckSystemCall( "accept", ::accept( fd_num(), nullptr, nullptr ) ) ) );
+}
+
+// get socket option
+template<typename option_type>
+socklen_t Socket::getsockopt( const int level, const int option, option_type& option_value ) const
+{
+ socklen_t optlen = sizeof( option_value );
+ CheckSystemCall( "getsockopt", ::getsockopt( fd_num(), level, option, &option_value, &optlen ) );
+ return optlen;
+}
+
+// set socket option
+//! \param[in] level The protocol level at which the argument resides
+//! \param[in] option A single option to set
+//! \param[in] option_value The value to set
+//! \details See [setsockopt(2)](\ref man2::setsockopt) for details.
+template<typename option_type>
+void Socket::setsockopt( const int level, const int option, const option_type& option_value )
+{
+ CheckSystemCall( "setsockopt", ::setsockopt( fd_num(), level, option, &option_value, sizeof( option_value ) ) );
+}
+
+// setsockopt with size only known at runtime
+void Socket::setsockopt( const int level, const int option, const string_view option_val )
+{
+ CheckSystemCall( "setsockopt", ::setsockopt( fd_num(), level, option, option_val.data(), option_val.size() ) );
+}
+
+// allow local address to be reused sooner, at the cost of some robustness
+//! \note Using `SO_REUSEADDR` may reduce the robustness of your application
+void Socket::set_reuseaddr()
+{
+ setsockopt( SOL_SOCKET, SO_REUSEADDR, int { true } );
+}
+
+void Socket::throw_if_error() const
+{
+ int socket_error = 0;
+ const socklen_t len = getsockopt( SOL_SOCKET, SO_ERROR, socket_error );
+ if ( len != sizeof( socket_error ) ) {
+ throw runtime_error( "unexpected length from getsockopt: " + to_string( len ) );
+ }
+
+ if ( socket_error ) {
+ throw unix_error( "socket error", socket_error );
+ }
+}
diff --git a/ricochet/src/net/url_parser.cc b/ricochet/src/net/url_parser.cc
new file mode 100644
index 0000000..95e86ef
--- /dev/null
+++ b/ricochet/src/net/url_parser.cc
@@ -0,0 +1,87 @@
+/*
+ * 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/net/url_parser.hh"
+
+#include <cctype>
+
+namespace ricochet::net {
+
+std::expected<ParsedUrl, std::string> parse_url( std::string_view url )
+{
+ std::string_view prefix = "http://";
+ if ( url.starts_with( "https://" ) ) {
+ prefix = "https://";
+ } else if ( url.starts_with( "http://" ) ) {
+ prefix = "http://";
+ } else {
+ return std::unexpected( "Only 'http://' or 'https://' URLs are supported." );
+ }
+
+ url.remove_prefix( prefix.length() );
+
+ const auto sep_pos = url.find_first_of( "/?#" );
+
+ ParsedUrl result;
+ if ( sep_pos == std::string_view::npos ) {
+ result.host = std::string( url );
+ result.path = "/";
+ } else {
+ result.host = std::string( url.substr( 0, sep_pos ) );
+ std::string_view path_view = url.substr( sep_pos );
+ const auto hash_pos = path_view.find( '#' );
+ if ( hash_pos != std::string_view::npos ) {
+ path_view = path_view.substr( 0, hash_pos );
+ }
+ result.path = std::string( path_view );
+ if ( result.path.empty() || result.path[0] != '/' ) {
+ result.path.insert( 0, "/" );
+ }
+ }
+ return result;
+}
+
+std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host )
+{
+ auto loc_pos = raw_response.find( "Location: " );
+ if ( loc_pos == std::string::npos ) {
+ loc_pos = raw_response.find( "location: " );
+ }
+ if ( loc_pos != std::string::npos ) {
+ loc_pos += 10;
+ auto end_pos = raw_response.find( '\r', loc_pos );
+ if ( end_pos == std::string::npos ) {
+ end_pos = raw_response.find( '\n', loc_pos );
+ }
+ if ( end_pos != std::string::npos ) {
+ std::string next_url = raw_response.substr( loc_pos, end_pos - loc_pos );
+ while ( !next_url.empty() && std::isspace( static_cast<unsigned char>( next_url.front() ) ) ) {
+ next_url.erase( 0, 1 );
+ }
+ if ( next_url.starts_with( "//" ) ) {
+ next_url = ( is_https ? "https:" : "http:" ) + next_url;
+ } else if ( next_url.starts_with( "/" ) ) {
+ next_url = ( is_https ? "https://" : "http://" ) + host + next_url;
+ }
+ return next_url;
+ }
+ }
+ return "";
+}
+
+} // namespace ricochet::net
diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc
new file mode 100644
index 0000000..4d70288
--- /dev/null
+++ b/ricochet/src/parser/html_lexer.cc
@@ -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/>.
+ */
+
+#include "ricochet/parser/html_lexer.hh"
+
+#include <cstdint>
+#include <string_view>
+
+namespace ricochet::parser {
+
+namespace {
+enum class LexerState : std::uint8_t
+{
+ Data, // Reading plain text
+ TagOpen, // Just encountered '<'
+ TagName, // Reading the name of an opening tag (e.g., "div")
+ ClosingTagName, // Reading the name of a closing tag (e.g., "/div")
+ SwallowAttributes // Ignore attributes mode (ignore until encountering '>')
+};
+
+void emit_text( std::string& buffer, std::vector<HtmlToken>& tokens )
+{
+ if ( buffer.empty() ) {
+ return;
+ }
+
+ bool all_whitespace = true;
+ for ( const char c : buffer ) {
+ if ( !std::isspace( static_cast<unsigned char>( c ) ) ) {
+ all_whitespace = false;
+ break;
+ }
+ }
+
+ if ( !all_whitespace ) {
+ tokens.emplace_back( TextToken { buffer } );
+ }
+ buffer.clear();
+}
+
+} // namespace
+
+std::vector<HtmlToken> HtmlLexer::tokenize( // NOLINT(readability-convert-member-functions-to-static)
+ std::string_view html ) const
+{
+ std::vector<HtmlToken> tokens;
+
+ LexerState state = LexerState::Data;
+ std::string buffer {};
+
+ for ( const char c : html ) {
+ switch ( state ) {
+ case LexerState::Data:
+ if ( c == '<' ) {
+ emit_text( buffer, tokens );
+ state = LexerState::TagOpen;
+ } else {
+ buffer += c;
+ }
+ break;
+ case LexerState::TagOpen:
+ if ( c == '/' ) {
+ state = LexerState::ClosingTagName;
+ } else if ( std::isalpha( static_cast<unsigned char>( c ) ) ) {
+ buffer.push_back( c );
+ state = LexerState::TagName;
+ } else {
+ buffer.push_back( '<' );
+ buffer.push_back( c );
+ state = LexerState::Data;
+ }
+ break;
+ case LexerState::TagName:
+ if ( c == '>' ) {
+ tokens.emplace_back( TagOpenToken { buffer } );
+ buffer.clear();
+ state = LexerState::Data;
+ } else {
+ buffer += c;
+ }
+ break;
+ case LexerState::ClosingTagName:
+ if ( c == '>' ) {
+ tokens.emplace_back( TagCloseToken { buffer } );
+ buffer.clear();
+ state = LexerState::Data;
+ } else {
+ buffer += static_cast<char>( std::tolower( static_cast<unsigned char>( c ) ) );
+ }
+ break;
+ case LexerState::SwallowAttributes:
+ if ( c == '>' ) {
+ state = LexerState::Data;
+ }
+ break;
+ }
+ }
+
+ emit_text( buffer, tokens );
+
+ return tokens;
+}
+
+} // namespace ricochet::parser \ No newline at end of file
diff --git a/ricochet/src/parser/tree_builder.cc b/ricochet/src/parser/tree_builder.cc
new file mode 100644
index 0000000..64f841b
--- /dev/null
+++ b/ricochet/src/parser/tree_builder.cc
@@ -0,0 +1,85 @@
+/*
+ * 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/parser/tree_builder.hh"
+#include "ricochet/parser/dom_tree.hh"
+#include <utility>
+#include <vector>
+
+namespace ricochet::parser {
+
+namespace {
+struct TokenVisitor
+{
+ std::vector<DomNode>* stack;
+
+ void operator()( const TextToken& token ) const
+ {
+ stack->back().children.emplace_back(
+ DomNode { .tag_name = "", .text_content = token.content, .children = {} } );
+ }
+
+ void operator()( const TagOpenToken& token ) const
+ {
+ const std::size_t space_pos = token.name.find( ' ' );
+ const std::string base_name
+ = ( space_pos == std::string::npos ) ? token.name : token.name.substr( 0, space_pos );
+
+ DomNode new_node { .tag_name = token.name, .text_content = "", .children = {} };
+
+ if ( base_name == "meta" || base_name == "link" || base_name == "img" || base_name == "br" || base_name == "hr"
+ || base_name == "input" || base_name == "base" || base_name == "col" ) {
+
+ stack->back().children.push_back( std::move( new_node ) );
+ } else {
+ stack->push_back( std::move( new_node ) );
+ }
+ }
+
+ void operator()( const TagCloseToken& /*token*/ ) const
+ {
+ if ( stack->size() > 1 ) {
+ DomNode completed_node = std::move( stack->back() );
+ stack->pop_back();
+ stack->back().children.push_back( std::move( completed_node ) );
+ }
+ }
+};
+} // namespace
+
+DomNode TreeBuilder::build( // NOLINT(readability-convert-member-functions-to-static)
+ const std::vector<HtmlToken>& tokens ) const
+{
+ std::vector<DomNode> stack;
+ stack.emplace_back( DomNode { .tag_name = "Document", .text_content = "", .children = {} } );
+
+ TokenVisitor visitor { &stack };
+ for ( const auto& token : tokens ) {
+ std::visit( visitor, token );
+ }
+
+ while ( stack.size() > 1 ) {
+ DomNode completed_node = std::move( stack.back() );
+ stack.pop_back();
+ stack.back().children.push_back( std::move( completed_node ) );
+ }
+
+ return std::move( stack.front() );
+}
+
+} // namespace ricochet::parser \ No newline at end of file
diff --git a/ricochet/src/render/input_renderer.cc b/ricochet/src/render/input_renderer.cc
new file mode 100644
index 0000000..076fa2a
--- /dev/null
+++ b/ricochet/src/render/input_renderer.cc
@@ -0,0 +1,82 @@
+/*
+ * 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/render/input_renderer.hh"
+#include "ricochet/render/internal.hh"
+#include "ricochet/render/utils.hh"
+
+namespace ricochet::render {
+
+namespace {
+
+std::string build_input_hint( const std::string& tag_name, bool checked )
+{
+ std::string placeholder = extract_attr( tag_name, "placeholder" );
+ const std::string type = extract_attr( tag_name, "type" );
+ std::string val = extract_attr( tag_name, "value" );
+
+ if ( !placeholder.empty() ) {
+ return placeholder;
+ }
+ if ( type == "submit" || type == "button" ) {
+ return val.empty() ? "Submit" : val;
+ }
+ if ( type == "checkbox" ) {
+ return checked ? "[X] " + val : "[ ] " + val;
+ }
+ if ( type == "radio" ) {
+ return checked ? "(X) " + val : "( ) " + val;
+ }
+ if ( type == "hidden" ) {
+ return "hidden";
+ }
+ if ( !val.empty() ) {
+ return val;
+ }
+ return "text";
+}
+
+} // namespace
+
+void render_input_node( const parser::DomNode& node,
+ RenderResult& result,
+ const FormContext& ctx,
+ std::string& output )
+{
+ std::string type = extract_attr( node.tag_name, "type" );
+ const std::size_t space_pos = node.tag_name.find( ' ' );
+ const std::string base_tag
+ = ( space_pos == std::string::npos ) ? node.tag_name : node.tag_name.substr( 0, space_pos );
+ if ( base_tag == "button" && type.empty() ) {
+ type = "submit";
+ }
+ const std::string default_val = extract_attr( node.tag_name, "value" );
+ const bool is_checked = node.tag_name.contains( "checked" );
+ result.inputs.push_back(
+ { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method, default_val, type, is_checked } );
+
+ std::string hint = build_input_hint( node.tag_name, is_checked );
+ if ( base_tag == "button" ) {
+ hint = "Submit";
+ }
+ if ( hint != "hidden" ) {
+ output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m ";
+ }
+}
+
+} // namespace ricochet::render
diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc
new file mode 100644
index 0000000..7fb665f
--- /dev/null
+++ b/ricochet/src/render/renderer.cc
@@ -0,0 +1,123 @@
+/*
+ * 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/render/renderer.hh"
+#include "ricochet/render/input_renderer.hh"
+#include "ricochet/render/internal.hh"
+#include "ricochet/render/table_renderer.hh"
+#include "ricochet/render/utils.hh"
+
+namespace ricochet::render {
+
+namespace {
+
+void render_link_end( const parser::DomNode& node, RenderResult& result )
+{
+ result.text += "\033[0m";
+ const std::string href = extract_attr( node.tag_name, "href" );
+ if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) {
+ result.links.push_back( decode_entities( href ) );
+ result.text += "\033[8m{L:" + std::to_string( result.links.size() ) + "}\033[0m";
+ }
+}
+
+void render_img( const parser::DomNode& node, RenderResult& result )
+{
+ std::size_t alt_pos = node.tag_name.find( "alt=\"" );
+ if ( alt_pos != std::string::npos ) {
+ alt_pos += 5;
+ const std::size_t alt_end = node.tag_name.find( '"', alt_pos );
+ result.text += " \033[90m[Image: " + node.tag_name.substr( alt_pos, alt_end - alt_pos ) + "]\033[0m ";
+ } else {
+ result.text += " \033[90m[IMAGE]\033[0m ";
+ }
+}
+
+} // namespace
+
+void render_node( const parser::DomNode& node, RenderResult& result, FormContext ctx ) // NOLINT(misc-no-recursion)
+{
+ std::string& output = result.text;
+ const std::size_t space_pos = node.tag_name.find( ' ' );
+ const std::string base_tag
+ = ( space_pos == std::string::npos ) ? node.tag_name : node.tag_name.substr( 0, space_pos );
+
+ if ( is_ignored_tag( base_tag ) ) {
+ return;
+ }
+ if ( base_tag.empty() ) {
+ output += decode_entities( node.text_content );
+ return;
+ }
+
+ if ( base_tag == "table" ) {
+ render_table( node, result, ctx );
+ return;
+ }
+
+ if ( base_tag == "form" ) {
+ ctx.action = extract_attr( node.tag_name, "action" );
+ ctx.method = extract_attr( node.tag_name, "method" );
+ }
+
+ const bool is_header = is_heading_tag( base_tag );
+ const bool is_link = ( base_tag == "a" );
+ const bool is_list_item = ( base_tag == "li" );
+ const bool is_input = ( base_tag == "input" || base_tag == "textarea" || base_tag == "button" );
+
+ if ( is_header ) {
+ output += "\n\n\033[1;31m";
+ } else if ( is_link ) {
+ output += "\033[4;34m";
+ } else if ( is_list_item ) {
+ output += "\n • ";
+ } else if ( is_input ) {
+ render_input_node( node, result, ctx, output );
+ } else if ( base_tag == "p" || base_tag == "div" || is_header ) {
+ if ( !output.empty() && output.back() != '\n' ) {
+ output += "\n";
+ }
+ }
+
+ for ( const auto& child : node.children ) {
+ render_node( child, result, ctx );
+ }
+
+ if ( is_header ) {
+ output += "\033[0m";
+ } else if ( is_link ) {
+ render_link_end( node, result );
+ }
+
+ if ( base_tag == "h1" || base_tag == "p" || base_tag == "div" ) {
+ output += "\n";
+ }
+ if ( base_tag == "img" ) {
+ render_img( node, result );
+ }
+}
+
+RenderResult Renderer::render( // NOLINT(readability-convert-member-functions-to-static)
+ const parser::DomNode& node ) const
+{
+ RenderResult result;
+ render_node( node, result, FormContext {} );
+ return result;
+}
+
+} // namespace ricochet::render \ No newline at end of file
diff --git a/ricochet/src/render/table_renderer.cc b/ricochet/src/render/table_renderer.cc
new file mode 100644
index 0000000..37e6bfb
--- /dev/null
+++ b/ricochet/src/render/table_renderer.cc
@@ -0,0 +1,147 @@
+/*
+ * 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/render/table_renderer.hh"
+#include "ricochet/render/internal.hh"
+#include "ricochet/render/utils.hh"
+#include <algorithm>
+
+namespace ricochet::render {
+
+namespace {
+
+std::size_t get_node_text_len( const parser::DomNode& node ) // NOLINT(misc-no-recursion)
+{
+ std::size_t len = get_visible_length( decode_entities( node.text_content ) );
+ for ( const auto& child : node.children ) {
+ len += get_node_text_len( child );
+ }
+ return len;
+}
+
+void process_table_row_widths( const parser::DomNode& row, std::vector<std::size_t>& widths )
+{
+ std::size_t col_idx = 0;
+ for ( const auto& cell : row.children ) {
+ const std::size_t space_pos = cell.tag_name.find( ' ' );
+ const std::string tag
+ = ( space_pos == std::string::npos ) ? cell.tag_name : cell.tag_name.substr( 0, space_pos );
+ if ( tag == "td" || tag == "th" ) {
+ const std::size_t len = get_node_text_len( cell ) + 2;
+ if ( col_idx >= widths.size() ) {
+ widths.push_back( len );
+ } else if ( len > widths[col_idx] ) {
+ widths[col_idx] = len;
+ }
+ col_idx++;
+ }
+ }
+}
+
+std::vector<std::size_t> calc_table_widths( const parser::DomNode& table_node )
+{
+ std::vector<std::size_t> widths;
+ for ( const auto& child : table_node.children ) {
+ const std::size_t space_pos = child.tag_name.find( ' ' );
+ const std::string tag
+ = ( space_pos == std::string::npos ) ? child.tag_name : child.tag_name.substr( 0, space_pos );
+ if ( tag == "tr" ) {
+ process_table_row_widths( child, widths );
+ } else if ( tag == "tbody" || tag == "thead" ) {
+ for ( const auto& row : child.children ) {
+ if ( row.tag_name.starts_with( "tr" ) ) {
+ process_table_row_widths( row, widths );
+ }
+ }
+ }
+ }
+ for ( auto& w : widths ) {
+ w = std::min<std::size_t>( w, 30 );
+ }
+ return widths;
+}
+
+void render_table_row( const parser::DomNode& row,
+ RenderResult& result,
+ const FormContext& ctx,
+ const std::vector<std::size_t>& widths )
+{
+ std::string& output = result.text;
+ output += "\033[90m|\033[0m ";
+ std::size_t col_idx = 0;
+ for ( const auto& cell : row.children ) {
+ const std::size_t cell_space = cell.tag_name.find( ' ' );
+ const std::string c_tag
+ = ( cell_space == std::string::npos ) ? cell.tag_name : cell.tag_name.substr( 0, cell_space );
+ if ( c_tag != "td" && c_tag != "th" ) {
+ continue;
+ }
+
+ const std::size_t text_start = output.size();
+ if ( c_tag == "th" ) {
+ output += "\033[1;36m";
+ }
+
+ for ( const auto& cell_child : cell.children ) {
+ render_node( cell_child, result, ctx );
+ }
+
+ if ( c_tag == "th" ) {
+ output += "\033[0m";
+ }
+
+ const std::string added = output.substr( text_start );
+ const std::size_t vis_len = get_visible_length( added );
+ const std::size_t tw = ( col_idx < widths.size() ) ? widths[col_idx] : 10;
+
+ if ( vis_len < tw ) {
+ output.append( tw - vis_len, ' ' );
+ }
+ output += " \033[90m|\033[0m ";
+ col_idx++;
+ }
+ output += "\n";
+}
+
+} // namespace
+
+void render_table( const parser::DomNode& node, RenderResult& result, const FormContext& ctx )
+{
+ std::string& output = result.text;
+ const std::vector<std::size_t> widths = calc_table_widths( node );
+
+ output += "\n\033[90m+" + std::string( 78, '-' ) + "+\033[0m\n";
+
+ for ( const auto& child : node.children ) {
+ const std::size_t child_space = child.tag_name.find( ' ' );
+ const std::string child_tag
+ = ( child_space == std::string::npos ) ? child.tag_name : child.tag_name.substr( 0, child_space );
+ if ( child_tag == "tr" ) {
+ render_table_row( child, result, ctx, widths );
+ } else if ( child_tag == "tbody" || child_tag == "thead" ) {
+ for ( const auto& row : child.children ) {
+ if ( row.tag_name.starts_with( "tr" ) ) {
+ render_table_row( row, result, ctx, widths );
+ }
+ }
+ }
+ }
+ output += "\033[90m+" + std::string( 78, '-' ) + "+\033[0m\n";
+}
+
+} // namespace ricochet::render
diff --git a/ricochet/src/render/utils.cc b/ricochet/src/render/utils.cc
new file mode 100644
index 0000000..9c6610a
--- /dev/null
+++ b/ricochet/src/render/utils.cc
@@ -0,0 +1,135 @@
+/*
+ * 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/render/utils.hh"
+
+#include <array>
+#include <cstdint>
+#include <stdexcept>
+
+namespace ricochet::render {
+
+std::string decode_entities( std::string text )
+{
+ constexpr std::array<std::pair<std::string_view, std::string_view>, 25> entities
+ = { { { "&nbsp;", " " }, { "&lt;", "<" }, { "&gt;", ">" }, { "&amp;", "&" }, { "&quot;", "\"" },
+ { "&apos;", "'" }, { "&copy;", "©" }, { "&reg;", "®" }, { "&trade;", "™" }, { "&euro;", "€" },
+ { "&pound;", "£" }, { "&yen;", "¥" }, { "&cent;", "¢" }, { "&sect;", "§" }, { "&para;", "¶" },
+ { "&hellip;", "…" }, { "&dagger;", "†" }, { "&Dagger;", "‡" }, { "&bull;", "•" }, { "&prime;", "′" },
+ { "&Prime;", "″" }, { "&lsaquo;", "‹" }, { "&rsaquo;", "›" }, { "&oline;", "‾" }, { "&frasl;", "⁄" } } };
+
+ for ( const auto& [entity, replacement] : entities ) {
+ std::size_t pos = 0;
+ while ( ( pos = text.find( entity, pos ) ) != std::string::npos ) {
+ text.replace( pos, entity.length(), replacement );
+ pos += replacement.length();
+ }
+ }
+
+ std::size_t pos = 0;
+ while ( ( pos = text.find( "&#", pos ) ) != std::string::npos ) {
+ const std::size_t end = text.find( ';', pos );
+ if ( end != std::string::npos && end - pos < 10 ) {
+ const std::string code = text.substr( pos + 2, end - pos - 2 );
+ try {
+ std::uint32_t cp = 0;
+ if ( !code.empty() && ( code[0] == 'x' || code[0] == 'X' ) ) {
+ cp = std::stoul( code.substr( 1 ), nullptr, 16 );
+ } else {
+ cp = std::stoul( code );
+ }
+ if ( cp < 128 ) {
+ text.replace( pos, end - pos + 1, 1, static_cast<char>( cp ) );
+ continue;
+ }
+ } catch ( const std::invalid_argument& ) {
+ (void)0;
+ } catch ( const std::out_of_range& ) {
+ (void)0;
+ }
+ }
+ pos++;
+ }
+ return text;
+}
+
+std::string extract_attr( const std::string& tag_name, std::string_view attr_name )
+{
+ std::string match = " " + std::string( attr_name ) + "=";
+ std::size_t pos = tag_name.find( match );
+
+ if ( pos == std::string::npos ) {
+ match = std::string( attr_name ) + "=";
+ if ( !tag_name.starts_with( match ) ) {
+ return "";
+ }
+ pos = 0;
+ } else {
+ pos += 1;
+ }
+
+ pos += attr_name.length() + 1;
+ if ( pos >= tag_name.length() ) {
+ return "";
+ }
+
+ const char quote = tag_name[pos];
+ if ( quote == '"' || quote == '\'' ) {
+ const std::size_t end = tag_name.find( quote, pos + 1 );
+ if ( end != std::string::npos ) {
+ return tag_name.substr( pos + 1, end - pos - 1 );
+ }
+ return "";
+ }
+
+ const std::size_t end = tag_name.find_first_of( " >/", pos );
+ return tag_name.substr( pos, ( end == std::string::npos ) ? std::string::npos : end - pos );
+}
+
+std::size_t get_visible_length( std::string_view str )
+{
+ std::size_t len = 0;
+ bool in_ansi = false;
+ for ( std::size_t i = 0; i < str.length(); ++i ) {
+ const auto c = static_cast<unsigned char>( str[i] );
+ if ( c == 0x1B ) {
+ in_ansi = true;
+ } else if ( in_ansi && c == 'm' ) {
+ in_ansi = false;
+ } else if ( !in_ansi ) {
+ if ( ( c & 0x80 ) == 0 || ( c & 0xE0 ) == 0xC0 ) {
+ len += 1;
+ } else if ( ( c & 0xF0 ) == 0xE0 || ( c & 0xF8 ) == 0xF0 ) {
+ len += 2;
+ }
+ }
+ }
+ return len;
+}
+
+bool is_ignored_tag( std::string_view tag )
+{
+ return tag == "head" || tag == "style" || tag == "script" || tag == "meta" || tag == "title" || tag == "link";
+}
+
+bool is_heading_tag( std::string_view tag )
+{
+ return tag == "h1" || tag == "h2" || tag == "h3" || tag == "h4";
+}
+
+} // namespace ricochet::render
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