aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ricochet/.clang-format23
-rw-r--r--ricochet/.clang-tidy9
-rw-r--r--ricochet/.gitignore53
-rw-r--r--ricochet/CMakeLists.txt47
-rw-r--r--ricochet/COPYING674
-rw-r--r--ricochet/README.md163
-rw-r--r--ricochet/etc/build_type.cmake11
-rw-r--r--ricochet/etc/cflags.cmake13
-rw-r--r--ricochet/etc/cmake_uninstall.cmake.in21
-rw-r--r--ricochet/etc/scanners.cmake27
-rw-r--r--ricochet/etc/tests.cmake19
-rw-r--r--ricochet/include/.gitkeep0
-rw-r--r--ricochet/include/ricochet/core/browser.hh40
-rw-r--r--ricochet/include/ricochet/core/form_handler.hh27
-rw-r--r--ricochet/include/ricochet/core/license.hh29
-rw-r--r--ricochet/include/ricochet/core/local_data.hh32
-rw-r--r--ricochet/include/ricochet/core/page_loader.hh32
-rw-r--r--ricochet/include/ricochet/core/page_processor.hh32
-rw-r--r--ricochet/include/ricochet/core/types.hh50
-rw-r--r--ricochet/include/ricochet/core/utils.hh30
-rw-r--r--ricochet/include/ricochet/net/config.hh35
-rw-r--r--ricochet/include/ricochet/net/cookie_jar.hh35
-rw-r--r--ricochet/include/ricochet/net/http_client.hh63
-rw-r--r--ricochet/include/ricochet/net/http_utils.hh30
-rw-r--r--ricochet/include/ricochet/net/ssl_socket.hh93
-rw-r--r--ricochet/include/ricochet/net/sys/address.hh88
-rw-r--r--ricochet/include/ricochet/net/sys/exception.hh80
-rw-r--r--ricochet/include/ricochet/net/sys/file_descriptor.hh141
-rw-r--r--ricochet/include/ricochet/net/sys/socket.hh160
-rw-r--r--ricochet/include/ricochet/net/sys/string_view_range.hh18
-rw-r--r--ricochet/include/ricochet/net/url_parser.hh37
-rw-r--r--ricochet/include/ricochet/parser/dom_tree.hh36
-rw-r--r--ricochet/include/ricochet/parser/html_lexer.hh64
-rw-r--r--ricochet/include/ricochet/parser/tree_builder.hh45
-rw-r--r--ricochet/include/ricochet/render/input_renderer.hh34
-rw-r--r--ricochet/include/ricochet/render/internal.hh35
-rw-r--r--ricochet/include/ricochet/render/renderer.hh60
-rw-r--r--ricochet/include/ricochet/render/table_renderer.hh30
-rw-r--r--ricochet/include/ricochet/render/utils.hh33
-rw-r--r--ricochet/include/ricochet/tui/document.hh255
-rw-r--r--ricochet/include/ricochet/tui/identity.hh63
-rw-r--r--ricochet/include/ricochet/tui/input.hh119
-rw-r--r--ricochet/include/ricochet/tui/messages.hh268
-rw-r--r--ricochet/include/ricochet/tui/position.hh116
-rw-r--r--ricochet/include/ricochet/tui/session.hh110
-rw-r--r--ricochet/include/ricochet/tui/view.hh74
-rw-r--r--ricochet/scripts/add_license.py57
-rw-r--r--ricochet/scripts/make-parallel.sh2
-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
-rw-r--r--ricochet/tests/CMakeLists.txt7
91 files changed, 11256 insertions, 0 deletions
diff --git a/ricochet/.clang-format b/ricochet/.clang-format
new file mode 100644
index 0000000..c49e66b
--- /dev/null
+++ b/ricochet/.clang-format
@@ -0,0 +1,23 @@
+---
+Language: Cpp
+BasedOnStyle: Mozilla
+ColumnLimit: 116
+SpacesInParentheses: true
+AlwaysBreakAfterReturnType: None
+AlwaysBreakAfterDefinitionReturnType: None
+SpaceBeforeCpp11BracedList: true
+BreakBeforeBinaryOperators: All
+Cpp11BracedListStyle: true
+AllowShortBlocksOnASingleLine: Always
+BreakBeforeBraces: Custom
+BraceWrapping:
+ AfterClass: true
+ AfterControlStatement: Never
+ AfterFunction: true
+ AfterStruct: true
+ AfterEnum: true
+ SplitEmptyFunction: false
+ SplitEmptyRecord: false
+ SplitEmptyNamespace: false
+PackConstructorInitializers: NextLine
+...
diff --git a/ricochet/.clang-tidy b/ricochet/.clang-tidy
new file mode 100644
index 0000000..c1d3e9f
--- /dev/null
+++ b/ricochet/.clang-tidy
@@ -0,0 +1,9 @@
+Checks:
+ '*,-altera-*,-llvmlibc-*,-google-build-using-namespace,-modernize-use-trailing-return-type,-readability-implicit-bool-conversion,-*-non-private-member-variables-in-classes,-modernize-use-nodiscard,-*-magic-numbers,-readability-identifier-length,-concurrency-mt-unsafe,-llvm-header-guard,-fuchsia-default-arguments-calls,-fuchsia-default-arguments-declarations,-fuchsia-overloaded-operator,-android-cloexec-accept,-cppcoreguidelines-pro-type-vararg,-hicpp-vararg,-hicpp-signed-bitwise,-misc-include-cleaner,-readability-redundant-member-init,-boost-use-ranges,-portability-template-virtual-member-function'
+
+WarningsAsErrors:
+ '*'
+
+CheckOptions:
+ hicpp-special-member-functions.AllowSoleDefaultDtor: true
+ cppcoreguidelines-special-member-functions.AllowSoleDefaultDtor: true
diff --git a/ricochet/.gitignore b/ricochet/.gitignore
new file mode 100644
index 0000000..221a15f
--- /dev/null
+++ b/ricochet/.gitignore
@@ -0,0 +1,53 @@
+build/
+bin/
+lib/
+
+CMakeCache.txt
+CMakeFiles/
+CMakeScripts/
+cmake_install.cmake
+install_manifest.txt
+Makefile
+_CPack_Packages/
+CPackConfig.cmake
+CPackSourceConfig.cmake
+
+compile_commands.json
+.cache/
+
+*.o
+*.obj
+*.a
+*.lib
+*.so
+*.dylib
+*.dll
+*.d
+*.suo
+*.idb
+*.pdb
+
+Testing/
+*.gcov
+*.gcda
+*.gcno
+*.html
+
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+.idea/
+cmake-build-*/
+
+*.swp
+*.swo
+.netrwhist
+
+.DS_Store
+
+*~
+*.log
+*.tmp \ No newline at end of file
diff --git a/ricochet/CMakeLists.txt b/ricochet/CMakeLists.txt
new file mode 100644
index 0000000..c2150b8
--- /dev/null
+++ b/ricochet/CMakeLists.txt
@@ -0,0 +1,47 @@
+cmake_minimum_required(VERSION 3.24.2)
+
+project(
+ Ricochet
+ VERSION 0.1.0
+ DESCRIPTION "A minimalist CLI web browser for fast, distraction-free navigation."
+ LANGUAGES CXX
+)
+
+if(${PROJECT_SOURCE_DIR} STREQUAL ${CMAKE_BINARY_DIR})
+ message(FATAL_ERROR "Ricochet must be built outside its source directory, e.g. `cmake -B build`.")
+endif()
+
+set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
+set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
+set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
+
+foreach(OUTPUTCONFIG ${CMAKE_CONFIGURATION_TYPES})
+ string(TOUPPER ${OUTPUTCONFIG} OUTPUTCONFIG)
+ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${OUTPUTCONFIG} ${CMAKE_BINARY_DIR}/bin)
+ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_${OUTPUTCONFIG} ${CMAKE_BINARY_DIR}/lib)
+ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_${OUTPUTCONFIG} ${CMAKE_BINARY_DIR}/lib)
+endforeach()
+
+include(etc/build_type.cmake)
+include(etc/cflags.cmake)
+include(etc/scanners.cmake)
+include(etc/tests.cmake)
+
+add_subdirectory(src)
+add_subdirectory(tests)
+
+install(TARGETS ricochet
+ RUNTIME DESTINATION bin
+ LIBRARY DESTINATION lib
+ ARCHIVE DESTINATION lib
+)
+
+if(NOT TARGET uninstall)
+ configure_file(
+ "${PROJECT_SOURCE_DIR}/etc/cmake_uninstall.cmake.in"
+ "${PROJECT_BINARY_DIR}/cmake_uninstall.cmake"
+ IMMEDIATE @ONLY)
+
+ add_custom_target(uninstall
+ COMMAND ${CMAKE_COMMAND} -P "${PROJECT_BINARY_DIR}/cmake_uninstall.cmake")
+endif() \ No newline at end of file
diff --git a/ricochet/COPYING b/ricochet/COPYING
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/ricochet/COPYING
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ 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.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ 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/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/ricochet/README.md b/ricochet/README.md
new file mode 100644
index 0000000..a09bf4a
--- /dev/null
+++ b/ricochet/README.md
@@ -0,0 +1,163 @@
+# Ricochet
+> **A minimalist CLI web browser for fast, distraction-free navigation.**
+
+**Ricochet** is a terminal-based web browser designed for those who value speed, privacy, and the original spirit of the World Wide Web. By stripping away the "JavaScript Trap" and focus-stealing elements of modern web design, Ricochet delivers content in its purest form directly to your emulator.
+
+## Philosophy
+Modern browsing has become a minefield of non-free scripts, tracking pixels, and visual clutter. Ricochet ricochets off the noise:
+* **Freedom First:** No JavaScript support by design. We serve the user, not the scripts.
+* **Minimalist:** Focused entirely on text and structure.
+* **Distraction-Free:** No ads, no popups, no auto-playing media. Just the data you came for.
+
+## Instructions
+- Keyboard-Driven Navigation: Fully controllable via Vim-style keybindings (j/k for scrolling, H/L for history).
+
+- Link Hinting: Press f to highlight all links with two-letter codes. Jump instantly without using a mouse or typing long numbers.
+
+- Privacy First: Industrial-grade Cookie Domain Isolation ensures your session data is never leaked across sites.
+
+- Fast Search: Built-in "Find in Page" (n/N) with real-time ANSI highlighting.
+
+- Persistent Data: Bookmarks and Browsing History are automatically saved to ~/.ricochet/ for a seamless experience.
+
+- Highly Configurable: Customize your User-Agent and default Homepage via a simple config file.
+
+## Key Features
+- [x] **High-Performance C++ Core:** Native speed with minimal memory footprint.
+- [x] **Robust HTML5 Parser:** A custom, fault-tolerant DOM builder for the "wild" web.
+- [x] **TUI Navigation:** Intuitive, keyboard-driven interface (Vim-like bindings).
+- [x] **Privacy Centric:** No cookies by default, no telemetry, and zero JS execution.
+- [x] **Secure:** Native HTTPS support via modern SSL/TLS stacks.
+
+## Architecture
+Ricochet is built with a strictly modular approach:
+* **`Net`**: Handles TCP/TLS streams and HTTP/1.1 protocol state.
+* **`Parser`**: A state-machine driven HTML lexer and DOM tree builder.
+* **`Layout`**: Calculates box models and word-wrapping for terminal constraints.
+* **`TUI`**: An event-driven terminal UI handling viewport rendering and user input.
+
+## Usage
+```bash
+# Start with default homepage
+./build/bin/ricochet
+
+# Open a specific URL
+./build/bin/ricochet https://www.gnu.org
+
+# Use DuckDuckGo smart search
+./build/bin/ricochet "linux kernel news"
+```
+
+## Navigation
+| Key | Action |
+| :--- | :--- |
+| `j / k` | Scroll Down / Up |
+| `f` | Follow Link (Hint Mode) |
+| `n / N` | Find in Page / Next Match |
+| `H / L` | History Back / Forward |
+| `h` | View Browsing History |
+| `b / B` | Add Bookmark / View Bookmarks |
+| `/` | Open URL Prompt |
+| `i` | Form Input Mode |
+| `?` | Show Help Manual |
+| `q` | Quit |
+
+## Configuration
+Ricochet stores data in ~/.ricochet/. You can create config.txt to customize your experience:
+
+```plaintext
+homepage=https://v2ex.com
+user_agent=MyCustomBrowser/1.0
+```
+
+## Getting Started
+
+### Prerequisites
+To build Ricochet, ensure your environment meets the following requirements:
+* **Compiler**: A C++ compiler with full **C++23** support (e.g., **GCC 13+** or **Clang 15+**).
+* **Build System**: **CMake 3.24.2** or higher.
+* **Development Tools** (Optional but recommended):
+ * `clang-format` for automated code styling.
+ * `clang-tidy` for static analysis and linting.
+
+### Building from Source
+Ricochet enforces out-of-source builds to keep the project structure clean. By default, the project builds in Debug mode with Address and Undefined Behavior sanitizers enabled.
+
+#### 1. Clone the repository
+```bash
+git clone https://github.com/yourusername/ricochet.git
+cd ricochet
+```
+
+#### 2. Configure the project
+This will generate compile commands and set the build type to Release
+```bash
+cmake -B build -DCMAKE_BUILD_TYPE=Release
+```
+
+#### 3. Compile the project using all available cores
+```bash
+cmake --build build -j$(nproc)
+```
+
+### Install
+To install Ricochet to your system (defaulting to /usr/local/bin), run the following command with root privileges:
+
+```bash
+sudo cmake --install build
+```
+
+This will install the `ricochet` executable to the `bin` destination and required libraries to `lib`.
+
+### Build Parameters & Configuration
+
+You can customize the build by passing `-D<VAR>=<VALUE>` to the initial `cmake -B build` command.
+
+#### 1. Build Types (`CMAKE_BUILD_TYPE`)
+The project defaults to **Debug** mode if no type is specified.
+* **`Debug`**: Enables full sanitizers and debug symbols (Default).
+* **`Release`**: Optimizes for performance and reduces binary size.
+* **`RelWithDebInfo`**: Optimized build that retains debug symbols.
+
+#### 2. Security & Quality Flags
+These parameters are automatically managed by `etc/cflags.cmake`:
+* **Sanitizers:** Includes AddressSanitizer (ASan) and UndefinedBehaviorSanitizer (UBSan). Recovery is disabled for all errors (`-fno-sanitize-recover=all`).
+* **Warning Level:** Set to extremely strict levels, including `-Wall`, `-Wextra`, `-Wpedantic`, and `-Weffc++`.
+* **Warnings as Errors:** All compiler warnings are treated as errors (`-Werror`), ensuring no code is merged with unresolved issues.
+
+#### 3. Output Directories
+The build system organizes outputs into the following structure within your build folder:
+* **Executables:** `build/bin/`
+* **Static/Shared Libraries:** `build/lib/`
+* **Compile Commands:** `build/compile_commands.json` is generated for LSP (Language Server) support.
+
+---
+
+### Development & Maintenance Targets
+
+The project includes several custom targets for code maintenance:
+
+| Target | Command | Description |
+| :--- | :--- | :--- |
+| **Format** | `cmake --build build --target format` | Uses `clang-format` to enforce consistent coding style across all `.cc` and `.hh` files. |
+| **Tidy** | `cmake --build build --target tidy` | Runs `clang-tidy` on the core Ricochet source files for static analysis. |
+| **Tidy-All** | `cmake --build build --target tidy-all` | Runs `clang-tidy` on every file in the project, including tests. |
+| **Test** | `ctest --test-dir build` | Executes the test suite via CTest. |
+
+### Installation
+To install the compiled binaries to your system, use:
+```bash
+cmake --install build --prefix /usr/local
+```
+This installs the `ricochet` binary to `bin` and supporting libraries to `lib`.
+
+
+
+## Acknowledgments
+The low-level POSIX socket wrappers in `src/net/sys/` are adapted from the Minnow networking framework developed for Stanford University's CS144 course. These utilities are used gracefully to handle file descriptors and network interfaces.
+
+## License
+Ricochet is free software: you can redistribute it and/or modify it under the terms of the **GNU General Public License (GPL) v3**.
+
+---
+*Built for the terminal. Built for freedom.* \ No newline at end of file
diff --git a/ricochet/etc/build_type.cmake b/ricochet/etc/build_type.cmake
new file mode 100644
index 0000000..69d019f
--- /dev/null
+++ b/ricochet/etc/build_type.cmake
@@ -0,0 +1,11 @@
+set(default_build_type "Debug")
+
+if (NOT (CMAKE_BUILD_TYPE_SHADOW STREQUAL CMAKE_BUILD_TYPE))
+ if (NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ message (STATUS "Setting build type to '${default_build_type}'")
+ set (CMAKE_BUILD_TYPE "${default_build_type}" CACHE STRING "Choose the type of build." FORCE)
+ endif ()
+ set (CMAKE_BUILD_TYPE_SHADOW ${CMAKE_BUILD_TYPE} CACHE STRING "used to detect changes in build type" FORCE)
+endif ()
+
+message (STATUS "Building in '${CMAKE_BUILD_TYPE}' mode.")
diff --git a/ricochet/etc/cflags.cmake b/ricochet/etc/cflags.cmake
new file mode 100644
index 0000000..cf60dd7
--- /dev/null
+++ b/ricochet/etc/cflags.cmake
@@ -0,0 +1,13 @@
+set (CMAKE_CXX_STANDARD 23)
+set (CMAKE_EXPORT_COMPILE_COMMANDS ON)
+set (CMAKE_CXX_STANDARD_REQUIRED ON)
+set (CMAKE_CXX_EXTENSIONS OFF)
+
+set(SANITIZING_FLAGS "-fno-sanitize-recover=all -fsanitize=undefined -fsanitize=address")
+
+# ask for more warnings from the compiler
+set (CMAKE_BASE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
+set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wpedantic -Wextra -Weffc++ -Werror -Wshadow -Wpointer-arith -Wcast-qual -Wformat=2 -Wno-unqualified-std-cast-call -Wno-non-virtual-dtor")
+
+set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${SANITIZING_FLAGS}")
+set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3 -march=native") \ No newline at end of file
diff --git a/ricochet/etc/cmake_uninstall.cmake.in b/ricochet/etc/cmake_uninstall.cmake.in
new file mode 100644
index 0000000..33ae281
--- /dev/null
+++ b/ricochet/etc/cmake_uninstall.cmake.in
@@ -0,0 +1,21 @@
+if(NOT EXISTS "@CMAKE_BINARY_DIR@/install_manifest.txt")
+ message(FATAL_ERROR "Cannot find install manifest: @CMAKE_BINARY_DIR@/install_manifest.txt")
+endif()
+
+file(STRINGS "@CMAKE_BINARY_DIR@/install_manifest.txt" files)
+
+foreach(file ${files})
+ message(STATUS "Uninstalling $ENV{DESTDIR}${file}")
+ if(IS_SYMLINK "$ENV{DESTDIR}${file}" OR EXISTS "$ENV{DESTDIR}${file}")
+ execute_process(
+ COMMAND "@CMAKE_COMMAND@" "-E" "rm" "-f" "$ENV{DESTDIR}${file}"
+ OUTPUT_VARIABLE rm_out
+ RESULT_VARIABLE rm_retval
+ )
+ if(NOT "${rm_retval}" STREQUAL 0)
+ message(FATAL_ERROR "Problem uninstalling $ENV{DESTDIR}${file}")
+ endif()
+ else()
+ message(STATUS "File $ENV{DESTDIR}${file} does not exist.")
+ endif()
+endforeach() \ No newline at end of file
diff --git a/ricochet/etc/scanners.cmake b/ricochet/etc/scanners.cmake
new file mode 100644
index 0000000..2dd9da0
--- /dev/null
+++ b/ricochet/etc/scanners.cmake
@@ -0,0 +1,27 @@
+file (GLOB_RECURSE RICOCHET_CC_FILES ${CMAKE_SOURCE_DIR}/src/*.cc)
+
+file(GLOB_RECURSE ALL_SRC_FILES
+ ${CMAKE_SOURCE_DIR}/src/*.cc
+ ${CMAKE_SOURCE_DIR}/src/*.hh
+ ${CMAKE_SOURCE_DIR}/include/*.hh
+)
+
+add_custom_target (format "clang-format" -i ${ALL_SRC_FILES} COMMENT "Formatting source code...")
+
+foreach (tidy_target ${ALL_SRC_FILES})
+ get_filename_component (basename ${tidy_target} NAME)
+ get_filename_component (dirname ${tidy_target} DIRECTORY)
+ get_filename_component (basedir ${dirname} NAME)
+ set (tidy_target_name "${basedir}__${basename}")
+ set (tidy_command clang-tidy --quiet -header-filter=.* -p=${PROJECT_BINARY_DIR} ${tidy_target})
+ add_custom_target (tidy_${tidy_target_name} ${tidy_command})
+ list (APPEND ALL_TIDY_TARGETS tidy_${tidy_target_name})
+
+ if (${tidy_target} IN_LIST RICOCHET_CC_FILES)
+ list (APPEND RICOCHET_TIDY_TARGETS tidy_${tidy_target_name})
+ endif ()
+endforeach (tidy_target)
+
+add_custom_target (tidy DEPENDS ${RICOCHET_TIDY_TARGETS})
+
+add_custom_target (tidy-all DEPENDS ${ALL_TIDY_TARGETS})
diff --git a/ricochet/etc/tests.cmake b/ricochet/etc/tests.cmake
new file mode 100644
index 0000000..f32724e
--- /dev/null
+++ b/ricochet/etc/tests.cmake
@@ -0,0 +1,19 @@
+include(CTest)
+
+# list(APPEND CMAKE_CTEST_ARGUMENTS --output-on-failure --stop-on-failure --timeout 15 -E 'speed_test|optimization|webget')
+
+# set(compile_name "compile with bug-checkers")
+# add_test(NAME ${compile_name}
+# COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" -t functionality_testing webget)
+
+# macro (ttest name)
+# add_test(NAME ${name} COMMAND "${name}_sanitized")
+# set_property(TEST ${name} PROPERTY FIXTURES_REQUIRED compile)
+# endmacro (ttest)
+
+# set_property(TEST ${compile_name} PROPERTY TIMEOUT 0)
+# set_tests_properties(${compile_name} PROPERTIES FIXTURES_SETUP compile)
+
+# add_test(NAME t_webget COMMAND "${PROJECT_SOURCE_DIR}/tests/webget_t.sh" "${PROJECT_BINARY_DIR}")
+# set_property(TEST t_webget PROPERTY FIXTURES_REQUIRED compile)
+
diff --git a/ricochet/include/.gitkeep b/ricochet/include/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ricochet/include/.gitkeep
diff --git a/ricochet/include/ricochet/core/browser.hh b/ricochet/include/ricochet/core/browser.hh
new file mode 100644
index 0000000..fabcba4
--- /dev/null
+++ b/ricochet/include/ricochet/core/browser.hh
@@ -0,0 +1,40 @@
+/*
+ * 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 <string>
+
+namespace ricochet::core {
+
+class Browser
+{
+public:
+ Browser() = default;
+ ~Browser() = default;
+
+ Browser( const Browser& ) = delete;
+ Browser& operator=( const Browser& ) = delete;
+ Browser( Browser&& ) = delete;
+ Browser& operator=( Browser&& ) = delete;
+
+ static std::string format_url( std::string input );
+ static std::string get_configured_homepage();
+};
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/core/form_handler.hh b/ricochet/include/ricochet/core/form_handler.hh
new file mode 100644
index 0000000..d95bd02
--- /dev/null
+++ b/ricochet/include/ricochet/core/form_handler.hh
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include "ricochet/core/types.hh"
+#include "ricochet/render/renderer.hh"
+#include <cstddef>
+#include <string>
+#include <vector>
+
+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 );
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/core/license.hh b/ricochet/include/ricochet/core/license.hh
new file mode 100644
index 0000000..32c04c6
--- /dev/null
+++ b/ricochet/include/ricochet/core/license.hh
@@ -0,0 +1,29 @@
+/*
+ * 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
+
+namespace ricochet::core {
+
+void print_license_notice();
+
+void show_warranty();
+
+void show_copying();
+
+} // namespace ricochet::core \ No newline at end of file
diff --git a/ricochet/include/ricochet/core/local_data.hh b/ricochet/include/ricochet/core/local_data.hh
new file mode 100644
index 0000000..ca07846
--- /dev/null
+++ b/ricochet/include/ricochet/core/local_data.hh
@@ -0,0 +1,32 @@
+/*
+ * 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 <string>
+
+namespace ricochet::core {
+
+std::string get_config_path( const std::string& filename );
+void save_bookmark( const std::string& title, const std::string& url );
+std::string generate_bookmarks_html();
+void append_to_history( const std::string& url, const std::string& title );
+std::string generate_history_html();
+std::string generate_help_html();
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/core/page_loader.hh b/ricochet/include/ricochet/core/page_loader.hh
new file mode 100644
index 0000000..11131e2
--- /dev/null
+++ b/ricochet/include/ricochet/core/page_loader.hh
@@ -0,0 +1,32 @@
+/*
+ * 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/core/types.hh"
+// #include "ricochet/net/http_client.hh"
+// #include "ricochet/parser/dom_tree.hh"
+// #include <string>
+
+namespace ricochet::core {
+
+// std::string extract_title( const parser::DomNode& root_node );
+// PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp );
+PageData load_page( const HttpRequest& req );
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/core/page_processor.hh b/ricochet/include/ricochet/core/page_processor.hh
new file mode 100644
index 0000000..8f40d3a
--- /dev/null
+++ b/ricochet/include/ricochet/core/page_processor.hh
@@ -0,0 +1,32 @@
+/*
+ * 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/core/types.hh"
+#include "ricochet/net/http_client.hh"
+#include "ricochet/parser/dom_tree.hh"
+#include <string>
+
+namespace ricochet::core {
+
+std::string extract_title( const parser::DomNode& root_node );
+
+PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp );
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/core/types.hh b/ricochet/include/ricochet/core/types.hh
new file mode 100644
index 0000000..ef48016
--- /dev/null
+++ b/ricochet/include/ricochet/core/types.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/render/renderer.hh"
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace ricochet::core {
+
+struct PageData
+{
+ std::vector<std::string> lines;
+ std::vector<std::string> links;
+ std::vector<render::InputField> inputs;
+ std::string title;
+};
+
+struct HttpRequest
+{
+ std::string url {};
+ std::string method = "GET";
+ std::string body {};
+};
+
+enum class InputAction : std::uint8_t
+{
+ None,
+ Quit,
+ Navigate
+};
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/core/utils.hh b/ricochet/include/ricochet/core/utils.hh
new file mode 100644
index 0000000..61d480b
--- /dev/null
+++ b/ricochet/include/ricochet/core/utils.hh
@@ -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/>.
+ */
+
+#pragma once
+
+#include <string>
+#include <vector>
+
+namespace ricochet::core {
+
+std::vector<std::string> split_into_lines( const std::string& text );
+std::string normalize_url( const std::string& target, const std::string& current_url );
+std::string strip_ddg_tracker( std::string target );
+
+} // namespace ricochet::core
diff --git a/ricochet/include/ricochet/net/config.hh b/ricochet/include/ricochet/net/config.hh
new file mode 100644
index 0000000..fa73193
--- /dev/null
+++ b/ricochet/include/ricochet/net/config.hh
@@ -0,0 +1,35 @@
+/*
+ * 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 <string>
+
+namespace ricochet::net {
+
+std::string get_config_path( const std::string& filename );
+
+struct BrowserConfig
+{
+ std::string user_agent;
+ std::string homepage;
+};
+
+BrowserConfig& get_browser_config();
+
+} // namespace ricochet::net
diff --git a/ricochet/include/ricochet/net/cookie_jar.hh b/ricochet/include/ricochet/net/cookie_jar.hh
new file mode 100644
index 0000000..5c5befb
--- /dev/null
+++ b/ricochet/include/ricochet/net/cookie_jar.hh
@@ -0,0 +1,35 @@
+/*
+ * 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 <string>
+#include <unordered_map>
+
+namespace ricochet::net {
+
+using CookieJar = std::unordered_map<std::string, std::unordered_map<std::string, std::string>>;
+
+void load_cookies_from_file( CookieJar& jar );
+CookieJar& get_cookie_jar();
+void save_cookies_to_file( const CookieJar& jar );
+
+void update_cookies( const std::string& raw_response, const std::string& host );
+std::string build_cookie_header( const std::string& host );
+
+} // namespace ricochet::net
diff --git a/ricochet/include/ricochet/net/http_client.hh b/ricochet/include/ricochet/net/http_client.hh
new file mode 100644
index 0000000..f57cbcf
--- /dev/null
+++ b/ricochet/include/ricochet/net/http_client.hh
@@ -0,0 +1,63 @@
+/*
+ * 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 <expected>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+namespace ricochet::net {
+/**
+ * @brief Encapsulate HTTP response result
+ */
+
+struct HttpResponse
+{
+ int status_code { 0 };
+ std::unordered_map<std::string, std::string> headers {};
+ std::string body {};
+ std::string content_type {};
+};
+
+class HttpClient
+{
+public:
+ HttpClient() = default;
+ ~HttpClient() = default;
+
+ HttpClient( const HttpClient& ) = delete;
+ HttpClient& operator=( const HttpClient& ) = delete;
+ HttpClient( HttpClient&& ) = delete;
+ HttpClient& operator=( HttpClient&& ) = delete;
+
+ /**
+ * @brief Initiates an HTTP GET request (only supports http:// in MVP stage)
+ * @param url Target URL, for example "http://example.com"
+ * @return Returns HttpResponse on success, returns an error message string on failure
+ */
+ std::expected<HttpResponse, std::string> fetch( std::string_view url,
+ std::string_view method
+ = "GET", // NOLINT(bugprone-easily-swappable-parameters)
+ std::string_view body = "" ) const;
+
+private:
+};
+
+} // namespace ricochet::net \ No newline at end of file
diff --git a/ricochet/include/ricochet/net/http_utils.hh b/ricochet/include/ricochet/net/http_utils.hh
new file mode 100644
index 0000000..3d96e79
--- /dev/null
+++ b/ricochet/include/ricochet/net/http_utils.hh
@@ -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/>.
+ */
+
+#pragma once
+
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+std::string dechunk( std::string_view body );
+std::string remove_html_comments( std::string html );
+std::string remove_tag_blocks( std::string html, const std::string& tag_name );
+
+} // namespace ricochet::net
diff --git a/ricochet/include/ricochet/net/ssl_socket.hh b/ricochet/include/ricochet/net/ssl_socket.hh
new file mode 100644
index 0000000..36a92b6
--- /dev/null
+++ b/ricochet/include/ricochet/net/ssl_socket.hh
@@ -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/>.
+ */
+
+#pragma once
+
+#include "sys/address.hh"
+#include "sys/socket.hh"
+#include <memory>
+#include <string>
+#include <string_view>
+
+extern "C" {
+using SSL_CTX = struct ssl_ctx_st;
+using SSL = struct ssl_st;
+}
+
+namespace ricochet::net {
+
+struct SslCtxDeleter
+{
+ void operator()( SSL_CTX* ptr ) const;
+};
+struct SslDeleter
+{
+ void operator()( SSL* ptr ) const;
+};
+
+using UniqueSslCtx = std::unique_ptr<SSL_CTX, SslCtxDeleter>;
+using UniqueSsl = std::unique_ptr<SSL, SslDeleter>;
+
+/**
+ * @brief Socket wrapper that supports TLS/SSL encrypted handshake
+ */
+class SSLSocket
+{
+public:
+ SSLSocket();
+ ~SSLSocket() = default;
+
+ SSLSocket( const SSLSocket& ) = delete;
+ SSLSocket& operator=( const SSLSocket& ) = delete;
+ SSLSocket( SSLSocket&& ) = delete;
+ SSLSocket& operator=( SSLSocket&& ) = delete;
+
+ /**
+ * @brief Connect to a remote host using TLS/SSL
+ * @param host The hostname to connect to
+ * @param port The port to connect to
+ * @return True if the connection was successful, false otherwise
+ */
+ void connect( const Address& address, const std::string& hostname );
+
+ /**
+ * @brief Send data over the TLS/SSL connection
+ * @param data The data to send
+ * @return The number of bytes sent, or -1 if an error occurred
+ */
+ std::size_t write( std::string_view data );
+
+ /**
+ * @brief Receive data from the TLS/SSL connection
+ * @param buffer The buffer to store the received data
+ */
+ void read( std::string& buffer );
+
+ /**
+ * @brief Check if the TLS/SSL connection is closed
+ * @return True if the connection is closed, false otherwise
+ */
+ bool eof() const;
+
+private:
+ TCPSocket underlying_socket_ {}; // TCP Socket
+ UniqueSslCtx ctx_ { nullptr }; // OpenSSL global context
+ UniqueSsl ssl_ { nullptr }; // Current connection's SSL state
+ bool eof_ { false }; // EOF flag
+};
+} // namespace ricochet::net \ No newline at end of file
diff --git a/ricochet/include/ricochet/net/sys/address.hh b/ricochet/include/ricochet/net/sys/address.hh
new file mode 100644
index 0000000..39c9553
--- /dev/null
+++ b/ricochet/include/ricochet/net/sys/address.hh
@@ -0,0 +1,88 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @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.
+ * ---------------------------------------------------------------------------
+ */
+
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <netdb.h>
+#include <string>
+#include <sys/socket.h>
+#include <utility>
+
+//! Wrapper around [IPv4 addresses](@ref man7::ip) and DNS operations.
+class Address
+{
+public:
+ //! \brief Wrapper around [sockaddr_storage](@ref man7::socket).
+ //! \details A `sockaddr_storage` is enough space to store any socket address (IPv4 or IPv6).
+ class Raw
+ {
+ public:
+ sockaddr_storage storage {}; //!< The wrapped struct itself.
+ // NOLINTBEGIN (*-explicit-*)
+ operator sockaddr*();
+ operator const sockaddr*() const;
+ // NOLINTEND (*-explicit-*)
+ };
+
+private:
+ socklen_t size_; //!< Size of the wrapped address.
+ Raw address_ {}; //!< A wrapped [sockaddr_storage](@ref man7::socket) containing the address.
+
+ //! Constructor from ip/host, service/port, and hints to the resolver.
+ Address( const std::string& node, const std::string& service, const addrinfo& hints );
+
+public:
+ //! Construct by resolving a hostname and servicename.
+ Address( const std::string& hostname, const std::string& service );
+
+ //! Construct from dotted-quad string ("18.243.0.1") and numeric port.
+ explicit Address( const std::string& ip, std::uint16_t port = 0 );
+
+ //! Construct from a [sockaddr *](@ref man7::socket).
+ Address( const sockaddr* addr, std::size_t size );
+
+ //! Default constructor
+ Address() : Address( nullptr, 0 ) {}
+
+ //! Equality comparison.
+ bool operator==( const Address& other ) const;
+ bool operator!=( const Address& other ) const { return not operator==( other ); }
+
+ //! \name Conversions
+ //!@{
+
+ //! Dotted-quad IP address string ("18.243.0.1") and numeric port.
+ std::pair<std::string, uint16_t> ip_port() const;
+ //! Dotted-quad IP address string ("18.243.0.1").
+ std::string ip() const { return ip_port().first; }
+ //! Numeric port (host byte order).
+ uint16_t port() const { return ip_port().second; }
+ //! Numeric IP address as an integer (i.e., in [host byte order](\ref man3::byteorder)).
+ uint32_t ipv4_numeric() const;
+ //! Create an Address from a 32-bit raw numeric IP address
+ static Address from_ipv4_numeric( uint32_t ip_address );
+ //! Human-readable string, e.g., "8.8.8.8:53".
+ std::string to_string() const;
+ //!@}
+
+ //! \name Low-level operations
+ //!@{
+
+ //! Size of the underlying address storage.
+ socklen_t size() const { return size_; }
+ //! Const pointer to the underlying socket address storage.
+ const sockaddr* raw() const { return static_cast<const sockaddr*>( address_ ); }
+ //! Safely convert to underlying sockaddr type
+ template<typename sockaddr_type>
+ const sockaddr_type* as() const;
+
+ //!@}
+};
diff --git a/ricochet/include/ricochet/net/sys/exception.hh b/ricochet/include/ricochet/net/sys/exception.hh
new file mode 100644
index 0000000..9587e5c
--- /dev/null
+++ b/ricochet/include/ricochet/net/sys/exception.hh
@@ -0,0 +1,80 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @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.
+ * ---------------------------------------------------------------------------
+ */
+
+#pragma once
+
+#include <cxxabi.h>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <system_error>
+
+class tagged_error : public std::system_error
+{
+private:
+ std::string attempt_and_error_;
+ int error_code_;
+
+public:
+ tagged_error( const std::error_category& category, const std::string_view s_attempt, const int error_code )
+ : system_error( error_code, category )
+ , attempt_and_error_( std::string( s_attempt ) + ": " + std::system_error::what() )
+ , error_code_( error_code )
+ {}
+
+ const char* what() const noexcept override { return attempt_and_error_.c_str(); }
+
+ int error_code() const { return error_code_; }
+};
+
+class unix_error : public tagged_error
+{
+public:
+ explicit unix_error( const std::string_view s_attempt, const int s_errno = errno )
+ : tagged_error( std::system_category(), s_attempt, s_errno )
+ {}
+};
+
+inline int CheckSystemCall( const std::string_view s_attempt, const int return_value )
+{
+ if ( return_value >= 0 ) {
+ return return_value;
+ }
+
+ throw unix_error { s_attempt };
+}
+
+template<typename T>
+inline T* notnull( const std::string_view context, T* const x )
+{
+ return x ? x : throw std::runtime_error( std::string( context ) + ": returned null pointer" );
+}
+
+template<typename T>
+inline std::unique_ptr<T> notnull( const std::string_view context, std::unique_ptr<T> x )
+{
+ return x ? x : throw std::runtime_error( std::string( context ) + ": returned null pointer" );
+}
+
+template<typename T>
+inline std::shared_ptr<T> notnull( const std::string_view context, std::shared_ptr<T>&& x )
+{
+ return x ? std::move( x ) : throw std::runtime_error( std::string( context ) + ": returned null pointer" );
+}
+
+inline std::string demangle( const char* name )
+{
+ int status {};
+ const std::unique_ptr<char, decltype( &free )> res { abi::__cxa_demangle( name, nullptr, nullptr, &status ),
+ free };
+ if ( status ) {
+ throw std::runtime_error( "cxa_demangle" );
+ }
+ return res.get();
+}
diff --git a/ricochet/include/ricochet/net/sys/file_descriptor.hh b/ricochet/include/ricochet/net/sys/file_descriptor.hh
new file mode 100644
index 0000000..4af56cb
--- /dev/null
+++ b/ricochet/include/ricochet/net/sys/file_descriptor.hh
@@ -0,0 +1,141 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @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.
+ * ---------------------------------------------------------------------------
+ */
+
+#pragma once
+
+#include "string_view_range.hh"
+
+#include <bits/types/struct_iovec.h>
+#include <cstddef>
+#include <memory>
+#include <vector>
+
+// A reference-counted handle to a file descriptor
+class FileDescriptor
+{
+public:
+ // Construct from a file descriptor number returned by the kernel
+ explicit FileDescriptor( int fd );
+
+ // Read from the file descriptor into `buffer` (a single string) or `buffers` (a vector of strings)
+ void read( std::string& buffer );
+ void read( std::vector<std::string>& buffers );
+
+ // `write_all` writes a buffer completely.
+ void write_all( std::string_view buffer );
+
+ // `write` writes *from* a buffer or range of buffers and returns the number of bytes it actually wrote.
+ size_t write( std::string_view buffer );
+ size_t write( const StringViewRange auto&& buffers )
+ {
+ static thread_local std::vector<iovec> iovecs;
+ const size_t total_size = to_iovecs( buffers, iovecs );
+ return write( iovecs, total_size );
+ }
+
+ // Close the underlying file descriptor
+ void close() { internal_fd_->close(); }
+
+ // Set blocking(true) or non-blocking(false) status on the file descriptor
+ void set_blocking( bool blocking );
+
+ // Copy a FileDescriptor explicitly, increasing the internal reference count
+ FileDescriptor duplicate() const;
+
+ // The destructor calls close() when no more references remain.
+ ~FileDescriptor() = default;
+
+ // Accessors
+ int fd_num() const { return internal_fd_->fd_; } // underlying descriptor number
+ bool eof() const { return internal_fd_->eof_; } // EOF flag state
+ bool closed() const { return internal_fd_->closed_; } // closed flag state
+ bool blocking() const { return not internal_fd_->non_blocking_; } // blocking state
+ unsigned int read_count() const { return internal_fd_->read_count_; } // number of reads
+ unsigned int write_count() const { return internal_fd_->write_count_; } // number of writes
+
+ // Copy/move constructor/assignment operators
+ // FileDescriptor can be moved, but cannot be copied implicitly (see duplicate())
+ FileDescriptor( const FileDescriptor& other ) = delete; // copy construction is forbidden
+ FileDescriptor& operator=( const FileDescriptor& other ) = delete; // copy assignment is forbidden
+ FileDescriptor( FileDescriptor&& other ) = default; // move construction is allowed
+ FileDescriptor& operator=( FileDescriptor&& other ) = default; // move assignment is allowed
+
+private:
+ // FDWrapper: A handle on a kernel file descriptor.
+ // FileDescriptor objects contain a std::shared_ptr to a FDWrapper.
+ class FDWrapper
+ {
+ public:
+ int fd_; // The file descriptor number returned by the kernel
+ bool eof_ = false; // Flag indicating whether FDWrapper::fd_ is at EOF
+ bool closed_ = false; // Flag indicating whether FDWrapper::fd_ has been closed
+ bool non_blocking_ = false; // Flag indicating whether FDWrapper::fd_ is non-blocking
+ unsigned read_count_ = 0; // The number of times FDWrapper::fd_ has been read
+ unsigned write_count_ = 0; // The numberof times FDWrapper::fd_ has been written
+
+ // Construct from a file descriptor number returned by the kernel
+ explicit FDWrapper( int fd );
+ // Closes the file descriptor upon destruction
+ ~FDWrapper();
+ // Calls [close(2)](\ref man2::close) on FDWrapper::fd_
+ void close();
+
+ size_t CheckFDSystemCall( std::string_view what, ssize_t return_value ) const;
+ size_t CheckRead( std::string_view what, ssize_t return_value );
+
+ // An FDWrapper cannot be copied or moved
+ FDWrapper( const FDWrapper& other ) = delete;
+ FDWrapper& operator=( const FDWrapper& other ) = delete;
+ FDWrapper( FDWrapper&& other ) = delete;
+ FDWrapper& operator=( FDWrapper&& other ) = delete;
+ };
+
+ // A reference-counted handle to a shared FDWrapper
+ std::shared_ptr<FDWrapper> internal_fd_;
+
+ // Private constructor used to duplicate the FileDescriptor (increase the reference count)
+ explicit FileDescriptor( std::shared_ptr<FDWrapper> other_shared_ptr );
+
+ // Private write method that takes a vector of iovecs (an internal OS structure)
+ size_t write( const std::vector<iovec>& iovecs, size_t total_size );
+
+protected:
+ // size of buffer to allocate by read(), recv(), etc., when passed-in buffer is empty
+ static constexpr size_t kReadBufferSize = 16384;
+
+ void set_eof() { internal_fd_->eof_ = true; }
+ void register_read() { ++internal_fd_->read_count_; } // increment read count
+ void register_write() { ++internal_fd_->write_count_; } // increment write count
+
+ size_t CheckFDSystemCall( std::string_view what, ssize_t return_value ) const;
+ size_t CheckRead( std::string_view what, ssize_t return_value );
+
+ // Convert a range of string_view-convertible objects to a vector of iovecs
+ static size_t to_iovecs( const StringViewRange auto& buffers, std::vector<iovec>& iovecs )
+ {
+ if ( buffers.empty() ) {
+ throw std::runtime_error( "to_iovecs called with empty buffer list" );
+ }
+ iovecs.clear();
+ iovecs.reserve( buffers.size() );
+ size_t total_size = 0;
+ for ( const auto& buf : buffers ) {
+ const std::string_view x { buf };
+ if ( x.empty() ) {
+ throw std::runtime_error( "to_iovecs called with empty buffer in buffer list" );
+ }
+ iovecs.push_back( { const_cast<char*>( x.data() ), x.size() } ); // NOLINT(*-const-cast)
+ total_size += x.size();
+ }
+ if ( total_size == 0 ) {
+ throw std::runtime_error( "to_iovecs called with zero-size buffer list" );
+ }
+ return total_size;
+ }
+};
diff --git a/ricochet/include/ricochet/net/sys/socket.hh b/ricochet/include/ricochet/net/sys/socket.hh
new file mode 100644
index 0000000..f84be26
--- /dev/null
+++ b/ricochet/include/ricochet/net/sys/socket.hh
@@ -0,0 +1,160 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @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.
+ * ---------------------------------------------------------------------------
+ */
+
+#pragma once
+
+#include "address.hh"
+#include "file_descriptor.hh"
+
+#include <functional>
+#include <sys/socket.h>
+
+//! \brief Base class for network sockets (TCP, UDP, etc.)
+//! \details Socket is generally used via a subclass. See TCPSocket and UDPSocket for usage examples.
+class Socket : public FileDescriptor
+{
+public:
+ //! Bind a socket to a specified address with [bind(2)](\ref man2::bind), usually for listen/accept
+ void bind( const Address& address );
+
+ //! Bind a socket to a specified device
+ void bind_to_device( std::string_view device_name );
+
+ //! Connect a socket to a specified peer address with [connect(2)](\ref man2::connect)
+ void connect( const Address& address );
+
+ //! Shut down a socket via [shutdown(2)](\ref man2::shutdown)
+ void shutdown( int how );
+
+ //! Get local address of socket with [getsockname(2)](\ref man2::getsockname)
+ Address local_address() const;
+ //! Get peer address of socket with [getpeername(2)](\ref man2::getpeername)
+ Address peer_address() const;
+
+ //! Allow local address to be reused sooner via [SO_REUSEADDR](\ref man7::socket)
+ void set_reuseaddr();
+
+ //! Check for errors (will be seen on non-blocking sockets)
+ void throw_if_error() const;
+
+private:
+ //! Get the local or peer address the socket is connected to
+ Address get_address( const std::string& name_of_function,
+ const std::function<int( int, sockaddr*, socklen_t* )>& function ) const;
+
+protected:
+ //! Construct via [socket(2)](\ref man2::socket)
+ Socket( int domain, int type, int protocol = 0 );
+
+ //! Construct from a file descriptor.
+ Socket( FileDescriptor&& fd, int domain, int type, int protocol = 0 );
+
+ //! Wrapper around [getsockopt(2)](\ref man2::getsockopt)
+ template<typename option_type>
+ socklen_t getsockopt( int level, int option, option_type& option_value ) const;
+
+ //! Wrappers around [setsockopt(2)](\ref man2::setsockopt)
+ template<typename option_type>
+ void setsockopt( int level, int option, const option_type& option_value );
+
+ void setsockopt( int level, int option, std::string_view option_val );
+};
+
+class DatagramSocket : public Socket
+{
+public:
+ // Receive a datagram and the Address of its sender, into a buffer or collection of buffers.
+ void recv( Address& source_address, std::string& payload );
+ void recv( Address& source_address, std::vector<std::string>& payloads );
+
+ //! Send a datagram to specified Address (if destination is missing, sends to already-connected address)
+ void send( std::string_view payload, const std::optional<Address>& destination = {} );
+ void send( const StringViewRange auto&& payloads, const std::optional<Address>& destination = {} )
+ {
+ static thread_local std::vector<iovec> iovecs;
+ const size_t total_size = to_iovecs( payloads, iovecs );
+ send( iovecs, total_size, destination );
+ }
+
+private:
+ void send( std::vector<iovec>& iovecs, size_t total_size, const std::optional<Address>& destination = {} );
+
+protected:
+ DatagramSocket( int domain, int type, int protocol = 0 ) : Socket( domain, type, protocol ) {}
+
+ //! Construct from a file descriptor.
+ DatagramSocket( FileDescriptor&& fd, int domain, int type, int protocol = 0 )
+ : Socket( std::move( fd ), domain, type, protocol )
+ {}
+};
+
+//! A wrapper around [UDP sockets](\ref man7::udp)
+class UDPSocket : public DatagramSocket
+{
+ //! \param[in] fd is the FileDescriptor from which to construct
+ explicit UDPSocket( FileDescriptor&& fd ) : DatagramSocket( std::move( fd ), AF_INET, SOCK_DGRAM ) {}
+
+public:
+ //! Default: construct an unbound, unconnected UDP socket
+ UDPSocket() : DatagramSocket( AF_INET, SOCK_DGRAM ) {}
+};
+
+//! A wrapper around [TCP sockets](\ref man7::tcp)
+class TCPSocket : public Socket
+{
+private:
+ //! \brief Construct from FileDescriptor (used by accept())
+ //! \param[in] fd is the FileDescriptor from which to construct
+ explicit TCPSocket( FileDescriptor&& fd ) : Socket( std::move( fd ), AF_INET, SOCK_STREAM, IPPROTO_TCP ) {}
+
+public:
+ //! Default: construct an unbound, unconnected TCP socket
+ TCPSocket() : Socket( AF_INET, SOCK_STREAM ) {}
+
+ //! Mark a socket as listening for incoming connections
+ void listen( int backlog = 16 );
+
+ //! Accept a new incoming connection
+ TCPSocket accept();
+};
+
+//! A wrapper around [packet sockets](\ref man7:packet)
+class PacketSocket : public DatagramSocket
+{
+public:
+ PacketSocket( const int type, const int protocol ) : DatagramSocket( AF_PACKET, type, protocol ) {}
+
+ void set_promiscuous();
+};
+
+//! A wrapper around [raw sockets](\ref man7:raw)
+class RawSocket : public DatagramSocket
+{
+public:
+ RawSocket() : DatagramSocket( AF_INET, SOCK_RAW, IPPROTO_RAW ) {}
+};
+
+//! A wrapper around [Unix-domain stream sockets](\ref man7::unix)
+class LocalStreamSocket : public Socket
+{
+public:
+ //! Construct from a file descriptor
+ explicit LocalStreamSocket( FileDescriptor&& fd ) : Socket( std::move( fd ), AF_UNIX, SOCK_STREAM ) {}
+};
+
+//! A wrapper around [Unix-domain datagram sockets](\ref man7::unix)
+class LocalDatagramSocket : public DatagramSocket
+{
+ //! \param[in] fd is the FileDescriptor from which to construct
+ explicit LocalDatagramSocket( FileDescriptor&& fd ) : DatagramSocket( std::move( fd ), AF_UNIX, SOCK_DGRAM ) {}
+
+public:
+ //! Default: construct an unbound, unconnected socket
+ LocalDatagramSocket() : DatagramSocket( AF_UNIX, SOCK_DGRAM ) {}
+};
diff --git a/ricochet/include/ricochet/net/sys/string_view_range.hh b/ricochet/include/ricochet/net/sys/string_view_range.hh
new file mode 100644
index 0000000..84be394
--- /dev/null
+++ b/ricochet/include/ricochet/net/sys/string_view_range.hh
@@ -0,0 +1,18 @@
+/**
+ * ---------------------------------------------------------------------------
+ * @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.
+ * ---------------------------------------------------------------------------
+ */
+
+#pragma once
+
+#include <ranges>
+#include <type_traits>
+
+// This concept represents any range (e.g. vector) whose elements can be converted to string_view.
+template<typename R>
+concept StringViewRange
+ = std::ranges::sized_range<R> && std::is_constructible_v<std::string_view, std::ranges::range_value_t<R>>;
diff --git a/ricochet/include/ricochet/net/url_parser.hh b/ricochet/include/ricochet/net/url_parser.hh
new file mode 100644
index 0000000..039e4dc
--- /dev/null
+++ b/ricochet/include/ricochet/net/url_parser.hh
@@ -0,0 +1,37 @@
+/*
+ * 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 <expected>
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+struct ParsedUrl
+{
+ std::string host {};
+ std::string path {};
+};
+
+std::expected<ParsedUrl, std::string> parse_url( std::string_view url );
+
+std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host );
+
+} // namespace ricochet::net
diff --git a/ricochet/include/ricochet/parser/dom_tree.hh b/ricochet/include/ricochet/parser/dom_tree.hh
new file mode 100644
index 0000000..e3b3664
--- /dev/null
+++ b/ricochet/include/ricochet/parser/dom_tree.hh
@@ -0,0 +1,36 @@
+/*
+ * 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 <string>
+#include <vector>
+
+namespace ricochet::parser {
+
+/**
+ * @brief DOM Node. Pure value semantics, no pointers.
+ */
+struct DomNode
+{
+ std::string tag_name {}; // If empty, it's a pure text node
+ std::string text_content {}; // Only meaningful when tag_name is empty
+ std::vector<DomNode> children {}; // Recursive child nodes list
+};
+
+} // namespace ricochet::parser \ No newline at end of file
diff --git a/ricochet/include/ricochet/parser/html_lexer.hh b/ricochet/include/ricochet/parser/html_lexer.hh
new file mode 100644
index 0000000..d9ce23a
--- /dev/null
+++ b/ricochet/include/ricochet/parser/html_lexer.hh
@@ -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/>.
+ */
+
+#pragma once
+
+#include <string>
+#include <string_view>
+#include <variant>
+#include <vector>
+
+namespace ricochet::parser {
+
+struct TextToken
+{
+ std::string content {};
+};
+
+struct TagOpenToken
+{
+ std::string name {};
+};
+
+struct TagCloseToken
+{
+ std::string name {};
+};
+
+using HtmlToken = std::variant<TextToken, TagOpenToken, TagCloseToken>;
+
+class HtmlLexer
+{
+public:
+ HtmlLexer() = default;
+ ~HtmlLexer() = default;
+
+ HtmlLexer( const HtmlLexer& ) = delete;
+ HtmlLexer& operator=( const HtmlLexer& ) = delete;
+ HtmlLexer( HtmlLexer&& ) = delete;
+ HtmlLexer& operator=( HtmlLexer&& ) = delete;
+
+ /**
+ * @brief Perform lexical analysis to convert HTML source code into a structured sequence of tokens
+ */
+ std::vector<HtmlToken> tokenize( std::string_view html ) const;
+
+private:
+};
+
+} // namespace ricochet::parser \ No newline at end of file
diff --git a/ricochet/include/ricochet/parser/tree_builder.hh b/ricochet/include/ricochet/parser/tree_builder.hh
new file mode 100644
index 0000000..2cb0748
--- /dev/null
+++ b/ricochet/include/ricochet/parser/tree_builder.hh
@@ -0,0 +1,45 @@
+/*
+ * 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/parser/dom_tree.hh"
+#include "ricochet/parser/html_lexer.hh"
+
+#include <vector>
+
+namespace ricochet::parser {
+
+class TreeBuilder
+{
+public:
+ TreeBuilder() = default;
+ ~TreeBuilder() = default;
+
+ TreeBuilder( const TreeBuilder& ) = delete;
+ TreeBuilder& operator=( const TreeBuilder& ) = delete;
+ TreeBuilder( TreeBuilder&& ) = delete;
+ TreeBuilder& operator=( TreeBuilder&& ) = delete;
+
+ /**
+ * @brief Assemble a one-dimensional token sequence into a multi-dimensional DOM tree
+ */
+ DomNode build( const std::vector<HtmlToken>& tokens ) const;
+};
+
+} // namespace ricochet::parser \ No newline at end of file
diff --git a/ricochet/include/ricochet/render/input_renderer.hh b/ricochet/include/ricochet/render/input_renderer.hh
new file mode 100644
index 0000000..0114673
--- /dev/null
+++ b/ricochet/include/ricochet/render/input_renderer.hh
@@ -0,0 +1,34 @@
+/*
+ * 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/parser/dom_tree.hh"
+#include "ricochet/render/renderer.hh"
+#include <string>
+
+namespace ricochet::render {
+
+struct FormContext;
+
+void render_input_node( const parser::DomNode& node,
+ RenderResult& result,
+ const FormContext& ctx,
+ std::string& output );
+
+} // namespace ricochet::render
diff --git a/ricochet/include/ricochet/render/internal.hh b/ricochet/include/ricochet/render/internal.hh
new file mode 100644
index 0000000..5e7cac0
--- /dev/null
+++ b/ricochet/include/ricochet/render/internal.hh
@@ -0,0 +1,35 @@
+/*
+ * 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/parser/dom_tree.hh"
+#include "ricochet/render/renderer.hh"
+#include <string>
+
+namespace ricochet::render {
+
+struct FormContext
+{
+ std::string action;
+ std::string method;
+};
+
+void render_node( const parser::DomNode& node, RenderResult& result, FormContext ctx );
+
+} // namespace ricochet::render
diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh
new file mode 100644
index 0000000..472afaa
--- /dev/null
+++ b/ricochet/include/ricochet/render/renderer.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 "ricochet/parser/dom_tree.hh"
+#include <string>
+
+namespace ricochet::render {
+
+struct InputField
+{
+ std::string name;
+ std::string action;
+ std::string method;
+ std::string value {};
+ std::string type {};
+ bool checked { false };
+};
+struct RenderResult
+{
+ std::string text {};
+ std::vector<std::string> links {};
+ std::vector<InputField> inputs {};
+};
+
+class Renderer
+{
+public:
+ Renderer() = default;
+ ~Renderer() = default;
+
+ Renderer( const Renderer& ) = delete;
+ Renderer& operator=( const Renderer& ) = delete;
+ Renderer( Renderer&& ) = delete;
+ Renderer& operator=( Renderer&& ) = delete;
+
+ /**
+ * @brief Traverse the DOM tree and output the formatted result to the terminal using ANSI escape codes
+ * @param node The root node of the DOM tree
+ */
+ RenderResult render( const parser::DomNode& node ) const;
+};
+
+} // namespace ricochet::render \ No newline at end of file
diff --git a/ricochet/include/ricochet/render/table_renderer.hh b/ricochet/include/ricochet/render/table_renderer.hh
new file mode 100644
index 0000000..5efc42c
--- /dev/null
+++ b/ricochet/include/ricochet/render/table_renderer.hh
@@ -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/>.
+ */
+
+#pragma once
+
+#include "ricochet/parser/dom_tree.hh"
+#include "ricochet/render/renderer.hh"
+
+namespace ricochet::render {
+
+struct FormContext;
+
+void render_table( const parser::DomNode& node, RenderResult& result, const FormContext& ctx );
+
+} // namespace ricochet::render
diff --git a/ricochet/include/ricochet/render/utils.hh b/ricochet/include/ricochet/render/utils.hh
new file mode 100644
index 0000000..38b3022
--- /dev/null
+++ b/ricochet/include/ricochet/render/utils.hh
@@ -0,0 +1,33 @@
+/*
+ * 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 <string>
+#include <string_view>
+
+namespace ricochet::render {
+
+std::string decode_entities( std::string text );
+std::string extract_attr( const std::string& tag_name, std::string_view attr_name );
+std::size_t get_visible_length( std::string_view str );
+
+bool is_ignored_tag( std::string_view tag );
+bool is_heading_tag( std::string_view tag );
+
+} // namespace ricochet::render
diff --git a/ricochet/include/ricochet/tui/document.hh b/ricochet/include/ricochet/tui/document.hh
new file mode 100644
index 0000000..bd76442
--- /dev/null
+++ b/ricochet/include/ricochet/tui/document.hh
@@ -0,0 +1,255 @@
+/*
+ * 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 <memory>
+#include <optional>
+#include <string>
+#include <variant>
+#include <vector>
+
+namespace ricochet::tui {
+
+struct TextStyle
+{
+ bool emphasis { false };
+ bool strong { false };
+ bool code { false };
+ bool subdued { false };
+ bool warning { false };
+};
+
+struct StyledRange
+{
+ ByteRange bytes {};
+ TextStyle style {};
+};
+
+// Leaf IDs and structural block IDs share one document-local namespace.
+// All text offsets address a Text leaf, not a concatenation of rendered lines.
+struct Text
+{
+ BlockId id {};
+ // LF is an explicit hard break; soft wrapping never changes this string.
+ std::string utf8 {};
+ std::vector<StyledRange> styles {};
+};
+
+struct ControlPlaceholder
+{ ControlId control {}; };
+
+using Inline = std::variant<Text, ControlPlaceholder>;
+
+struct Paragraph
+{ std::vector<Inline> content {}; };
+
+struct Heading
+{
+ unsigned int level { 1 };
+ std::vector<Inline> content {};
+};
+
+struct Preformatted
+{
+ Text content {};
+ std::optional<std::string> language {};
+};
+
+struct Block;
+
+struct ListItem
+{
+ std::optional<std::string> marker {};
+ std::vector<Block> content {};
+};
+
+enum class ListKind : std::uint8_t
+{
+ Unordered,
+ Ordered
+};
+
+struct List
+{
+ ListKind kind { ListKind::Unordered };
+ std::int64_t first_number { 1 };
+ std::vector<ListItem> items {};
+};
+
+struct TableCell
+{
+ // The producer resolves HTML spans to grid coordinates before delivery.
+ std::size_t column { 0 };
+ std::size_t column_span { 1 };
+ std::size_t row_span { 1 };
+ bool header { false };
+ std::vector<Block> content {};
+};
+
+struct TableRow
+{ std::vector<TableCell> cells {}; };
+
+struct Table
+{
+ std::size_t columns { 0 };
+ std::vector<TableRow> rows {};
+};
+
+struct Alternative
+{ Text description {}; };
+
+struct Separator
+{};
+
+struct Block
+{
+ // Structural identity supports empty blocks and per-block horizontal scroll.
+ BlockId id {};
+ std::variant<Paragraph, Heading, Preformatted, List, Table, ControlPlaceholder, Alternative, Separator>
+ content {};
+};
+
+struct Link
+{
+ LinkId id {};
+ std::string target {};
+ std::string description {};
+ // Multiple ranges can belong to one link, independently of text styling.
+ std::vector<TextRange> ranges {};
+};
+
+enum class TextControlKind : std::uint8_t
+{
+ SingleLine,
+ MultiLine,
+ Password
+};
+
+struct TextControl
+{
+ TextControlKind kind { TextControlKind::SingleLine };
+ std::string value {};
+ std::string default_value {};
+};
+
+struct Checkbox
+{
+ bool checked { false };
+ bool default_checked { false };
+};
+
+struct RadioButton
+{
+ RadioGroupId group {};
+ bool checked { false };
+ bool default_checked { false };
+};
+
+struct SelectOption
+{
+ OptionId id {};
+ std::string label {};
+ bool disabled { false };
+};
+
+enum class SelectKind : std::uint8_t
+{
+ Single,
+ Multiple
+};
+
+struct SelectControl
+{
+ SelectKind kind { SelectKind::Single };
+ std::vector<SelectOption> options {};
+ std::vector<OptionId> selected {};
+ std::vector<OptionId> default_selected {};
+};
+
+enum class ButtonKind : std::uint8_t
+{
+ Submit,
+ Reset
+};
+
+struct Button
+{ ButtonKind kind { ButtonKind::Submit }; };
+
+struct UnsupportedControl
+{ std::string description {}; };
+
+struct Control
+{
+ ControlId id {};
+ std::optional<FormId> form {};
+ std::string label {};
+ std::string hint {};
+ std::optional<std::string> error {};
+ bool disabled { false };
+ bool read_only { false };
+ std::variant<TextControl, Checkbox, RadioButton, SelectControl, Button, UnsupportedControl> content {};
+};
+
+struct Form
+{
+ FormId id {};
+ std::string label {};
+};
+
+struct Anchor
+{
+ std::string name {};
+ ContentPoint point {};
+};
+
+struct DocumentMetadata
+{
+ std::string title {};
+ std::string display_url {};
+ std::optional<std::string> language {};
+};
+
+// Producers build owned values. No DOM pointers, terminal codes or screen coordinates.
+struct DocumentData
+{
+ DocumentVersion version {};
+ DocumentMetadata metadata {};
+ std::vector<Block> blocks {};
+ std::vector<Link> links {};
+ std::vector<Control> controls {};
+ std::vector<Form> forms {};
+ std::vector<Anchor> anchors {};
+};
+
+class Document
+{
+public:
+ explicit Document( DocumentData data );
+
+ const DocumentData& data() const noexcept;
+
+private:
+ std::shared_ptr<const DocumentData> data_;
+};
+
+} // namespace ricochet::tui
diff --git a/ricochet/include/ricochet/tui/identity.hh b/ricochet/include/ricochet/tui/identity.hh
new file mode 100644
index 0000000..624c6cc
--- /dev/null
+++ b/ricochet/include/ricochet/tui/identity.hh
@@ -0,0 +1,63 @@
+/*
+ * 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 <compare>
+#include <cstdint>
+
+namespace ricochet::tui {
+
+template<class Tag>
+struct Identity
+{
+ std::uint64_t value { 0 };
+
+ auto operator<=>( const Identity& ) const = default;
+};
+
+using TabId = Identity<struct TabTag>;
+using DocumentId = Identity<struct DocumentTag>;
+using Revision = Identity<struct RevisionTag>;
+using DraftRevision = Identity<struct DraftRevisionTag>;
+using BlockId = Identity<struct BlockTag>;
+using LinkId = Identity<struct LinkTag>;
+using ControlId = Identity<struct ControlTag>;
+using FormId = Identity<struct FormTag>;
+using OptionId = Identity<struct OptionTag>;
+using RadioGroupId = Identity<struct RadioGroupTag>;
+using TaskId = Identity<struct TaskTag>;
+using RequestId = Identity<struct RequestTag>;
+
+struct DocumentVersion
+{
+ DocumentId document {};
+ Revision revision {};
+
+ bool operator==( const DocumentVersion& ) const = default;
+};
+
+struct DocumentScope
+{
+ TabId tab {};
+ DocumentVersion version {};
+
+ bool operator==( const DocumentScope& ) const = default;
+};
+
+} // namespace ricochet::tui
diff --git a/ricochet/include/ricochet/tui/input.hh b/ricochet/include/ricochet/tui/input.hh
new file mode 100644
index 0000000..df5b458
--- /dev/null
+++ b/ricochet/include/ricochet/tui/input.hh
@@ -0,0 +1,119 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <chrono>
+#include <cstdint>
+#include <string>
+#include <variant>
+
+namespace ricochet::tui {
+
+struct ScreenSize
+{
+ int rows { 0 };
+ int columns { 0 };
+ bool operator==( const ScreenSize& ) const = default;
+};
+
+struct CellPoint
+{
+ int row { 0 };
+ int column { 0 };
+};
+
+struct Modifiers
+{
+ bool shift { false };
+ bool control { false };
+ bool alt { false };
+ bool operator==( const Modifiers& ) const = default;
+};
+
+enum class KeyCode : std::uint8_t
+{
+ Character,
+ Escape,
+ Enter,
+ Tab,
+ Backspace,
+ Delete,
+ Insert,
+ Left,
+ Right,
+ Up,
+ Down,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+ Function,
+ Unsupported
+};
+
+struct Key
+{
+ KeyCode code { KeyCode::Unsupported };
+ Modifiers modifiers {};
+ char32_t character { 0 };
+ unsigned int function { 0 };
+};
+
+struct TextInput
+{ char32_t character { 0 }; };
+
+struct Paste
+{ std::u32string text {}; };
+
+struct MouseButtons
+{
+ bool left { false };
+ bool middle { false };
+ bool right { false };
+};
+
+struct Mouse
+{
+ // Screen coordinates; the view router converts these to region-local coordinates.
+ CellPoint position {};
+ Modifiers modifiers {};
+ MouseButtons pressed {};
+ MouseButtons released {};
+ bool motion { false };
+ int wheel_steps { 0 }; // Positive scrolls down.
+};
+
+struct Resize
+{ ScreenSize size {}; };
+
+struct Tick
+{ std::chrono::steady_clock::time_point now {}; };
+
+enum class TerminalCloseReason : std::uint8_t
+{
+ Disconnected,
+ TerminationSignal
+};
+
+struct TerminalClosed
+{ TerminalCloseReason reason { TerminalCloseReason::Disconnected }; };
+
+using InputEvent = std::variant<Key, TextInput, Paste, Mouse, Resize, Tick, TerminalClosed>;
+
+} // namespace ricochet::tui
diff --git a/ricochet/include/ricochet/tui/messages.hh b/ricochet/include/ricochet/tui/messages.hh
new file mode 100644
index 0000000..9d51fa3
--- /dev/null
+++ b/ricochet/include/ricochet/tui/messages.hh
@@ -0,0 +1,268 @@
+/*
+ * 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 <cstdint>
+#include <optional>
+#include <string>
+#include <variant>
+#include <vector>
+
+namespace ricochet::tui {
+
+struct TabInfo
+{
+ TabId id {};
+ std::string title {};
+ std::string display_url {};
+ std::optional<TaskId> loading_task {};
+};
+
+struct SyncTabs
+{
+ // Vector order is tab order. Request IDs acknowledge create/close actions.
+ std::vector<TabInfo> tabs {};
+ std::optional<TabId> active {};
+ std::vector<RequestId> completed_requests {};
+};
+
+struct NavigationStarted
+{
+ TabId tab {};
+ RequestId request {};
+ TaskId task {};
+ std::string display_target {};
+};
+
+struct CommitDocument
+{
+ TabId tab {};
+ RequestId navigation {};
+ Document document;
+ std::optional<ViewSnapshot> restore {};
+};
+
+struct TextCorrespondence
+{
+ // The producer identifies identical text in the two revisions, including every
+ // changed portion crossed by a selection. This is not an inferred text diff.
+ TextRange source {};
+ TextRange target {};
+};
+
+struct PositionMapping
+{
+ DocumentVersion source {};
+ DocumentVersion target {};
+ std::vector<TextCorrespondence> text {};
+};
+
+enum class ViewRetention : std::uint8_t
+{
+ Reset,
+ PreserveUnchanged
+};
+
+struct UpdateDocument
+{
+ TabId tab {};
+ RequestId navigation {};
+ DocumentVersion source {};
+ Document document;
+ ViewRetention retention { ViewRetention::PreserveUnchanged };
+ std::optional<PositionMapping> mapping {};
+};
+
+struct LocateView
+{
+ DocumentScope scope {};
+ std::variant<std::string, DocumentPoint> target {};
+};
+
+// Values are keyed by control identity, never by field name or action URL.
+// These business messages may carry passwords; ViewSnapshot deliberately does not.
+using ControlValue = std::variant<std::string, bool, std::vector<OptionId>>;
+
+struct ControlDraft
+{
+ ControlId control {};
+ ControlValue value {};
+};
+
+struct FieldError
+{
+ ControlId control {};
+ std::string message {};
+};
+
+struct FormFeedback
+{
+ DocumentScope scope {};
+ FormId form {};
+ RequestId request {};
+ DraftRevision draft_revision {};
+ std::vector<FieldError> errors {};
+ std::vector<ControlDraft> normalized_values {};
+};
+
+struct ActionFailed
+{
+ RequestId request {};
+ std::optional<TabId> tab {};
+ std::string summary {};
+ std::optional<std::string> details {};
+};
+
+struct ExitDecision
+{
+ RequestId request {};
+ bool approved { false };
+ std::optional<std::string> explanation {};
+};
+
+enum class CopyStatus : std::uint8_t
+{
+ Confirmed,
+ Dispatched,
+ Unavailable,
+ Failed
+};
+
+// The host's injected clipboard provider completes a CopySelection request once.
+// Dispatched must not be used as confirmation that a terminal accepted the text.
+struct CopyResult
+{
+ RequestId request {};
+ CopyStatus status { CopyStatus::Unavailable };
+ std::string summary {};
+};
+
+using HostUpdate = std::variant<SyncTabs,
+ NavigationStarted,
+ CommitDocument,
+ UpdateDocument,
+ LocateView,
+ FormFeedback,
+ CopyResult,
+ ActionFailed,
+ ExitDecision>;
+
+enum class OpenTarget : std::uint8_t
+{
+ CurrentTab,
+ NewTab
+};
+
+struct OpenLocation
+{
+ RequestId request {};
+ TabId tab {};
+ std::string input {};
+ OpenTarget target { OpenTarget::CurrentTab };
+};
+
+struct OpenLink
+{
+ RequestId request {};
+ DocumentScope source {};
+ LinkId link {};
+ std::string target {};
+ OpenTarget destination { OpenTarget::CurrentTab };
+};
+
+enum class NavigationCommand : std::uint8_t
+{
+ Back,
+ Forward,
+ Reload
+};
+
+struct Navigate
+{
+ RequestId request {};
+ TabId tab {};
+ std::optional<DocumentVersion> document {};
+ NavigationCommand command { NavigationCommand::Reload };
+};
+
+struct CreateTab
+{ RequestId request {}; };
+
+struct CloseTab
+{
+ RequestId request {};
+ TabId tab {};
+};
+
+enum class FormOperation : std::uint8_t
+{
+ Submit,
+ Reset
+};
+
+struct FormAction
+{
+ RequestId request {};
+ DocumentScope scope {};
+ FormId form {};
+ std::optional<ControlId> button {};
+ DraftRevision draft_revision {};
+ FormOperation operation { FormOperation::Submit };
+ std::vector<ControlDraft> values {};
+};
+
+struct ExportSelection
+{
+ RequestId request {};
+ DocumentScope source {};
+ // Already extracted from one view snapshot; passwords are masked.
+ std::string text {};
+};
+
+struct CopySelection
+{
+ RequestId request {};
+ DocumentScope source {};
+ DraftRevision draft_revision {};
+ // Frozen before dispatch; later cursor/selection changes cannot change this copy.
+ // Password content is already masked. Clipboard providers never receive drafts.
+ std::string text {};
+};
+
+struct RequestExit
+{
+ RequestId request {};
+ std::optional<TabId> active {};
+ std::vector<ViewSnapshot> views {};
+};
+
+using UserAction = std::variant<OpenLocation,
+ OpenLink,
+ Navigate,
+ CreateTab,
+ CloseTab,
+ FormAction,
+ ExportSelection,
+ CopySelection,
+ RequestExit>;
+
+} // namespace ricochet::tui
diff --git a/ricochet/include/ricochet/tui/position.hh b/ricochet/include/ricochet/tui/position.hh
new file mode 100644
index 0000000..49b63f2
--- /dev/null
+++ b/ricochet/include/ricochet/tui/position.hh
@@ -0,0 +1,116 @@
+/*
+ * 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/identity.hh"
+
+#include <cstddef>
+#include <cstdint>
+#include <variant>
+
+namespace ricochet::tui {
+
+// UTF-8 byte ranges are half-open. Text operations use grapheme boundaries.
+struct ByteRange
+{
+ std::size_t start { 0 };
+ std::size_t end { 0 };
+
+ bool operator==( const ByteRange& ) const = default;
+};
+
+struct TextPosition
+{
+ BlockId block {};
+ std::size_t byte_offset { 0 };
+
+ bool operator==( const TextPosition& ) const = default;
+};
+
+struct ControlTextPosition
+{
+ ControlId control {};
+ DraftRevision revision {};
+ // Offset in display text; password offsets refer only to its mask.
+ std::size_t byte_offset { 0 };
+
+ bool operator==( const ControlTextPosition& ) const = default;
+};
+
+enum class BoundarySide : std::uint8_t
+{
+ Before,
+ After
+};
+
+struct ObjectPosition
+{
+ std::variant<BlockId, ControlId> object {};
+ BoundarySide side { BoundarySide::Before };
+
+ bool operator==( const ObjectPosition& ) const = default;
+};
+
+struct DocumentStart
+{ bool operator==( const DocumentStart& ) const = default; };
+
+// Local to a document snapshot. Stored anchors cannot depend on mutable drafts.
+using ContentPoint = std::variant<DocumentStart, TextPosition, ObjectPosition>;
+using DocumentPoint = std::variant<DocumentStart, TextPosition, ControlTextPosition, ObjectPosition>;
+
+struct LogicalPosition
+{
+ DocumentScope scope {};
+ DocumentPoint point {};
+
+ bool operator==( const LogicalPosition& ) const = default;
+};
+
+// Endpoints follow reading order, never the numeric order of their IDs.
+struct DocumentRange
+{
+ DocumentPoint start {};
+ DocumentPoint end {};
+
+ bool operator==( const DocumentRange& ) const = default;
+};
+
+struct TextRange
+{
+ BlockId block {};
+ ByteRange bytes {};
+
+ bool operator==( const TextRange& ) const = default;
+};
+
+enum class WrapAffinity : std::uint8_t
+{
+ PreviousLine,
+ NextLine
+};
+
+struct DisplayPosition
+{
+ DocumentPoint point {};
+ WrapAffinity affinity { WrapAffinity::NextLine };
+
+ bool operator==( const DisplayPosition& ) const = default;
+};
+
+} // namespace ricochet::tui
diff --git a/ricochet/include/ricochet/tui/session.hh b/ricochet/include/ricochet/tui/session.hh
new file mode 100644
index 0000000..94cae68
--- /dev/null
+++ b/ricochet/include/ricochet/tui/session.hh
@@ -0,0 +1,110 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/tui/input.hh"
+#include "ricochet/tui/messages.hh"
+
+#include <chrono>
+#include <cstdio>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace ricochet::tui {
+
+namespace detail {
+class Mailbox;
+}
+
+struct TerminalOptions
+{
+ bool capture_mouse { true };
+ // Explicit host opt-in: terminfo does not establish bracketed-paste support.
+ bool bracketed_paste { false };
+ std::chrono::milliseconds escape_delay { 25 };
+};
+
+struct TerminalCapabilities
+{
+ bool colors { false };
+ bool mouse_buttons { false };
+ bool mouse_motion { false };
+ bool bracketed_paste { false };
+};
+
+enum class PostResult : std::uint8_t
+{
+ Queued,
+ Closed
+};
+
+// A sender can outlive the UI session. Posting is thread-safe; it never calls curses.
+class UpdateSender
+{
+public:
+ PostResult post_update( HostUpdate update ) const;
+
+private:
+ friend class Session;
+ explicit UpdateSender( std::shared_ptr<detail::Mailbox> mailbox );
+ std::shared_ptr<detail::Mailbox> mailbox_;
+};
+
+struct StepResult
+{
+ bool running { true };
+ // Incremental document updates and editing are subsequent milestones. Until then,
+ // unsupported updates are returned intact rather than silently acknowledged.
+ std::vector<HostUpdate> unhandled_updates {};
+ std::optional<TerminalClosed> terminal_closed {};
+ std::optional<std::string> error {};
+};
+
+class Session
+{
+public:
+ explicit Session( TerminalOptions options = {} );
+ // Streams must remain open until destruction. Construction, step, close and
+ // destruction run on the same UI thread; only one terminal session may exist.
+ Session( std::FILE& input, std::FILE& output, TerminalOptions options = {} );
+ ~Session();
+
+ Session( const Session& ) = delete;
+ Session& operator=( const Session& ) = delete;
+ Session( Session&& ) = delete;
+ Session& operator=( Session&& ) = delete;
+
+ UpdateSender sender() const;
+ PostResult post_update( HostUpdate update );
+ // max_wait bounds idle waiting. Decoding a partial terminal key sequence can
+ // additionally wait for escape_delay; processing batches is bounded separately.
+ StepResult step( std::chrono::milliseconds max_wait );
+ std::vector<UserAction> take_actions();
+ std::optional<ViewSnapshot> snapshot( TabId tab ) const;
+ TerminalCapabilities capabilities() const;
+ void close() noexcept;
+
+private:
+ class Impl;
+ std::unique_ptr<Impl> impl_;
+};
+
+} // namespace ricochet::tui
diff --git a/ricochet/include/ricochet/tui/view.hh b/ricochet/include/ricochet/tui/view.hh
new file mode 100644
index 0000000..cc1d0fd
--- /dev/null
+++ b/ricochet/include/ricochet/tui/view.hh
@@ -0,0 +1,74 @@
+/*
+ * 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 <optional>
+#include <string>
+#include <vector>
+
+namespace ricochet::tui {
+
+enum class SelectionDirection : std::uint8_t
+{
+ Forward,
+ Backward
+};
+
+struct SelectionSnapshot
+{
+ // The normalized half-open range also covers any controls between its endpoints.
+ DocumentRange range {};
+ SelectionDirection direction { SelectionDirection::Forward };
+ // Inclusive endpoint units retain the anchor through reversal and history restore.
+ DocumentRange anchor {};
+ DocumentRange active {};
+};
+
+struct HorizontalScroll
+{
+ BlockId block {};
+ std::size_t columns { 0 };
+};
+
+struct SearchSnapshot
+{
+ std::string query {};
+ std::optional<DocumentRange> current_match {};
+};
+
+// All positions and ranges belong to scope and draft_revision.
+// Editing sessions and control values (including passwords) are not exported here.
+struct ViewSnapshot
+{
+ DocumentScope scope {};
+ DraftRevision draft_revision {};
+ DisplayPosition cursor {};
+ DisplayPosition viewport_anchor {};
+ // Relative visual row for blank table space with no text at the exact top.
+ std::int64_t viewport_row_offset { 0 };
+ std::optional<SelectionSnapshot> selection {};
+ std::vector<HorizontalScroll> horizontal_scroll {};
+ SearchSnapshot search {};
+};
+
+} // namespace ricochet::tui
diff --git a/ricochet/scripts/add_license.py b/ricochet/scripts/add_license.py
new file mode 100644
index 0000000..d15c1fc
--- /dev/null
+++ b/ricochet/scripts/add_license.py
@@ -0,0 +1,57 @@
+import os
+from pathlib import Path
+
+# File extensions to process
+EXTENSIONS = {'.cpp', '.h', '.hpp', '.c', '.js', '.rs'}
+
+# License template
+LICENSE_TEMPLATE = """/*
+ * 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/>.
+ */
+"""
+
+# Identifier to check if license already exists
+IDENTIFIER = "Ricochet - A minimalist CLI web browser"
+
+def add_license_to_file(file_path):
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ if IDENTIFIER in content[:500]:
+ print(f"[-] Skip: {file_path} (License already exists)")
+ return
+ with open(file_path, 'w', encoding='utf-8') as f:
+ f.write(LICENSE_TEMPLATE + "\n" + content)
+ print(f"[+] Updated: {file_path}")
+
+ except Exception as e:
+ print(f"[!] Error: Failed to process {file_path} -> {e}")
+
+def main(target_dir):
+ path = Path(target_dir)
+ if not path.exists():
+ print(f"Error: Path {target_dir} does not exist")
+ return
+
+ for file in path.rglob('*'):
+ if file.suffix in EXTENSIONS:
+ add_license_to_file(file)
+
+if __name__ == "__main__":
+ project_root = input("Please enter the project root directory (press Enter to use the current directory): ").strip() or "."
+ main(project_root) \ No newline at end of file
diff --git a/ricochet/scripts/make-parallel.sh b/ricochet/scripts/make-parallel.sh
new file mode 100644
index 0000000..879af9a
--- /dev/null
+++ b/ricochet/scripts/make-parallel.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make -j`nproc` "$@"
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
diff --git a/ricochet/tests/CMakeLists.txt b/ricochet/tests/CMakeLists.txt
new file mode 100644
index 0000000..00d7e4e
--- /dev/null
+++ b/ricochet/tests/CMakeLists.txt
@@ -0,0 +1,7 @@
+# file(GLOB_RECURSE ALL_TEST_SOURCES CONFIGURE_DEPENDS "*.cc")
+
+# add_executable(ricochet_tests ${ALL_TEST_SOURCES})
+# target_link_libraries(ricochet_tests PRIVATE ricochet_core)
+
+# include(CTest)
+# add_test(NAME ricochet_tests COMMAND ricochet_tests) \ No newline at end of file