diff options
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 + = { { { " ", " " }, { "<", "<" }, { ">", ">" }, { "&", "&" }, { """, "\"" }, + { "'", "'" }, { "©", "©" }, { "®", "®" }, { "™", "™" }, { "€", "€" }, + { "£", "£" }, { "¥", "¥" }, { "¢", "¢" }, { "§", "§" }, { "¶", "¶" }, + { "…", "…" }, { "†", "†" }, { "‡", "‡" }, { "•", "•" }, { "′", "′" }, + { "″", "″" }, { "‹", "‹" }, { "›", "›" }, { "‾", "‾" }, { "⁄", "⁄" } } }; + + 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 |
