From 3f39df2356504bc68a4e493a723fd4d907c43ff4 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 00:50:38 +0800 Subject: feat: initialize project structure with CMake build system, C++23 support, and core browser scaffolding --- ricochet/src/core/browser.cc | 30 +++++++++++++++++++ ricochet/src/core/license.cc | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 ricochet/src/core/browser.cc create mode 100644 ricochet/src/core/license.cc (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc new file mode 100644 index 0000000..88c47be --- /dev/null +++ b/ricochet/src/core/browser.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 . + */ + +#include "ricochet/core/browser.hh" +#include + +namespace ricochet::core { + +int Browser::run( std::string_view initial_url ) +{ + std::cout << "Browser: Navigating to " << initial_url << "...\n"; + return 0; +} + +} // 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 . + */ + +#include "ricochet/core/license.hh" +#include + +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 . +)" << '\n'; +} + +} // namespace ricochet::core \ No newline at end of file -- cgit v1.2.3 From bfb85334541e44cf03823fe8f0f80c89ee1feeba Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 02:20:31 +0800 Subject: feat: implement basic HTTP client with socket and address networking utilities --- ricochet/README.md | 3 + ricochet/include/ricochet/net/http_client.hh | 59 +++++ ricochet/include/ricochet/net/sys/address.hh | 88 +++++++ ricochet/include/ricochet/net/sys/exception.hh | 80 ++++++ .../include/ricochet/net/sys/file_descriptor.hh | 141 ++++++++++ ricochet/include/ricochet/net/sys/socket.hh | 160 ++++++++++++ .../include/ricochet/net/sys/string_view_range.hh | 18 ++ ricochet/src/core/browser.cc | 13 + ricochet/src/net/http_client.cc | 118 +++++++++ ricochet/src/net/sys/address.cc | 207 +++++++++++++++ ricochet/src/net/sys/file_descriptor.cc | 213 +++++++++++++++ ricochet/src/net/sys/socket.cc | 286 +++++++++++++++++++++ 12 files changed, 1386 insertions(+) create mode 100644 ricochet/include/ricochet/net/http_client.hh create mode 100644 ricochet/include/ricochet/net/sys/address.hh create mode 100644 ricochet/include/ricochet/net/sys/exception.hh create mode 100644 ricochet/include/ricochet/net/sys/file_descriptor.hh create mode 100644 ricochet/include/ricochet/net/sys/socket.hh create mode 100644 ricochet/include/ricochet/net/sys/string_view_range.hh create mode 100644 ricochet/src/net/http_client.cc create mode 100644 ricochet/src/net/sys/address.cc create mode 100644 ricochet/src/net/sys/file_descriptor.cc create mode 100644 ricochet/src/net/sys/socket.cc (limited to 'ricochet/src/core') diff --git a/ricochet/README.md b/ricochet/README.md index 5d6549d..fd300d2 100644 --- a/ricochet/README.md +++ b/ricochet/README.md @@ -104,6 +104,9 @@ This installs the `ricochet` binary to `bin` and supporting libraries to `lib`. | `u` | Enter new URL | | `q` | Quit Ricochet | +## 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**. diff --git a/ricochet/include/ricochet/net/http_client.hh b/ricochet/include/ricochet/net/http_client.hh new file mode 100644 index 0000000..2dd7419 --- /dev/null +++ b/ricochet/include/ricochet/net/http_client.hh @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +#pragma once + +#include +#include +#include +#include + +namespace ricochet::net { +/** + * @brief Encapsulate HTTP response result + */ + +struct HttpResponse +{ + int status_code { 0 }; + std::unordered_map headers {}; + std::string body {}; +}; + +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 fetch( std::string_view url ) const; + +private: +}; + +} // 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 +#include +#include +#include +#include +#include + +//! 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 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( address_ ); } + //! Safely convert to underlying sockaddr type + template + 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 +#include +#include +#include +#include + +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 +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 +inline std::unique_ptr notnull( const std::string_view context, std::unique_ptr x ) +{ + return x ? x : throw std::runtime_error( std::string( context ) + ": returned null pointer" ); +} + +template +inline std::shared_ptr notnull( const std::string_view context, std::shared_ptr&& 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 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 +#include +#include +#include + +// 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& 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 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 internal_fd_; + + // Private constructor used to duplicate the FileDescriptor (increase the reference count) + explicit FileDescriptor( std::shared_ptr other_shared_ptr ); + + // Private write method that takes a vector of iovecs (an internal OS structure) + size_t write( const std::vector& 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& 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( 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 +#include + +//! \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& 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 + socklen_t getsockopt( int level, int option, option_type& option_value ) const; + + //! Wrappers around [setsockopt(2)](\ref man2::setsockopt) + template + 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& 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
& destination = {} ); + void send( const StringViewRange auto&& payloads, const std::optional
& destination = {} ) + { + static thread_local std::vector iovecs; + const size_t total_size = to_iovecs( payloads, iovecs ); + send( iovecs, total_size, destination ); + } + +private: + void send( std::vector& iovecs, size_t total_size, const std::optional
& 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 +#include + +// This concept represents any range (e.g. vector) whose elements can be converted to string_view. +template +concept StringViewRange + = std::ranges::sized_range && std::is_constructible_v>; diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 88c47be..ccda9a1 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -17,6 +17,7 @@ */ #include "ricochet/core/browser.hh" +#include "ricochet/net/http_client.hh" #include namespace ricochet::core { @@ -24,6 +25,18 @@ namespace ricochet::core { int Browser::run( std::string_view initial_url ) { std::cout << "Browser: Navigating to " << initial_url << "...\n"; + + const net::HttpClient client; + auto response_result = client.fetch( initial_url ); + if ( !response_result.has_value() ) { + std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; + return 1; + } + + const auto& response = response_result.value(); + std::cout << "\n[=== HTTP Status: " << response.status_code << " ===]\n"; + std::cout << "[=== HTML Content Below ===]\n\n"; + std::cout << response.body << "\n"; return 0; } diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc new file mode 100644 index 0000000..256c1f2 --- /dev/null +++ b/ricochet/src/net/http_client.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 . + */ + +#include "ricochet/net/http_client.hh" + +#include "ricochet/net/sys/address.hh" +#include "ricochet/net/sys/socket.hh" + +#include +#include +#include + +namespace ricochet::net { + +namespace { + +struct ParsedUrl +{ + std::string host {}; + std::string path {}; +}; + +std::expected parse_url( std::string_view url ) +{ + const std::string_view http_prefix = "http://"; + + if ( !url.starts_with( http_prefix ) ) { + return std::unexpected( "Only 'http://' URLs are supported in MVP. (e.g., http://example.com)" ); + } + + url.remove_prefix( http_prefix.length() ); + auto slash_pos = url.find( '/' ); + + ParsedUrl result; + if ( slash_pos == std::string_view::npos ) { + result.host = std::string( url ); + result.path = "/"; + } else { + result.host = std::string( url.substr( 0, slash_pos ) ); + result.path = std::string( url.substr( slash_pos ) ); + } + + return result; +} +} // namespace + +std::expected HttpClient::fetch( std::string_view url ) const // NOLINT(readability-convert-member-functions-to-static) +{ + auto parsed_url = parse_url( url ); + if ( !parsed_url.has_value() ) { + return std::unexpected( parsed_url.error() ); + } + + auto [host, path] = parsed_url.value(); + + try { + const Address server_addr( host, "http" ); + + TCPSocket socket; + socket.connect( server_addr ); + + const std::string request = std::format( "GET {} HTTP/1.1\r\n" + "Host: {}\r\n" + "User-Agent: Ricochet/0.1.0\r\n" + "Connection: close\r\n\r\n", + path, + host ); + + size_t bytes_written = 0; + while ( bytes_written < request.size() ) { + bytes_written += socket.write( request.substr( bytes_written ) ); + } + + std::string raw_response; + while ( !socket.eof() ) { + std::string buffer; + socket.read( buffer ); + raw_response += buffer; + } + + HttpResponse response; + auto header_end = raw_response.find( "\r\n\r\n" ); + + if ( header_end != std::string::npos ) { + response.body = raw_response.substr( header_end + 4 ); + 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; + } + + return response; + } catch ( const std::exception& e ) { + return std::unexpected( std::string( "Network Error: " ) + e.what() ); + } +} + +} // 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 +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +//! Converts Raw to `sockaddr *`. +Address::Raw::operator sockaddr*() +{ + return reinterpret_cast( &storage ); // NOLINT(*-reinterpret-cast) +} + +//! Converts Raw to `const sockaddr *`. +Address::Raw::operator const sockaddr*() const +{ + return reinterpret_cast( &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 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 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 ip {}; + array port {}; + + const int gni_ret = getnameinfo( static_cast( 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( &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 +constexpr int sockaddr_family = -1; + +template<> +constexpr int sockaddr_family = AF_INET; + +template<> +constexpr int sockaddr_family = AF_INET6; + +template<> +constexpr int sockaddr_family = AF_PACKET; + +// safely cast the address to its underlying sockaddr type +template +const sockaddr_type* Address::as() const +{ + const sockaddr* raw { address_ }; + if ( sizeof( sockaddr_type ) < size() or raw->sa_family != sockaddr_family ) { + throw runtime_error( "Address::as() conversion failure" ); + } + + return reinterpret_cast( raw ); // NOLINT(*-reinterpret-cast) +} + +template const sockaddr_in* Address::as() const; +template const sockaddr_in6* Address::as() const; +template const sockaddr_ll* Address::as() 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 +#include +#include +#include +#include +#include + +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( fd ) ) {} + +// Private constructor used by duplicate() +FileDescriptor::FileDescriptor( shared_ptr 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& 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 iovecs; + const size_t total_size = to_iovecs( buffers, iovecs ); + + const size_t bytes_read + = CheckRead( "readv", readv( fd_num(), iovecs.data(), static_cast( 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& iovecs, size_t total_size ) +{ + const size_t bytes_written + = CheckFDSystemCall( "writev", ::writev( fd_num(), iovecs.data(), static_cast( 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 +#include +#include + +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& 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& 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 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
& 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& iovecs, size_t total_size, const optional
& destination ) +{ + const msghdr message { .msg_name = destination.has_value() // NOLINTNEXTLINE(*-const-cast) + ? static_cast( const_cast( 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 +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 +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 ); + } +} -- cgit v1.2.3 From 997415e3bad0b9ff37aa9abf930841e039695a27 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 02:48:24 +0800 Subject: feat: implement HTML lexer and integrate tokenization into browser output --- ricochet/include/ricochet/parser/html_lexer.hh | 61 +++++++++++++ ricochet/src/core/browser.cc | 50 +++++++--- ricochet/src/parser/html_lexer.cc | 121 +++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 ricochet/include/ricochet/parser/html_lexer.hh create mode 100644 ricochet/src/parser/html_lexer.cc (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/parser/html_lexer.hh b/ricochet/include/ricochet/parser/html_lexer.hh new file mode 100644 index 0000000..f97bcfa --- /dev/null +++ b/ricochet/include/ricochet/parser/html_lexer.hh @@ -0,0 +1,61 @@ +/* + * 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 . + */ + +#pragma once + +#include +#include +#include +#include + +namespace ricochet::parser { + + struct TextToken { + std::string content{}; +}; + +struct TagOpenToken { + std::string name{}; +}; + +struct TagCloseToken { + std::string name{}; +}; + +using HtmlToken = std::variant; + +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 tokenize(std::string_view html) const; + +private: +}; + +} // namespace ricochet::parser \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index ccda9a1..1e9d044 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -18,26 +18,48 @@ #include "ricochet/core/browser.hh" #include "ricochet/net/http_client.hh" +#include "ricochet/parser/html_lexer.hh" #include +namespace { +struct TokenPrinter { + void operator()(const ricochet::parser::TextToken& t) const { + std::cout << "[TEXT] " << t.content << "\n"; + } + void operator()(const ricochet::parser::TagOpenToken& t) const { + std::cout << "[OPEN] <" << t.name << ">\n"; + } + void operator()(const ricochet::parser::TagCloseToken& t) const { + std::cout << "[CLOSE] \n"; + } +}; +} // namespace namespace ricochet::core { int Browser::run( std::string_view initial_url ) { - std::cout << "Browser: Navigating to " << initial_url << "...\n"; - - const net::HttpClient client; - auto response_result = client.fetch( initial_url ); - if ( !response_result.has_value() ) { - std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; - return 1; - } - - const auto& response = response_result.value(); - std::cout << "\n[=== HTTP Status: " << response.status_code << " ===]\n"; - std::cout << "[=== HTML Content Below ===]\n\n"; - std::cout << response.body << "\n"; - return 0; + std::cout << "-> Fetching: " << initial_url << "...\n"; + + const net::HttpClient client; + auto response_result = client.fetch(initial_url); + + if (!response_result.has_value()) { + std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; + return 1; + } + + const auto& response = response_result.value(); + std::cout << "[=== HTTP Status: " << response.status_code << " ===]\n"; + + std::cout << "\n[=== Tokenizing HTML ===]\n"; + const parser::HtmlLexer lexer; + auto tokens = lexer.tokenize(response.body); + + for (const auto& token : tokens) { + std::visit(TokenPrinter{}, token); + } + + return 0; } } // namespace ricochet::core diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc new file mode 100644 index 0000000..bbf8c57 --- /dev/null +++ b/ricochet/src/parser/html_lexer.cc @@ -0,0 +1,121 @@ +/* + * 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 . + */ + +#include "ricochet/parser/html_lexer.hh" + +#include +#include + + +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& tokens) { + if (buffer.empty()) { + return; // 提前返回,减少 if 嵌套层级 + } + + bool all_whitespace = true; + for (const char c : buffer) { + if (!std::isspace(static_cast(c))) { + all_whitespace = false; + break; + } + } + + if (!all_whitespace) { + tokens.emplace_back(TextToken{buffer}); + } + buffer.clear(); +} + +} // namespace + +std::vector HtmlLexer::tokenize( std::string_view html ) const // NOLINT(readability-convert-member-functions-to-static) +{ + std::vector 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(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 if (std::isspace(static_cast(c))) { + tokens.emplace_back(TagOpenToken{buffer}); + buffer.clear(); + state = LexerState::SwallowAttributes; + } else { + buffer += static_cast(std::tolower(static_cast(c))); + } + break; + case LexerState::ClosingTagName: + if (c == '>') { + tokens.emplace_back(TagCloseToken{buffer}); + buffer.clear(); + state = LexerState::Data; + } else { + buffer += static_cast(std::tolower(static_cast(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 -- cgit v1.2.3 From 168504eab3727bd028db9c8b9e377571ae601ac5 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 03:26:17 +0800 Subject: feat: implement DOM tree builder to transform HTML tokens into a hierarchical structure --- ricochet/include/ricochet/parser/dom_tree.hh | 35 +++++++++ ricochet/include/ricochet/parser/html_lexer.hh | 2 +- ricochet/include/ricochet/parser/tree_builder.hh | 44 ++++++++++++ ricochet/src/core/browser.cc | 35 ++++----- ricochet/src/parser/tree_builder.cc | 90 ++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 ricochet/include/ricochet/parser/dom_tree.hh create mode 100644 ricochet/include/ricochet/parser/tree_builder.hh create mode 100644 ricochet/src/parser/tree_builder.cc (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/parser/dom_tree.hh b/ricochet/include/ricochet/parser/dom_tree.hh new file mode 100644 index 0000000..b1e4812 --- /dev/null +++ b/ricochet/include/ricochet/parser/dom_tree.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 . + */ + +#pragma once + +#include +#include + +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 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 index f97bcfa..2cfdda9 100644 --- a/ricochet/include/ricochet/parser/html_lexer.hh +++ b/ricochet/include/ricochet/parser/html_lexer.hh @@ -25,7 +25,7 @@ namespace ricochet::parser { - struct TextToken { +struct TextToken { std::string content{}; }; diff --git a/ricochet/include/ricochet/parser/tree_builder.hh b/ricochet/include/ricochet/parser/tree_builder.hh new file mode 100644 index 0000000..4adbc4d --- /dev/null +++ b/ricochet/include/ricochet/parser/tree_builder.hh @@ -0,0 +1,44 @@ +/* + * 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 . + */ + +#pragma once + +#include "ricochet/parser/dom_tree.hh" +#include "ricochet/parser/html_lexer.hh" + +#include + +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& tokens) const; +}; + +} // namespace ricochet::parser \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 1e9d044..7acc915 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -19,20 +19,23 @@ #include "ricochet/core/browser.hh" #include "ricochet/net/http_client.hh" #include "ricochet/parser/html_lexer.hh" +#include "ricochet/parser/tree_builder.hh" #include namespace { -struct TokenPrinter { - void operator()(const ricochet::parser::TextToken& t) const { - std::cout << "[TEXT] " << t.content << "\n"; - } - void operator()(const ricochet::parser::TagOpenToken& t) const { - std::cout << "[OPEN] <" << t.name << ">\n"; - } - void operator()(const ricochet::parser::TagCloseToken& t) const { - std::cout << "[CLOSE] \n"; +void print_dom_tree(const ricochet::parser::DomNode& node, std::size_t depth = 0) // NOLINT(misc-no-recursion) +{ + const std::string indent(depth * 2, ' '); + + if (node.tag_name.empty()) { + std::cout << indent << "\"" << node.text_content << "\"\n"; + } else { + std::cout << indent << "<" << node.tag_name << ">\n"; + for (const auto& child : node.children) { + print_dom_tree(child, depth + 1); + } } -}; +} } // namespace namespace ricochet::core { @@ -49,15 +52,15 @@ int Browser::run( std::string_view initial_url ) } const auto& response = response_result.value(); - std::cout << "[=== HTTP Status: " << response.status_code << " ===]\n"; - std::cout << "\n[=== Tokenizing HTML ===]\n"; const parser::HtmlLexer lexer; - auto tokens = lexer.tokenize(response.body); + const auto tokens = lexer.tokenize(response.body); - for (const auto& token : tokens) { - std::visit(TokenPrinter{}, token); - } + const parser::TreeBuilder builder; + const auto dom_root = builder.build(tokens); + + std::cout << "\n[=== Abstract Syntax Tree (DOM) ===]\n"; + print_dom_tree(dom_root); return 0; } diff --git a/ricochet/src/parser/tree_builder.cc b/ricochet/src/parser/tree_builder.cc new file mode 100644 index 0000000..51a8a51 --- /dev/null +++ b/ricochet/src/parser/tree_builder.cc @@ -0,0 +1,90 @@ +/* + * 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 . + */ + +#include "ricochet/parser/tree_builder.hh" +#include "ricochet/parser/dom_tree.hh" +#include +#include + +namespace ricochet::parser { + +namespace { +struct TokenVisitor { + std::vector* 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 { + DomNode new_node{ + .tag_name = token.name, + .text_content = "", + .children = {} + }; + + if (token.name == "meta" || token.name == "link" || token.name == "img" || + token.name == "br" || token.name == "hr" || token.name == "input" || + token.name == "base" || token.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(const std::vector& tokens) const // NOLINT(readability-convert-member-functions-to-static) +{ + std::vector 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 -- cgit v1.2.3 From 86f0162f5556e25ce6a5b9b1327ff7ef2800d5c1 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 03:28:01 +0800 Subject: style: apply consistent code formatting and bracing style across the codebase --- ricochet/include/ricochet/parser/dom_tree.hh | 9 +- ricochet/include/ricochet/parser/html_lexer.hh | 17 ++-- ricochet/include/ricochet/parser/tree_builder.hh | 27 ++--- ricochet/src/core/browser.cc | 50 +++++----- ricochet/src/net/http_client.cc | 13 +-- ricochet/src/parser/html_lexer.cc | 122 ++++++++++++----------- ricochet/src/parser/tree_builder.cc | 87 ++++++++-------- 7 files changed, 162 insertions(+), 163 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/parser/dom_tree.hh b/ricochet/include/ricochet/parser/dom_tree.hh index b1e4812..e3b3664 100644 --- a/ricochet/include/ricochet/parser/dom_tree.hh +++ b/ricochet/include/ricochet/parser/dom_tree.hh @@ -26,10 +26,11 @@ 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 children{}; // Recursive child nodes list +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 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 index 2cfdda9..d9ce23a 100644 --- a/ricochet/include/ricochet/parser/html_lexer.hh +++ b/ricochet/include/ricochet/parser/html_lexer.hh @@ -25,16 +25,19 @@ namespace ricochet::parser { -struct TextToken { - std::string content{}; +struct TextToken +{ + std::string content {}; }; -struct TagOpenToken { - std::string name{}; +struct TagOpenToken +{ + std::string name {}; }; -struct TagCloseToken { - std::string name{}; +struct TagCloseToken +{ + std::string name {}; }; using HtmlToken = std::variant; @@ -53,7 +56,7 @@ public: /** * @brief Perform lexical analysis to convert HTML source code into a structured sequence of tokens */ - std::vector tokenize(std::string_view html) const; + std::vector tokenize( std::string_view html ) const; private: }; diff --git a/ricochet/include/ricochet/parser/tree_builder.hh b/ricochet/include/ricochet/parser/tree_builder.hh index 4adbc4d..2cb0748 100644 --- a/ricochet/include/ricochet/parser/tree_builder.hh +++ b/ricochet/include/ricochet/parser/tree_builder.hh @@ -25,20 +25,21 @@ namespace ricochet::parser { -class TreeBuilder { +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& tokens) const; + 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& tokens ) const; }; } // namespace ricochet::parser \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 7acc915..e4c4c60 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -23,18 +23,18 @@ #include namespace { -void print_dom_tree(const ricochet::parser::DomNode& node, std::size_t depth = 0) // NOLINT(misc-no-recursion) +void print_dom_tree( const ricochet::parser::DomNode& node, std::size_t depth = 0 ) // NOLINT(misc-no-recursion) { - const std::string indent(depth * 2, ' '); - - if (node.tag_name.empty()) { - std::cout << indent << "\"" << node.text_content << "\"\n"; - } else { - std::cout << indent << "<" << node.tag_name << ">\n"; - for (const auto& child : node.children) { - print_dom_tree(child, depth + 1); - } + const std::string indent( depth * 2, ' ' ); + + if ( node.tag_name.empty() ) { + std::cout << indent << "\"" << node.text_content << "\"\n"; + } else { + std::cout << indent << "<" << node.tag_name << ">\n"; + for ( const auto& child : node.children ) { + print_dom_tree( child, depth + 1 ); } + } } } // namespace namespace ricochet::core { @@ -43,26 +43,26 @@ int Browser::run( std::string_view initial_url ) { std::cout << "-> Fetching: " << initial_url << "...\n"; - const net::HttpClient client; - auto response_result = client.fetch(initial_url); + const net::HttpClient client; + auto response_result = client.fetch( initial_url ); - if (!response_result.has_value()) { - std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; - return 1; - } + if ( !response_result.has_value() ) { + std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; + return 1; + } + + const auto& response = response_result.value(); - const auto& response = response_result.value(); - - const parser::HtmlLexer lexer; - const auto tokens = lexer.tokenize(response.body); + const parser::HtmlLexer lexer; + const auto tokens = lexer.tokenize( response.body ); - const parser::TreeBuilder builder; - const auto dom_root = builder.build(tokens); + const parser::TreeBuilder builder; + const auto dom_root = builder.build( tokens ); - std::cout << "\n[=== Abstract Syntax Tree (DOM) ===]\n"; - print_dom_tree(dom_root); + std::cout << "\n[=== Abstract Syntax Tree (DOM) ===]\n"; + print_dom_tree( dom_root ); - return 0; + return 0; } } // namespace ricochet::core diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 256c1f2..d45de40 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -59,7 +59,8 @@ std::expected parse_url( std::string_view url ) } } // namespace -std::expected HttpClient::fetch( std::string_view url ) const // NOLINT(readability-convert-member-functions-to-static) +std::expected HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) + std::string_view url ) const { auto parsed_url = parse_url( url ); if ( !parsed_url.has_value() ) { @@ -75,11 +76,11 @@ std::expected HttpClient::fetch( std::string_view url socket.connect( server_addr ); const std::string request = std::format( "GET {} HTTP/1.1\r\n" - "Host: {}\r\n" - "User-Agent: Ricochet/0.1.0\r\n" - "Connection: close\r\n\r\n", - path, - host ); + "Host: {}\r\n" + "User-Agent: Ricochet/0.1.0\r\n" + "Connection: close\r\n\r\n", + path, + host ); size_t bytes_written = 0; while ( bytes_written < request.size() ) { diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc index bbf8c57..73591b3 100644 --- a/ricochet/src/parser/html_lexer.cc +++ b/ricochet/src/parser/html_lexer.cc @@ -21,99 +21,101 @@ #include #include - 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 '>') +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& tokens) { - if (buffer.empty()) { - return; // 提前返回,减少 if 嵌套层级 - } +void emit_text( std::string& buffer, std::vector& tokens ) +{ + if ( buffer.empty() ) { + return; // 提前返回,减少 if 嵌套层级 + } - bool all_whitespace = true; - for (const char c : buffer) { - if (!std::isspace(static_cast(c))) { - all_whitespace = false; - break; - } - } - - if (!all_whitespace) { - tokens.emplace_back(TextToken{buffer}); + bool all_whitespace = true; + for ( const char c : buffer ) { + if ( !std::isspace( static_cast( c ) ) ) { + all_whitespace = false; + break; } - buffer.clear(); + } + + if ( !all_whitespace ) { + tokens.emplace_back( TextToken { buffer } ); + } + buffer.clear(); } } // namespace -std::vector HtmlLexer::tokenize( std::string_view html ) const // NOLINT(readability-convert-member-functions-to-static) +std::vector HtmlLexer::tokenize( // NOLINT(readability-convert-member-functions-to-static) + std::string_view html ) const { std::vector tokens; LexerState state = LexerState::Data; - std::string buffer{}; + std::string buffer {}; - for (const char c : html) { - switch (state) { - case LexerState::Data: - if (c == '<') { - emit_text(buffer, tokens); - state = LexerState::TagOpen; + for ( const char c : html ) { + switch ( state ) { + case LexerState::Data: + if ( c == '<' ) { + emit_text( buffer, tokens ); + state = LexerState::TagOpen; } else { - buffer += c; + buffer += c; } break; - case LexerState::TagOpen: - if (c == '/') { - state = LexerState::ClosingTagName; - } else if (std::isalpha(static_cast(c))) { - buffer.push_back(c); - state = LexerState::TagName; + case LexerState::TagOpen: + if ( c == '/' ) { + state = LexerState::ClosingTagName; + } else if ( std::isalpha( static_cast( c ) ) ) { + buffer.push_back( c ); + state = LexerState::TagName; } else { - buffer.push_back('<'); - buffer.push_back(c); - state = LexerState::Data; + 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 if (std::isspace(static_cast(c))) { - tokens.emplace_back(TagOpenToken{buffer}); - buffer.clear(); - state = LexerState::SwallowAttributes; + case LexerState::TagName: + if ( c == '>' ) { + tokens.emplace_back( TagOpenToken { buffer } ); + buffer.clear(); + state = LexerState::Data; + } else if ( std::isspace( static_cast( c ) ) ) { + tokens.emplace_back( TagOpenToken { buffer } ); + buffer.clear(); + state = LexerState::SwallowAttributes; } else { - buffer += static_cast(std::tolower(static_cast(c))); + buffer += static_cast( std::tolower( static_cast( c ) ) ); } break; - case LexerState::ClosingTagName: - if (c == '>') { - tokens.emplace_back(TagCloseToken{buffer}); - buffer.clear(); - state = LexerState::Data; + case LexerState::ClosingTagName: + if ( c == '>' ) { + tokens.emplace_back( TagCloseToken { buffer } ); + buffer.clear(); + state = LexerState::Data; } else { - buffer += static_cast(std::tolower(static_cast(c))); + buffer += static_cast( std::tolower( static_cast( c ) ) ); } break; - case LexerState::SwallowAttributes: - if (c == '>') { - state = LexerState::Data; + case LexerState::SwallowAttributes: + if ( c == '>' ) { + state = LexerState::Data; } break; } } - emit_text(buffer, tokens); + emit_text( buffer, tokens ); return tokens; } diff --git a/ricochet/src/parser/tree_builder.cc b/ricochet/src/parser/tree_builder.cc index 51a8a51..4f7f87e 100644 --- a/ricochet/src/parser/tree_builder.cc +++ b/ricochet/src/parser/tree_builder.cc @@ -24,67 +24,58 @@ namespace ricochet::parser { namespace { -struct TokenVisitor { - std::vector* stack; +struct TokenVisitor +{ + std::vector* stack; - void operator()(const TextToken& token) const { - stack->back().children.emplace_back( - DomNode{ - .tag_name = "", - .text_content = token.content, - .children = {} - } - ); - } + 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 + { + DomNode new_node { .tag_name = token.name, .text_content = "", .children = {} }; - void operator()(const TagOpenToken& token) const { - DomNode new_node{ - .tag_name = token.name, - .text_content = "", - .children = {} - }; + if ( token.name == "meta" || token.name == "link" || token.name == "img" || token.name == "br" + || token.name == "hr" || token.name == "input" || token.name == "base" || token.name == "col" ) { - if (token.name == "meta" || token.name == "link" || token.name == "img" || - token.name == "br" || token.name == "hr" || token.name == "input" || - token.name == "base" || token.name == "col") { - - stack->back().children.push_back(std::move(new_node)); - } else { - stack->push_back(std::move(new_node)); - } + 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)); - } + 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(const std::vector& tokens) const // NOLINT(readability-convert-member-functions-to-static) +DomNode TreeBuilder::build( // NOLINT(readability-convert-member-functions-to-static) + const std::vector& tokens ) const { - std::vector stack; - stack.emplace_back(DomNode{ - .tag_name = "Document", - .text_content = "", - .children = {} - }); + std::vector stack; + stack.emplace_back( DomNode { .tag_name = "Document", .text_content = "", .children = {} } ); - TokenVisitor visitor{&stack}; - for (const auto& token : tokens) { - std::visit(visitor, token); - } + 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)); - } + 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()); + return std::move( stack.front() ); } } // namespace ricochet::parser \ No newline at end of file -- cgit v1.2.3 From 85c1e247ce50ea1fa1ba12d524664063eac737a1 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 03:40:54 +0800 Subject: feat: implement terminal renderer and integrate into browser core --- ricochet/include/ricochet/render/renderer.hh | 43 ++++++++++++++++++ ricochet/src/core/browser.cc | 35 ++++++++------- ricochet/src/net/http_client.cc | 3 +- ricochet/src/render/renderer.cc | 65 ++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 ricochet/include/ricochet/render/renderer.hh create mode 100644 ricochet/src/render/renderer.cc (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh new file mode 100644 index 0000000..8a5c463 --- /dev/null +++ b/ricochet/include/ricochet/render/renderer.hh @@ -0,0 +1,43 @@ +/* + * 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 . + */ + +#pragma once + +#include "ricochet/parser/dom_tree.hh" + +namespace ricochet::render { + +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 + */ + void render( const parser::DomNode& node ) const; +}; + +} // namespace ricochet::render \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index e4c4c60..31f4df0 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -20,23 +20,24 @@ #include "ricochet/net/http_client.hh" #include "ricochet/parser/html_lexer.hh" #include "ricochet/parser/tree_builder.hh" +#include "ricochet/render/renderer.hh" #include -namespace { -void print_dom_tree( const ricochet::parser::DomNode& node, std::size_t depth = 0 ) // NOLINT(misc-no-recursion) -{ - const std::string indent( depth * 2, ' ' ); +// namespace { +// void print_dom_tree( const ricochet::parser::DomNode& node, std::size_t depth = 0 ) // NOLINT(misc-no-recursion) +// { +// const std::string indent( depth * 2, ' ' ); - if ( node.tag_name.empty() ) { - std::cout << indent << "\"" << node.text_content << "\"\n"; - } else { - std::cout << indent << "<" << node.tag_name << ">\n"; - for ( const auto& child : node.children ) { - print_dom_tree( child, depth + 1 ); - } - } -} -} // namespace +// if ( node.tag_name.empty() ) { +// std::cout << indent << "\"" << node.text_content << "\"\n"; +// } else { +// std::cout << indent << "<" << node.tag_name << ">\n"; +// for ( const auto& child : node.children ) { +// print_dom_tree( child, depth + 1 ); +// } +// } +// } +// } // namespace namespace ricochet::core { int Browser::run( std::string_view initial_url ) @@ -59,8 +60,10 @@ int Browser::run( std::string_view initial_url ) const parser::TreeBuilder builder; const auto dom_root = builder.build( tokens ); - std::cout << "\n[=== Abstract Syntax Tree (DOM) ===]\n"; - print_dom_tree( dom_root ); + // std::cout << "\033[2J\033[1;1H"; + + const render::Renderer renderer; + renderer.render( dom_root ); return 0; } diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index d45de40..b31073c 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -59,7 +59,8 @@ std::expected parse_url( std::string_view url ) } } // namespace -std::expected HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) +std::expected +HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) std::string_view url ) const { auto parsed_url = parse_url( url ); diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc new file mode 100644 index 0000000..81c2be2 --- /dev/null +++ b/ricochet/src/render/renderer.cc @@ -0,0 +1,65 @@ +/* + * 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 . + */ + +#include "ricochet/render/renderer.hh" +#include + +namespace ricochet::render { + +namespace { + +void render_node( const parser::DomNode& node ) // NOLINT(misc-no-recursion) +{ + if ( node.tag_name == "head" || node.tag_name == "style" || node.tag_name == "script" || node.tag_name == "meta" + || node.tag_name == "title" || node.tag_name == "link" ) { + return; + } + + if ( node.tag_name.empty() ) { + std::cout << node.text_content; + return; + } + if ( node.tag_name == "h1" ) { + std::cout << "\n\n\033[1;31m"; + } else if ( node.tag_name == "a" ) { + std::cout << "\033[4;34m"; + } else if ( node.tag_name == "p" || node.tag_name == "div" ) { + std::cout << "\n"; + } + + for ( const auto& child : node.children ) { + render_node( child ); + } + if ( node.tag_name == "h1" || node.tag_name == "a" ) { + std::cout << "\033[0m"; + } + + if ( node.tag_name == "h1" || node.tag_name == "p" || node.tag_name == "div" ) { + std::cout << "\n"; + } +} + +} // namespace + +void Renderer::render( const parser::DomNode& node ) const // NOLINT(readability-convert-member-functions-to-static) +{ + render_node( node ); + std::cout << "\n"; +} + +} // namespace ricochet::render \ No newline at end of file -- cgit v1.2.3 From d74dd98e1a28ea4adf056077a3d212c8f0ca5fbe Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 04:12:56 +0800 Subject: feat: implement interactive TUI with scrolling and add OpenSSL dependency support --- ricochet/include/ricochet/render/renderer.hh | 2 +- ricochet/include/ricochet/tui/terminal.hh | 45 +++++++++++++++++++ ricochet/src/CMakeLists.txt | 9 ++++ ricochet/src/core/browser.cc | 65 +++++++++++++++++++++------- ricochet/src/render/renderer.cc | 21 ++++----- ricochet/src/tui/terminal.cc | 62 ++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 ricochet/include/ricochet/tui/terminal.hh create mode 100644 ricochet/src/tui/terminal.cc (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index 8a5c463..f26a788 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -37,7 +37,7 @@ public: * @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 */ - void render( const parser::DomNode& node ) const; + std::string render( const parser::DomNode& node ) const; }; } // namespace ricochet::render \ No newline at end of file diff --git a/ricochet/include/ricochet/tui/terminal.hh b/ricochet/include/ricochet/tui/terminal.hh new file mode 100644 index 0000000..5388845 --- /dev/null +++ b/ricochet/include/ricochet/tui/terminal.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 . + */ + +#pragma once + +#include + +namespace ricochet::tui { + +class Terminal { +public: + Terminal(); + ~Terminal(); + + Terminal(const Terminal&) = delete; + Terminal& operator=(const Terminal&) = delete; + Terminal(Terminal&&) = delete; + Terminal& operator=(Terminal&&) = delete; + + // Read a single key + char read_key() const; + + // Clear screen and move cursor to top-left + void clear_screen() const; + +private: + struct termios original_termios_{}; // Save terminal original state +}; + +} // namespace ricochet::tui \ No newline at end of file diff --git a/ricochet/src/CMakeLists.txt b/ricochet/src/CMakeLists.txt index 9ddc717..dcc8f49 100644 --- a/ricochet/src/CMakeLists.txt +++ b/ricochet/src/CMakeLists.txt @@ -3,8 +3,17 @@ file(GLOB_RECURSE ALL_SOURCES CONFIGURE_DEPENDS "*.cc") set(MAIN_SRC "${CMAKE_CURRENT_SOURCE_DIR}/main.cc") list(REMOVE_ITEM ALL_SOURCES ${MAIN_SRC}) +find_package(OpenSSL REQUIRED) +message(STATUS "Found OpenSSL Version: ${OPENSSL_VERSION}") +message(STATUS "OpenSSL Include dir: ${OPENSSL_INCLUDE_DIR}") + add_library(ricochet_core STATIC ${ALL_SOURCES}) 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 index 31f4df0..0636de6 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -21,23 +21,11 @@ #include "ricochet/parser/html_lexer.hh" #include "ricochet/parser/tree_builder.hh" #include "ricochet/render/renderer.hh" +#include "ricochet/tui/terminal.hh" +#include #include +#include -// namespace { -// void print_dom_tree( const ricochet::parser::DomNode& node, std::size_t depth = 0 ) // NOLINT(misc-no-recursion) -// { -// const std::string indent( depth * 2, ' ' ); - -// if ( node.tag_name.empty() ) { -// std::cout << indent << "\"" << node.text_content << "\"\n"; -// } else { -// std::cout << indent << "<" << node.tag_name << ">\n"; -// for ( const auto& child : node.children ) { -// print_dom_tree( child, depth + 1 ); -// } -// } -// } -// } // namespace namespace ricochet::core { int Browser::run( std::string_view initial_url ) @@ -63,8 +51,53 @@ int Browser::run( std::string_view initial_url ) // std::cout << "\033[2J\033[1;1H"; const render::Renderer renderer; - renderer.render( dom_root ); + const std::string rendered_text = renderer.render( dom_root ); + std::vector lines; + std::stringstream ss(rendered_text); + std::string line; + while (std::getline(ss, line, '\n')) { + lines.push_back(line); + } + + // TUI Event Loop + const tui::Terminal terminal; + + std::size_t scroll_y = 0; + const std::size_t screen_height = 24; + + while (true) { + terminal.clear_screen(); + + for (std::size_t i = 0; i < screen_height - 1; ++i) { + const std::size_t line_idx = scroll_y + i; + if (line_idx < lines.size()) { + std::cout << lines[line_idx] << "\r\n"; + } else { + std::cout << "~\r\n"; + } + } + + std::cout << "\033[7m" << " URL: " << initial_url + << " | [j] Down [k] Up [q] Quit " << "\033[0m"; + std::cout.flush(); + + const char c = terminal.read_key(); + if (c == 'q') { + break; // Break loop, terminal destructor will restore terminal + } + if (c == 'j') { + if (scroll_y + screen_height - 1 < lines.size()) { + scroll_y++; // Scroll down + } + } else if (c == 'k') { + if (scroll_y > 0) { + scroll_y--; // Scroll up + } + } + } + + terminal.clear_screen(); return 0; } diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 81c2be2..0a91d49 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -23,7 +23,7 @@ namespace ricochet::render { namespace { -void render_node( const parser::DomNode& node ) // NOLINT(misc-no-recursion) +void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(misc-no-recursion) { if ( node.tag_name == "head" || node.tag_name == "style" || node.tag_name == "script" || node.tag_name == "meta" || node.tag_name == "title" || node.tag_name == "link" ) { @@ -31,35 +31,36 @@ void render_node( const parser::DomNode& node ) // NOLINT(misc-no-recursion) } if ( node.tag_name.empty() ) { - std::cout << node.text_content; + output += node.text_content; return; } if ( node.tag_name == "h1" ) { - std::cout << "\n\n\033[1;31m"; + output += "\n\n\033[1;31m"; } else if ( node.tag_name == "a" ) { std::cout << "\033[4;34m"; } else if ( node.tag_name == "p" || node.tag_name == "div" ) { - std::cout << "\n"; + output += "\n"; } for ( const auto& child : node.children ) { - render_node( child ); + render_node( child, output ); } if ( node.tag_name == "h1" || node.tag_name == "a" ) { - std::cout << "\033[0m"; + output += "\033[0m"; } if ( node.tag_name == "h1" || node.tag_name == "p" || node.tag_name == "div" ) { - std::cout << "\n"; + output += "\n"; } } } // namespace -void Renderer::render( const parser::DomNode& node ) const // NOLINT(readability-convert-member-functions-to-static) +std::string Renderer::render( const parser::DomNode& node ) const // NOLINT(readability-convert-member-functions-to-static) { - render_node( node ); - std::cout << "\n"; + std::string output; + render_node( node, output ); + return output; } } // namespace ricochet::render \ No newline at end of file diff --git a/ricochet/src/tui/terminal.cc b/ricochet/src/tui/terminal.cc new file mode 100644 index 0000000..8fdb585 --- /dev/null +++ b/ricochet/src/tui/terminal.cc @@ -0,0 +1,62 @@ +/* + * 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 . + */ + +#include "ricochet/tui/terminal.hh" +#include +#include +#include + +namespace ricochet::tui { + +Terminal::Terminal() { + // get terminal attributes + tcgetattr(STDIN_FILENO, &original_termios_); + + struct termios raw = original_termios_; + + // close echo, close canonical mode + // close terminal signals (ISIG disable Ctrl+C and Ctrl+Z, let the program handle it) + raw.c_lflag &= ~(ECHO | ICANON | ISIG); + + // close software flow control (Ctrl+S, Ctrl+Q) + raw.c_iflag &= ~(IXON | ICRNL); + + // apply new terminal attributes + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +} + +Terminal::~Terminal() { + // RAII: restore terminal attributes when program exits + tcsetattr(STDIN_FILENO, TCSAFLUSH, &original_termios_); +} + +char Terminal::read_key() const { // NOLINT(readability-convert-member-functions-to-static) + char c = '\0'; + // read 1 byte + if (read(STDIN_FILENO, &c, 1) == -1) { + return '\0'; + } + return c; +} + +void Terminal::clear_screen() const { // NOLINT(readability-convert-member-functions-to-static) + // ANSI escape codes: \x1b[2J clear screen, \x1b[H move cursor to (1,1) + std::cout << "\x1b[2J\x1b[H"; +} + +} // namespace ricochet::tui \ No newline at end of file -- cgit v1.2.3 From 12188f045a3f6ccce4359d946167066e3b584939 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 05:36:22 +0800 Subject: feat: implement SSLSocket for encrypted connections and add terminal size detection utility --- ricochet/include/ricochet/net/ssl_socket.hh | 93 +++++++++++++++++++ ricochet/include/ricochet/tui/terminal.hh | 31 ++++--- ricochet/src/core/browser.cc | 32 ++++--- ricochet/src/net/http_client.cc | 135 ++++++++++++++++++++++++---- ricochet/src/net/ssl_socket.cc | 118 ++++++++++++++++++++++++ ricochet/src/render/renderer.cc | 38 ++++++-- ricochet/src/tui/terminal.cc | 71 +++++++++------ 7 files changed, 439 insertions(+), 79 deletions(-) create mode 100644 ricochet/include/ricochet/net/ssl_socket.hh create mode 100644 ricochet/src/net/ssl_socket.cc (limited to 'ricochet/src/core') 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 . + */ + +#pragma once + +#include "sys/address.hh" +#include "sys/socket.hh" +#include +#include +#include + +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; +using UniqueSsl = std::unique_ptr; + +/** + * @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/tui/terminal.hh b/ricochet/include/ricochet/tui/terminal.hh index 5388845..59ca298 100644 --- a/ricochet/include/ricochet/tui/terminal.hh +++ b/ricochet/include/ricochet/tui/terminal.hh @@ -19,27 +19,32 @@ #pragma once #include +#include namespace ricochet::tui { -class Terminal { +class Terminal +{ public: - Terminal(); - ~Terminal(); + Terminal(); + ~Terminal(); - Terminal(const Terminal&) = delete; - Terminal& operator=(const Terminal&) = delete; - Terminal(Terminal&&) = delete; - Terminal& operator=(Terminal&&) = delete; + Terminal( const Terminal& ) = delete; + Terminal& operator=( const Terminal& ) = delete; + Terminal( Terminal&& ) = delete; + Terminal& operator=( Terminal&& ) = delete; - // Read a single key - char read_key() const; - - // Clear screen and move cursor to top-left - void clear_screen() const; + // Read a single key + char read_key() const; + + // Clear screen and move cursor to top-left + void clear_screen() const; + + // Get terminal size + std::pair get_size() const; private: - struct termios original_termios_{}; // Save terminal original state + struct termios original_termios_ {}; // Save terminal original state }; } // namespace ricochet::tui \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 0636de6..650ebcf 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -54,44 +54,48 @@ int Browser::run( std::string_view initial_url ) const std::string rendered_text = renderer.render( dom_root ); std::vector lines; - std::stringstream ss(rendered_text); + std::stringstream ss( rendered_text ); std::string line; - while (std::getline(ss, line, '\n')) { - lines.push_back(line); + while ( std::getline( ss, line, '\n' ) ) { + lines.push_back( line ); } + std::cout << "DEBUG: Status Code: " << response.status_code << "\r\n"; + std::cout << "DEBUG: Raw Body Length: " << response.body.length() << "\r\n"; + // TUI Event Loop const tui::Terminal terminal; std::size_t scroll_y = 0; - const std::size_t screen_height = 24; - while (true) { + while ( true ) { terminal.clear_screen(); - for (std::size_t i = 0; i < screen_height - 1; ++i) { + const auto [term_width, term_height] = terminal.get_size(); + const std::size_t usable_height = ( term_height > 1 ) ? ( term_height - 1 ) : 1; + + for ( std::size_t i = 0; i < usable_height; ++i ) { const std::size_t line_idx = scroll_y + i; - if (line_idx < lines.size()) { + if ( line_idx < lines.size() ) { std::cout << lines[line_idx] << "\r\n"; } else { std::cout << "~\r\n"; } } - std::cout << "\033[7m" << " URL: " << initial_url - << " | [j] Down [k] Up [q] Quit " << "\033[0m"; + std::cout << "\033[7m" << " URL: " << initial_url << " | [j] Down [k] Up [q] Quit " << "\033[0m"; std::cout.flush(); const char c = terminal.read_key(); - if (c == 'q') { + if ( c == 'q' ) { break; // Break loop, terminal destructor will restore terminal } - if (c == 'j') { - if (scroll_y + screen_height - 1 < lines.size()) { + if ( c == 'j' ) { + if ( scroll_y + usable_height < lines.size() ) { scroll_y++; // Scroll down } - } else if (c == 'k') { - if (scroll_y > 0) { + } else if ( c == 'k' ) { + if ( scroll_y > 0 ) { scroll_y--; // Scroll up } } diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index b31073c..b31976f 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -17,6 +17,7 @@ */ #include "ricochet/net/http_client.hh" +#include "ricochet/net/ssl_socket.hh" #include "ricochet/net/sys/address.hh" #include "ricochet/net/sys/socket.hh" @@ -37,13 +38,17 @@ struct ParsedUrl std::expected parse_url( std::string_view url ) { - const std::string_view http_prefix = "http://"; + std::string_view prefix = "http://"; - if ( !url.starts_with( http_prefix ) ) { - return std::unexpected( "Only 'http://' URLs are supported in MVP. (e.g., http://example.com)" ); + 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( http_prefix.length() ); + url.remove_prefix( prefix.length() ); auto slash_pos = url.find( '/' ); ParsedUrl result; @@ -57,6 +62,77 @@ std::expected parse_url( std::string_view url ) return result; } + +std::string dechunk( std::string_view body ) // NOLINT(readability-convert-member-functions-to-static) +{ + std::string result; + std::size_t pos = 0; + while ( pos < body.size() ) { + // find hex length end with \r\n + auto line_end = body.find( "\r\n", pos ); + if ( line_end == std::string_view::npos ) { + break; + } + + // parse hex length + 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; // end of stream + } + + // extract data block (skip \r\n) + pos = line_end + 2; + if ( pos + chunk_size > body.size() ) { + break; + } + result.append( body.substr( pos, chunk_size ) ); + + // skip data block's \r\n + pos += chunk_size + 2; + } + return result; +} + +std::string remove_html_comments( std::string html ) // NOLINT(readability-convert-member-functions-to-static) +{ + std::size_t start_pos = 0; + while ( ( start_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, const std::string& tag_name ) // NOLINT +{ + const std::string open_tag = "<" + tag_name; + const std::string close_tag = ""; + 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 std::expected @@ -69,30 +145,46 @@ HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) } auto [host, path] = parsed_url.value(); + const bool is_https = url.starts_with( "https://" ); + const std::string service = is_https ? "https" : "http"; try { - const Address server_addr( host, "http" ); - - TCPSocket socket; - socket.connect( server_addr ); + const Address server_addr( host, service ); const std::string request = std::format( "GET {} HTTP/1.1\r\n" "Host: {}\r\n" - "User-Agent: Ricochet/0.1.0\r\n" + "User-Agent: Mozilla/5.0 (Ricochet/0.1.0)\r\n" + "Accept: text/html\r\n" + "Accept-Encoding: identity\r\n" "Connection: close\r\n\r\n", path, host ); - size_t bytes_written = 0; - while ( bytes_written < request.size() ) { - bytes_written += socket.write( request.substr( bytes_written ) ); - } - std::string raw_response; - while ( !socket.eof() ) { - std::string buffer; - socket.read( buffer ); - raw_response += buffer; + if ( is_https ) { + SSLSocket ssl_socket; + ssl_socket.connect( server_addr, host ); + size_t bytes_written = 0; + while ( bytes_written < request.size() ) { + bytes_written += ssl_socket.write( request.substr( bytes_written ) ); + } + while ( !ssl_socket.eof() ) { + std::string buffer; + ssl_socket.read( buffer ); + raw_response += buffer; + } + } else { + TCPSocket tcp_socket; + tcp_socket.connect( server_addr ); + size_t bytes_written = 0; + while ( bytes_written < request.size() ) { + bytes_written += tcp_socket.write( request.substr( bytes_written ) ); + } + while ( !tcp_socket.eof() ) { + std::string buffer; + tcp_socket.read( buffer ); + raw_response += buffer; + } } HttpResponse response; @@ -111,6 +203,13 @@ HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) response.body = raw_response; } + if ( raw_response.contains( "Transfer-Encoding: chunked" ) ) { + response.body = dechunk( response.body ); + } + + response.body = remove_html_comments( response.body ); + response.body = remove_tag_blocks( response.body, "script" ); + response.body = remove_tag_blocks( response.body, "style" ); return response; } catch ( const std::exception& e ) { return std::unexpected( std::string( "Network Error: " ) + e.what() ); 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 . + */ + +#include "ricochet/net/ssl_socket.hh" +#include +#include +#include +#include + +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( data.size() ) ); + if ( bytes_written <= 0 ) { + throw std::runtime_error( "SSL write failed" ); + } + return static_cast( bytes_written ); +} + +void SSLSocket::read( std::string& buffer ) +{ + if ( !ssl_ ) { + return; + } + + std::array buf {}; + // Read decrypted data from the encrypted channel + const int bytes_read = SSL_read( ssl_.get(), buf.data(), static_cast( buf.size() ) ); + + if ( bytes_read > 0 ) { + buffer.append( buf.data(), static_cast( 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/render/renderer.cc b/ricochet/src/render/renderer.cc index 0a91d49..2115ee1 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -23,6 +23,25 @@ namespace ricochet::render { namespace { +std::string decode_entities( std::string text ) // NOLINT(readability-convert-member-functions-to-static) +{ + constexpr std::array, 24> 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(); + } + } + return text; +} + void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(misc-no-recursion) { if ( node.tag_name == "head" || node.tag_name == "style" || node.tag_name == "script" || node.tag_name == "meta" @@ -31,21 +50,27 @@ void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(m } if ( node.tag_name.empty() ) { - output += node.text_content; + output += decode_entities( node.text_content ); return; } - if ( node.tag_name == "h1" ) { + + const bool is_header + = ( node.tag_name == "h1" || node.tag_name == "h2" || node.tag_name == "h3" || node.tag_name == "h4" ); + + if ( is_header ) { output += "\n\n\033[1;31m"; } else if ( node.tag_name == "a" ) { std::cout << "\033[4;34m"; - } else if ( node.tag_name == "p" || node.tag_name == "div" ) { - output += "\n"; + } else if ( node.tag_name == "p" || node.tag_name == "div" || is_header ) { + if ( !output.empty() && output.back() != '\n' ) { + output += "\n"; + } } for ( const auto& child : node.children ) { render_node( child, output ); } - if ( node.tag_name == "h1" || node.tag_name == "a" ) { + if ( is_header || node.tag_name == "a" ) { output += "\033[0m"; } @@ -56,7 +81,8 @@ void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(m } // namespace -std::string Renderer::render( const parser::DomNode& node ) const // NOLINT(readability-convert-member-functions-to-static) +std::string Renderer::render( // NOLINT(readability-convert-member-functions-to-static) + const parser::DomNode& node ) const { std::string output; render_node( node, output ); diff --git a/ricochet/src/tui/terminal.cc b/ricochet/src/tui/terminal.cc index 8fdb585..75c6c1f 100644 --- a/ricochet/src/tui/terminal.cc +++ b/ricochet/src/tui/terminal.cc @@ -19,44 +19,59 @@ #include "ricochet/tui/terminal.hh" #include #include +#include #include namespace ricochet::tui { -Terminal::Terminal() { - // get terminal attributes - tcgetattr(STDIN_FILENO, &original_termios_); - - struct termios raw = original_termios_; - - // close echo, close canonical mode - // close terminal signals (ISIG disable Ctrl+C and Ctrl+Z, let the program handle it) - raw.c_lflag &= ~(ECHO | ICANON | ISIG); - - // close software flow control (Ctrl+S, Ctrl+Q) - raw.c_iflag &= ~(IXON | ICRNL); - - // apply new terminal attributes - tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +Terminal::Terminal() +{ + // get terminal attributes + tcgetattr( STDIN_FILENO, &original_termios_ ); + + struct termios raw = original_termios_; + + // close echo, close canonical mode + // close terminal signals (ISIG disable Ctrl+C and Ctrl+Z, let the program handle it) + raw.c_lflag &= ~( ECHO | ICANON | ISIG ); + + // close software flow control (Ctrl+S, Ctrl+Q) + raw.c_iflag &= ~( IXON | ICRNL ); + + // apply new terminal attributes + tcsetattr( STDIN_FILENO, TCSAFLUSH, &raw ); +} + +Terminal::~Terminal() +{ + // RAII: restore terminal attributes when program exits + tcsetattr( STDIN_FILENO, TCSAFLUSH, &original_termios_ ); } -Terminal::~Terminal() { - // RAII: restore terminal attributes when program exits - tcsetattr(STDIN_FILENO, TCSAFLUSH, &original_termios_); +char Terminal::read_key() const // NOLINT(readability-convert-member-functions-to-static) +{ + char c = '\0'; + // read 1 byte + if ( read( STDIN_FILENO, &c, 1 ) == -1 ) { + return '\0'; + } + return c; } -char Terminal::read_key() const { // NOLINT(readability-convert-member-functions-to-static) - char c = '\0'; - // read 1 byte - if (read(STDIN_FILENO, &c, 1) == -1) { - return '\0'; - } - return c; +void Terminal::clear_screen() const // NOLINT(readability-convert-member-functions-to-static) +{ + // ANSI escape codes: \x1b[2J clear screen, \x1b[H move cursor to (1,1) + std::cout << "\x1b[2J\x1b[H"; } -void Terminal::clear_screen() const { // NOLINT(readability-convert-member-functions-to-static) - // ANSI escape codes: \x1b[2J clear screen, \x1b[H move cursor to (1,1) - std::cout << "\x1b[2J\x1b[H"; +std::pair Terminal::get_size() // NOLINT(readability-convert-member-functions-to-static) + const +{ + struct winsize w {}; + if ( ioctl( STDOUT_FILENO, TIOCGWINSZ, &w ) == -1 ) { + return { 80, 24 }; + } + return { static_cast( w.ws_col ), static_cast( w.ws_row ) }; } } // namespace ricochet::tui \ No newline at end of file -- cgit v1.2.3 From 3fbda8903b809e50a8e96acd66b2b80b54e1b6bf Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 06:08:38 +0800 Subject: feat: implement URL navigation and input handling in browser TUI --- ricochet/include/ricochet/tui/terminal.hh | 6 ++ ricochet/src/core/browser.cc | 154 ++++++++++++++++++++---------- ricochet/src/tui/terminal.cc | 12 +++ 3 files changed, 122 insertions(+), 50 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/tui/terminal.hh b/ricochet/include/ricochet/tui/terminal.hh index 59ca298..2942a2a 100644 --- a/ricochet/include/ricochet/tui/terminal.hh +++ b/ricochet/include/ricochet/tui/terminal.hh @@ -43,6 +43,12 @@ public: // Get terminal size std::pair get_size() const; + // Move cursor to a specific position + void move_cursor( std::size_t row, std::size_t col ) const; + + // Show or hide cursor + void show_cursor( bool visible ) const; + private: struct termios original_termios_ {}; // Save terminal original state }; diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 650ebcf..4225986 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -24,79 +24,133 @@ #include "ricochet/tui/terminal.hh" #include #include -#include namespace ricochet::core { -int Browser::run( std::string_view initial_url ) +namespace { +std::vector load_page( std::string_view url ) { - std::cout << "-> Fetching: " << initial_url << "...\n"; - const net::HttpClient client; - auto response_result = client.fetch( initial_url ); - + auto response_result = client.fetch( url ); if ( !response_result.has_value() ) { - std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; - return 1; + return { "[!] Failed to load: " + std::string( url ) }; } - const auto& response = response_result.value(); - const parser::HtmlLexer lexer; - const auto tokens = lexer.tokenize( response.body ); - const parser::TreeBuilder builder; - const auto dom_root = builder.build( tokens ); - - // std::cout << "\033[2J\033[1;1H"; - const render::Renderer renderer; - const std::string rendered_text = renderer.render( dom_root ); + + const auto dom_root = builder.build( lexer.tokenize( response_result.value().body ) ); + const std::string text = renderer.render( dom_root ); std::vector lines; - std::stringstream ss( rendered_text ); - std::string line; - while ( std::getline( ss, line, '\n' ) ) { - lines.push_back( line ); + 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::cout << "DEBUG: Status Code: " << response.status_code << "\r\n"; - std::cout << "DEBUG: Raw Body Length: " << response.body.length() << "\r\n"; - - // TUI Event Loop - const tui::Terminal terminal; +void draw_view( const tui::Terminal& terminal, + const std::vector& lines, + std::size_t scroll_y, + std::string_view url ) +{ + terminal.clear_screen(); + auto [width, height] = terminal.get_size(); + const std::size_t usable_height = ( height > 1 ) ? ( height - 1 ) : 1; + + for ( std::size_t i = 0; i < usable_height; ++i ) { + if ( scroll_y + i < lines.size() ) { + std::cout << lines[scroll_y + i] << "\r\n"; + } else { + std::cout << "~\r\n"; + } + } + std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [q] Quit \033[0m"; + std::cout.flush(); +} - std::size_t scroll_y = 0; +std::string get_url_input( const tui::Terminal& terminal ) +{ + auto [width, height] = terminal.get_size(); + terminal.move_cursor( height, 1 ); + std::cout << "\x1b[2K\033[7m URL: \033[0m "; + std::cout.flush(); + std::string input; + terminal.show_cursor( true ); while ( true ) { - terminal.clear_screen(); - - const auto [term_width, term_height] = terminal.get_size(); - const std::size_t usable_height = ( term_height > 1 ) ? ( term_height - 1 ) : 1; - - for ( std::size_t i = 0; i < usable_height; ++i ) { - const std::size_t line_idx = scroll_y + i; - if ( line_idx < lines.size() ) { - std::cout << lines[line_idx] << "\r\n"; - } else { - std::cout << "~\r\n"; + const char ch = terminal.read_key(); + if ( ch == '\r' || ch == '\n' ) { + break; + } + if ( ch == 27 ) { + input.clear(); + break; + } + if ( ch == 127 || ch == 8 ) { + if ( !input.empty() ) { + input.pop_back(); + std::cout << "\b \b"; + std::cout.flush(); } + } else if ( std::isprint( static_cast( ch ) ) ) { + input += ch; + std::cout << ch; + std::cout.flush(); } + } + terminal.show_cursor( false ); - std::cout << "\033[7m" << " URL: " << initial_url << " | [j] Down [k] Up [q] Quit " << "\033[0m"; - std::cout.flush(); + if ( !input.empty() && !input.starts_with( "http://" ) && !input.starts_with( "https://" ) ) { + input.insert( 0, "https://" ); + } + return input; +} - const char c = terminal.read_key(); - if ( c == 'q' ) { - break; // Break loop, terminal destructor will restore terminal - } - if ( c == 'j' ) { - if ( scroll_y + usable_height < lines.size() ) { - scroll_y++; // Scroll down +} // namespace + +int Browser::run( std::string_view initial_url ) +{ + std::cout << "-> Fetching: " << initial_url << "...\n"; + + std::string current_url { initial_url }; + const tui::Terminal terminal; + terminal.clear_screen(); + + while ( !current_url.empty() ) { + auto lines = load_page( current_url ); + std::size_t scroll_y = 0; + bool navigate = false; + + while ( !navigate ) { + draw_view( terminal, lines, scroll_y, current_url ); + + const char c = terminal.read_key(); + auto [width, height] = terminal.get_size(); + const std::size_t view_h = ( height > 1 ) ? ( height - 1 ) : 1; + + if ( c == 'q' ) { + terminal.clear_screen(); + return 0; } - } else if ( c == 'k' ) { - if ( scroll_y > 0 ) { - scroll_y--; // Scroll up + if ( c == 'j' && scroll_y + view_h < lines.size() ) { + scroll_y++; + } else if ( c == 'k' && scroll_y > 0 ) { + scroll_y--; + } else if ( c == '/' ) { + std::string next_url = get_url_input( terminal ); + if ( !next_url.empty() ) { + current_url = std::move( next_url ); + navigate = true; + } } } } diff --git a/ricochet/src/tui/terminal.cc b/ricochet/src/tui/terminal.cc index 75c6c1f..9e78a02 100644 --- a/ricochet/src/tui/terminal.cc +++ b/ricochet/src/tui/terminal.cc @@ -74,4 +74,16 @@ std::pair Terminal::get_size() // NOLINT(readability-c return { static_cast( w.ws_col ), static_cast( w.ws_row ) }; } +void Terminal::move_cursor( std::size_t row, std::size_t col ) const // NOLINT +{ + // ANSI: \x1b[H move to 1,1; \x1b[row;colH move to specified row and column + std::cout << "\x1b[" << row << ";" << col << "H"; +} + +void Terminal::show_cursor( bool visible ) const // NOLINT +{ + // \x1b[?25h show cursor, \x1b[?25l hide cursor + std::cout << ( visible ? "\x1b[?25h" : "\x1b[?25l" ); +} + } // namespace ricochet::tui \ No newline at end of file -- cgit v1.2.3 From e81d870756bccbb2b5da52673d52a6bfd93f7b3d Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 04:22:01 +0800 Subject: feat: implement link extraction and interactive navigation via numbered tags in the browser --- ricochet/include/ricochet/render/renderer.hh | 8 ++- ricochet/src/core/browser.cc | 104 ++++++++++++++++++++++++--- ricochet/src/parser/html_lexer.cc | 6 +- ricochet/src/parser/tree_builder.cc | 8 ++- ricochet/src/render/renderer.cc | 69 +++++++++++++----- 5 files changed, 160 insertions(+), 35 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index f26a788..2a9acae 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -22,6 +22,12 @@ namespace ricochet::render { +struct RenderResult +{ + std::string text {}; + std::vector links {}; +}; + class Renderer { public: @@ -37,7 +43,7 @@ public: * @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 */ - std::string render( const parser::DomNode& node ) const; + RenderResult render( const parser::DomNode& node ) const; }; } // namespace ricochet::render \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 4225986..8bd921c 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -23,17 +23,24 @@ #include "ricochet/render/renderer.hh" #include "ricochet/tui/terminal.hh" #include +#include #include namespace ricochet::core { +struct PageData +{ + std::vector lines; + std::vector links; +}; + namespace { -std::vector load_page( std::string_view url ) +PageData load_page( std::string_view url ) { const net::HttpClient client; auto response_result = client.fetch( url ); if ( !response_result.has_value() ) { - return { "[!] Failed to load: " + std::string( url ) }; + return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {} }; } const parser::HtmlLexer lexer; @@ -41,20 +48,20 @@ std::vector load_page( std::string_view url ) const render::Renderer renderer; const auto dom_root = builder.build( lexer.tokenize( response_result.value().body ) ); - const std::string text = renderer.render( dom_root ); + const render::RenderResult result = renderer.render( dom_root ); std::vector lines; std::size_t start = 0; - while ( start < text.length() ) { - const std::size_t end = text.find( '\n', start ); + while ( start < result.text.length() ) { + const std::size_t end = result.text.find( '\n', start ); if ( end == std::string::npos ) { - lines.push_back( text.substr( start ) ); + lines.push_back( result.text.substr( start ) ); break; } - lines.push_back( text.substr( start, end - start ) ); + lines.push_back( result.text.substr( start, end - start ) ); start = end + 1; } - return lines; + return { .lines = lines, .links = result.links }; } void draw_view( const tui::Terminal& terminal, @@ -73,7 +80,7 @@ void draw_view( const tui::Terminal& terminal, std::cout << "~\r\n"; } } - std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [q] Quit \033[0m"; + std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [f] Follow [q] Quit \033[0m"; std::cout.flush(); } @@ -115,6 +122,79 @@ std::string get_url_input( const tui::Terminal& terminal ) return input; } +std::string get_link_input( const tui::Terminal& terminal ) +{ + auto [width, height] = terminal.get_size(); + terminal.move_cursor( height, 1 ); + std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; + std::cout.flush(); + + std::string num_input; + terminal.show_cursor( true ); + while ( true ) { + const char ch = terminal.read_key(); + if ( ch == '\r' || ch == '\n' ) { + break; + } + if ( ch == 27 ) { + num_input.clear(); + break; + } + if ( ch == 127 || ch == 8 ) { + if ( !num_input.empty() ) { + num_input.pop_back(); + std::cout << "\b \b"; + std::cout.flush(); + } + } else if ( std::isdigit( static_cast( ch ) ) ) { + num_input += ch; + std::cout << ch; + std::cout.flush(); + } + } + terminal.show_cursor( false ); + return num_input; +} + +void handle_follow_link( const tui::Terminal& terminal, + const std::vector& links, + std::string& current_url, + bool& navigate ) +{ + const std::string num_input = get_link_input( terminal ); + if ( !num_input.empty() ) { + try { + const std::size_t index = std::stoul( num_input ); + if ( index > 0 && index <= links.size() ) { + std::string target = links[index - 1]; + + if ( target.starts_with( "/" ) ) { + if ( target.starts_with( "//" ) ) { + target = "https:" + target; // "//example.com" + } else { + const std::size_t slash_pos = current_url.find( '/', 8 ); // 跳过 https:// + const std::string domain + = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos ); + target = domain + target; // "/about" -> "https://gnu.org/about" + } + } else if ( !target.starts_with( "http" ) ) { + const std::size_t last_slash = current_url.rfind( '/' ); + if ( last_slash > 7 ) { + target = current_url.substr( 0, last_slash + 1 ) + target; + } else { + target = current_url + "/" + target; + } + } + + current_url = target; + navigate = true; + } + } catch ( const std::exception& e ) { + (void)e; // NOLINT + } + } +} + } // namespace int Browser::run( std::string_view initial_url ) @@ -126,7 +206,9 @@ int Browser::run( std::string_view initial_url ) terminal.clear_screen(); while ( !current_url.empty() ) { - auto lines = load_page( current_url ); + auto page_data = load_page( current_url ); + const auto& lines = page_data.lines; + const auto& links = page_data.links; std::size_t scroll_y = 0; bool navigate = false; @@ -151,6 +233,8 @@ int Browser::run( std::string_view initial_url ) current_url = std::move( next_url ); navigate = true; } + } else if ( c == 'f' ) { + handle_follow_link( terminal, links, current_url, navigate ); } } } diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc index 73591b3..2aa05b1 100644 --- a/ricochet/src/parser/html_lexer.cc +++ b/ricochet/src/parser/html_lexer.cc @@ -90,12 +90,8 @@ std::vector HtmlLexer::tokenize( // NOLINT(readability-convert-member tokens.emplace_back( TagOpenToken { buffer } ); buffer.clear(); state = LexerState::Data; - } else if ( std::isspace( static_cast( c ) ) ) { - tokens.emplace_back( TagOpenToken { buffer } ); - buffer.clear(); - state = LexerState::SwallowAttributes; } else { - buffer += static_cast( std::tolower( static_cast( c ) ) ); + buffer += c; } break; case LexerState::ClosingTagName: diff --git a/ricochet/src/parser/tree_builder.cc b/ricochet/src/parser/tree_builder.cc index 4f7f87e..64f841b 100644 --- a/ricochet/src/parser/tree_builder.cc +++ b/ricochet/src/parser/tree_builder.cc @@ -36,10 +36,14 @@ struct TokenVisitor 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 ( token.name == "meta" || token.name == "link" || token.name == "img" || token.name == "br" - || token.name == "hr" || token.name == "input" || token.name == "base" || token.name == "col" ) { + 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 { diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 2115ee1..1af40f1 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -17,7 +17,7 @@ */ #include "ricochet/render/renderer.hh" -#include +#include namespace ricochet::render { @@ -42,51 +42,86 @@ std::string decode_entities( std::string text ) // NOLINT(readability-convert-me return text; } -void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(misc-no-recursion) +std::string extract_href( const std::string& tag_name ) { - if ( node.tag_name == "head" || node.tag_name == "style" || node.tag_name == "script" || node.tag_name == "meta" - || node.tag_name == "title" || node.tag_name == "link" ) { + std::size_t pos = tag_name.find( "href=\"" ); + if ( pos != std::string::npos ) { + pos += 6; + const std::size_t end = tag_name.find( '\"', pos ); + if ( end != std::string::npos ) { + return tag_name.substr( pos, end - pos ); + } + } + pos = tag_name.find( "href='" ); + if ( pos != std::string::npos ) { + pos += 6; + const std::size_t end = tag_name.find( '\'', pos ); + if ( end != std::string::npos ) { + return tag_name.substr( pos, end - pos ); + } + } + return ""; +} + +void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) + std::string& output, + std::vector& links ) +{ + 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 == "head" || base_tag == "style" || base_tag == "script" || base_tag == "meta" + || base_tag == "title" || base_tag == "link" ) { return; } - if ( node.tag_name.empty() ) { + if ( base_tag.empty() ) { output += decode_entities( node.text_content ); return; } - const bool is_header - = ( node.tag_name == "h1" || node.tag_name == "h2" || node.tag_name == "h3" || node.tag_name == "h4" ); + const bool is_header = ( base_tag == "h1" || base_tag == "h2" || base_tag == "h3" || base_tag == "h4" ); + const bool is_link = ( base_tag == "a" ); if ( is_header ) { output += "\n\n\033[1;31m"; - } else if ( node.tag_name == "a" ) { - std::cout << "\033[4;34m"; - } else if ( node.tag_name == "p" || node.tag_name == "div" || is_header ) { + } else if ( is_link ) { + output += "\033[4;34m"; // 【核心修复】:必须是 output += ,绝对不能用 std::cout + } 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, output ); + render_node( child, output, links ); } - if ( is_header || node.tag_name == "a" ) { + + if ( is_header ) { + output += "\033[0m"; + } else if ( is_link ) { output += "\033[0m"; + const std::string href = extract_href( node.tag_name ); + if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) { + links.push_back( href ); + output += " \033[7m[" + std::to_string( links.size() ) + "]\033[0m"; // 打印数字标签! + } } - if ( node.tag_name == "h1" || node.tag_name == "p" || node.tag_name == "div" ) { + if ( base_tag == "h1" || base_tag == "p" || base_tag == "div" ) { output += "\n"; } } } // namespace -std::string Renderer::render( // NOLINT(readability-convert-member-functions-to-static) +RenderResult Renderer::render( // NOLINT(readability-convert-member-functions-to-static) const parser::DomNode& node ) const { - std::string output; - render_node( node, output ); - return output; + RenderResult result; + render_node( node, result.text, result.links ); + return result; } } // namespace ricochet::render \ No newline at end of file -- cgit v1.2.3 From 79ab6fcb6e74109c46e836594b2cc1ead87d7b30 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 16:43:23 +0800 Subject: feat: implement HTTP redirect support, URL search formatting, image alt text rendering, and improved terminal cursor management --- ricochet/src/core/browser.cc | 131 +++++++++++++++++++++++++++++++--------- ricochet/src/main.cc | 7 ++- ricochet/src/net/http_client.cc | 126 +++++++++++++++++++++++++++----------- ricochet/src/render/renderer.cc | 16 ++++- ricochet/src/tui/terminal.cc | 1 + 5 files changed, 214 insertions(+), 67 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 8bd921c..2968ab1 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -80,10 +80,34 @@ void draw_view( const tui::Terminal& terminal, std::cout << "~\r\n"; } } - std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [f] Follow [q] Quit \033[0m"; + std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [f] Follow [H/L] History [q] Quit \033[0m"; std::cout.flush(); } +std::string 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 get_url_input( const tui::Terminal& terminal ) { auto [width, height] = terminal.get_size(); @@ -116,10 +140,7 @@ std::string get_url_input( const tui::Terminal& terminal ) } terminal.show_cursor( false ); - if ( !input.empty() && !input.starts_with( "http://" ) && !input.starts_with( "https://" ) ) { - input.insert( 0, "https://" ); - } - return input; + return format_url( std::move( input ) ); } std::string get_link_input( const tui::Terminal& terminal ) @@ -195,50 +216,100 @@ void handle_follow_link( const tui::Terminal& terminal, } } +enum class InputAction : std::uint8_t +{ + None, + Quit, + Navigate +}; + +InputAction process_key( char c, + const tui::Terminal& terminal, + const PageData& page_data, + std::string& current_url, + std::vector& history, + std::size_t& history_idx, // NOLINT(bugprone-easily-swappable-parameters) + std::size_t& scroll_y ) // NOLINT(bugprone-easily-swappable-parameters) +{ + auto [width, height] = terminal.get_size(); + const std::size_t view_h = ( height > 1 ) ? ( height - 1 ) : 1; + + if ( c == 'q' ) { + return InputAction::Quit; + } + if ( c == 'j' && scroll_y + view_h < page_data.lines.size() ) { + scroll_y++; + } + if ( c == 'k' && scroll_y > 0 ) { + scroll_y--; + } + if ( c == 'H' && history_idx > 0 ) { + history_idx--; + return InputAction::Navigate; + } + if ( c == 'L' && history_idx + 1 < history.size() ) { + history_idx++; + return InputAction::Navigate; + } + + if ( c == '/' ) { + const std::string next_url = get_url_input( terminal ); + if ( !next_url.empty() ) { + history.resize( history_idx + 1 ); + history.push_back( next_url ); + history_idx++; + return InputAction::Navigate; + } + } + + if ( c == 'f' ) { + const std::string old_url = current_url; + bool nav = false; + handle_follow_link( terminal, page_data.links, current_url, nav ); + if ( nav && current_url != old_url ) { + history.resize( history_idx + 1 ); + history.push_back( current_url ); + history_idx++; + return InputAction::Navigate; + } + } + return InputAction::None; +} + } // namespace int Browser::run( std::string_view initial_url ) { - std::cout << "-> Fetching: " << initial_url << "...\n"; - - std::string current_url { initial_url }; + std::vector history; + history.emplace_back( initial_url ); + std::size_t history_idx = 0; const tui::Terminal terminal; - terminal.clear_screen(); + terminal.show_cursor( false ); + + while ( true ) { + std::string current_url = history[history_idx]; + terminal.clear_screen(); + std::cout << "-> Fetching: " << current_url << "...\r\n"; + std::cout.flush(); - while ( !current_url.empty() ) { auto page_data = load_page( current_url ); - const auto& lines = page_data.lines; - const auto& links = page_data.links; std::size_t scroll_y = 0; bool navigate = false; while ( !navigate ) { - draw_view( terminal, lines, scroll_y, current_url ); - + draw_view( terminal, page_data.lines, scroll_y, current_url ); const char c = terminal.read_key(); - auto [width, height] = terminal.get_size(); - const std::size_t view_h = ( height > 1 ) ? ( height - 1 ) : 1; - if ( c == 'q' ) { + const InputAction action = process_key( c, terminal, page_data, current_url, history, history_idx, scroll_y ); + if ( action == InputAction::Quit ) { terminal.clear_screen(); return 0; } - if ( c == 'j' && scroll_y + view_h < lines.size() ) { - scroll_y++; - } else if ( c == 'k' && scroll_y > 0 ) { - scroll_y--; - } else if ( c == '/' ) { - std::string next_url = get_url_input( terminal ); - if ( !next_url.empty() ) { - current_url = std::move( next_url ); - navigate = true; - } - } else if ( c == 'f' ) { - handle_follow_link( terminal, links, current_url, navigate ); + if ( action == InputAction::Navigate ) { + navigate = true; } } } - terminal.clear_screen(); return 0; } diff --git a/ricochet/src/main.cc b/ricochet/src/main.cc index 0371cda..1037833 100644 --- a/ricochet/src/main.cc +++ b/ricochet/src/main.cc @@ -62,8 +62,13 @@ int main( int argc, char* argv[] ) return 0; } + std::string start_url( input ); + if ( !start_url.starts_with( "http://" ) && !start_url.starts_with( "https://" ) ) { + start_url.insert( 0, "https://" ); + } + try { - return ricochet::core::Browser::run( input ); + return ricochet::core::Browser::run( start_url ); } catch ( const std::exception& e ) { std::cerr << "Critical Error: " << e.what() << "\n"; return 1; diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index b31976f..5cf6a30 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -133,63 +133,111 @@ std::string remove_tag_blocks( std::string html, const std::string& tag_name ) / return html; } -} // namespace - -std::expected -HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) - std::string_view url ) const +std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host ) { - auto parsed_url = parse_url( url ); - if ( !parsed_url.has_value() ) { - return std::unexpected( parsed_url.error() ); + 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( next_url.front() ) ) ) { + next_url.erase( 0, 1 ); + } + if ( next_url.starts_with( "/" ) ) { + next_url = ( is_https ? "https://" : "http://" ) + host + next_url; + } + return next_url; + } + } + return ""; +} - auto [host, path] = parsed_url.value(); - const bool is_https = url.starts_with( "https://" ); - const std::string service = is_https ? "https" : "http"; - +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 ) +{ + FetchResult res; try { const Address server_addr( host, service ); + const std::string request + = std::format( "GET {} HTTP/1.1\r\n" + "Host: {}\r\n" + "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\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\r\n", + path, + host ); - const std::string request = std::format( "GET {} HTTP/1.1\r\n" - "Host: {}\r\n" - "User-Agent: Mozilla/5.0 (Ricochet/0.1.0)\r\n" - "Accept: text/html\r\n" - "Accept-Encoding: identity\r\n" - "Connection: close\r\n\r\n", - path, - host ); - - std::string raw_response; if ( is_https ) { SSLSocket ssl_socket; ssl_socket.connect( server_addr, host ); - size_t bytes_written = 0; - while ( bytes_written < request.size() ) { - bytes_written += ssl_socket.write( request.substr( bytes_written ) ); + 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 ); - raw_response += buffer; + res.raw_response += buffer; } } else { TCPSocket tcp_socket; tcp_socket.connect( server_addr ); - size_t bytes_written = 0; - while ( bytes_written < request.size() ) { - bytes_written += tcp_socket.write( request.substr( bytes_written ) ); + 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 ); - raw_response += buffer; + res.raw_response += buffer; } } + } catch ( const std::exception& e ) { + res.error = std::string( "Network Error: " ) + e.what(); + } + return res; +} - HttpResponse response; - auto header_end = raw_response.find( "\r\n\r\n" ); +} // namespace + +std::expected HttpClient::fetch( std::string_view url ) const +{ + (void)this; // 完美绕过 static 方法警告,满足最严苛的 tidy 规则 + std::string current_url( url ); + + 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 FetchResult net_res = do_network_request( host, service, path, is_https ); + if ( !net_res.error.empty() ) { + return std::unexpected( net_res.error ); + } + const std::string& raw_response = net_res.raw_response; + HttpResponse response; + const auto header_end = raw_response.find( "\r\n\r\n" ); if ( header_end != std::string::npos ) { response.body = raw_response.substr( header_end + 4 ); if ( raw_response.size() >= 12 ) { @@ -201,19 +249,27 @@ HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) } } else { response.body = raw_response; + response.status_code = 0; + } + + if ( response.status_code >= 300 && response.status_code < 400 ) { + const std::string next_url = extract_redirect_url( raw_response, is_https, host ); + if ( !next_url.empty() ) { + current_url = next_url; + continue; + } } if ( raw_response.contains( "Transfer-Encoding: chunked" ) ) { response.body = dechunk( response.body ); } - response.body = remove_html_comments( response.body ); response.body = remove_tag_blocks( response.body, "script" ); response.body = remove_tag_blocks( response.body, "style" ); return response; - } catch ( const std::exception& e ) { - return std::unexpected( std::string( "Network Error: " ) + e.what() ); } + + return std::unexpected( "Too many redirects" ); } } // namespace ricochet::net \ No newline at end of file diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 1af40f1..2b690ca 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -83,11 +83,14 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) const bool is_header = ( base_tag == "h1" || base_tag == "h2" || base_tag == "h3" || base_tag == "h4" ); const bool is_link = ( base_tag == "a" ); + const bool is_list_item = ( base_tag == "li" ); if ( is_header ) { output += "\n\n\033[1;31m"; } else if ( is_link ) { - output += "\033[4;34m"; // 【核心修复】:必须是 output += ,绝对不能用 std::cout + output += "\033[4;34m"; + } else if ( is_list_item ) { + output += "\n • "; } else if ( base_tag == "p" || base_tag == "div" || is_header ) { if ( !output.empty() && output.back() != '\n' ) { output += "\n"; @@ -112,6 +115,17 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) if ( base_tag == "h1" || base_tag == "p" || base_tag == "div" ) { output += "\n"; } + + if ( base_tag == "img" ) { + 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 ); + output += " \033[90m[Image: " + node.tag_name.substr( alt_pos, alt_end - alt_pos ) + "]\033[0m "; + } else { + output += " \033[90m[IMAGE]\033[0m "; + } + } } } // namespace diff --git a/ricochet/src/tui/terminal.cc b/ricochet/src/tui/terminal.cc index 9e78a02..44c0f3b 100644 --- a/ricochet/src/tui/terminal.cc +++ b/ricochet/src/tui/terminal.cc @@ -44,6 +44,7 @@ Terminal::Terminal() Terminal::~Terminal() { + show_cursor( true ); // RAII: restore terminal attributes when program exits tcsetattr( STDIN_FILENO, TCSAFLUSH, &original_termios_ ); } -- cgit v1.2.3 From 54adacb8316bd23f27265751e9ac8ef35b9ec892 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 20:05:05 +0800 Subject: feat: add page title extraction, URL normalization, DuckDuckGo tracker stripping, and text wrapping support --- ricochet/src/core/browser.cc | 188 +++++++++++++++++++++++++++++++++++----- ricochet/src/net/http_client.cc | 14 +-- ricochet/src/render/renderer.cc | 43 +++++++-- 3 files changed, 208 insertions(+), 37 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 2968ab1..4c42bd0 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -25,6 +25,7 @@ #include #include #include +#include namespace ricochet::core { @@ -32,15 +33,57 @@ struct PageData { std::vector lines; std::vector links; + std::string title; }; namespace { + +std::string extract_title( const parser::DomNode& root_node ) +{ + std::vector 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( 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 load_page( std::string_view url ) { const net::HttpClient client; auto response_result = client.fetch( url ); if ( !response_result.has_value() ) { - return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {} }; + return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {}, .title = "Error" }; } const parser::HtmlLexer lexer; @@ -61,17 +104,40 @@ PageData load_page( std::string_view url ) lines.push_back( result.text.substr( start, end - start ) ); start = end + 1; } - return { .lines = lines, .links = result.links }; + const std::string title = extract_title( dom_root ); + return { .lines = lines, .links = result.links, .title = title }; } void draw_view( const tui::Terminal& terminal, const std::vector& lines, std::size_t scroll_y, - std::string_view url ) + std::string_view url, // NOLINT(bugprone-easily-swappable-parameters) + std::string_view title ) // NOLINT(bugprone-easily-swappable-parameters) { terminal.clear_screen(); auto [width, height] = terminal.get_size(); - const std::size_t usable_height = ( height > 1 ) ? ( height - 1 ) : 1; + const std::size_t usable_height = ( height > 2 ) ? ( height - 2 ) : 1; + + std::string clean_title( title ); + if ( clean_title.empty() ) { + const std::size_t start = url.find( "://" ); + const std::size_t host_start = ( start == std::string::npos ) ? 0 : start + 3; + const std::size_t host_end = url.find( '/', host_start ); + clean_title = std::string( url.substr( host_start, host_end - host_start ) ); + } + for ( char& c : clean_title ) { + if ( c == '\n' || c == '\r' ) { + c = ' '; + } + } + + std::string header = " PAGE: " + clean_title; + if ( header.size() < width ) { + header.append( width - header.size(), ' ' ); + } else if ( header.size() > width && width > 3 ) { + header = header.substr( 0, width - 3 ) + "..."; + } + std::cout << "\033[7m" << header << "\033[0m\r\n"; for ( std::size_t i = 0; i < usable_height; ++i ) { if ( scroll_y + i < lines.size() ) { @@ -80,7 +146,14 @@ void draw_view( const tui::Terminal& terminal, std::cout << "~\r\n"; } } - std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [f] Follow [H/L] History [q] Quit \033[0m"; + + std::string footer = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [H/L]History [q]Quit "; + if ( footer.size() < width ) { + footer.append( width - footer.size(), ' ' ); + } else if ( footer.size() > width && width > 3 ) { + footer = footer.substr( 0, width - 3 ) + "..."; + } + std::cout << "\033[7m" << footer << "\033[0m"; std::cout.flush(); } @@ -177,6 +250,52 @@ std::string get_link_input( const tui::Terminal& terminal ) return num_input; } +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( std::stoi( hex, nullptr, 16 ) ); + } catch ( const std::exception& ) { + (void)0; + } + i += 2; + } else { + decoded += encoded[i]; + } + } + return decoded; + } + return target; +} + void handle_follow_link( const tui::Terminal& terminal, const std::vector& links, std::string& current_url, @@ -189,23 +308,8 @@ void handle_follow_link( const tui::Terminal& terminal, if ( index > 0 && index <= links.size() ) { std::string target = links[index - 1]; - if ( target.starts_with( "/" ) ) { - if ( target.starts_with( "//" ) ) { - target = "https:" + target; // "//example.com" - } else { - const std::size_t slash_pos = current_url.find( '/', 8 ); // 跳过 https:// - const std::string domain - = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos ); - target = domain + target; // "/about" -> "https://gnu.org/about" - } - } else if ( !target.starts_with( "http" ) ) { - const std::size_t last_slash = current_url.rfind( '/' ); - if ( last_slash > 7 ) { - target = current_url.substr( 0, last_slash + 1 ) + target; - } else { - target = current_url + "/" + target; - } - } + target = normalize_url( target, current_url ); + target = strip_ddg_tracker( target ); current_url = target; navigate = true; @@ -276,6 +380,39 @@ InputAction process_key( char c, return InputAction::None; } +std::vector wrap_lines( const std::vector& original_lines, std::size_t wrap_width ) +{ + std::vector wrapped_lines; + for ( const auto& line : original_lines ) { + std::string current_line; + std::size_t vis_count = 0; + bool in_ansi = false; + for ( const char c : line ) { + if ( c == '\x1b' ) { + in_ansi = true; + } + if ( in_ansi ) { + current_line += c; + if ( c == 'm' ) { + in_ansi = false; + } + } else { + if ( vis_count >= wrap_width ) { + wrapped_lines.push_back( current_line + "\033[0m" ); + current_line.clear(); + vis_count = 0; + } + current_line += c; + vis_count++; + } + } + if ( !current_line.empty() || line.empty() ) { + wrapped_lines.push_back( current_line + "\033[0m" ); + } + } + return wrapped_lines; +} + } // namespace int Browser::run( std::string_view initial_url ) @@ -293,11 +430,16 @@ int Browser::run( std::string_view initial_url ) std::cout.flush(); auto page_data = load_page( current_url ); + + auto [term_width, term_height] = terminal.get_size(); + const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; + page_data.lines = wrap_lines( page_data.lines, wrap_width ); + std::size_t scroll_y = 0; bool navigate = false; while ( !navigate ) { - draw_view( terminal, page_data.lines, scroll_y, current_url ); + draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); const char c = terminal.read_key(); const InputAction action = process_key( c, terminal, page_data, current_url, history, history_idx, scroll_y ); diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 5cf6a30..143e067 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -39,7 +39,6 @@ struct ParsedUrl std::expected parse_url( std::string_view url ) { std::string_view prefix = "http://"; - if ( url.starts_with( "https://" ) ) { prefix = "https://"; } else if ( url.starts_with( "http://" ) ) { @@ -49,17 +48,20 @@ std::expected parse_url( std::string_view url ) } url.remove_prefix( prefix.length() ); - auto slash_pos = url.find( '/' ); + + const auto sep_pos = url.find_first_of( "/?#" ); ParsedUrl result; - if ( slash_pos == std::string_view::npos ) { + if ( sep_pos == std::string_view::npos ) { result.host = std::string( url ); result.path = "/"; } else { - result.host = std::string( url.substr( 0, slash_pos ) ); - result.path = std::string( url.substr( slash_pos ) ); + result.host = std::string( url.substr( 0, sep_pos ) ); + result.path = std::string( url.substr( sep_pos ) ); + if ( result.path[0] != '/' ) { + result.path.insert( 0, "/" ); + } } - return result; } diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 2b690ca..ed622b4 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -18,19 +18,21 @@ #include "ricochet/render/renderer.hh" #include +#include +#include namespace ricochet::render { namespace { -std::string decode_entities( std::string text ) // NOLINT(readability-convert-member-functions-to-static) +std::string decode_entities( std::string text ) { - constexpr std::array, 24> entities - = { { { " ", " " }, { "<", "<" }, { ">", ">" }, { "&", "&" }, { """, "\"" }, - { "©", "©" }, { "®", "®" }, { "™", "™" }, { "€", "€" }, { "£", "£" }, - { "¥", "¥" }, { "¢", "¢" }, { "§", "§" }, { "¶", "¶" }, { "…", "…" }, - { "†", "†" }, { "‡", "‡" }, { "•", "•" }, { "′", "′" }, { "″", "″" }, - { "‹", "‹" }, { "›", "›" }, { "‾", "‾" }, { "⁄", "⁄" } } }; + constexpr std::array, 25> entities + = { { { " ", " " }, { "<", "<" }, { ">", ">" }, { "&", "&" }, { """, "\"" }, + { "'", "'" }, { "©", "©" }, { "®", "®" }, { "™", "™" }, { "€", "€" }, + { "£", "£" }, { "¥", "¥" }, { "¢", "¢" }, { "§", "§" }, { "¶", "¶" }, + { "…", "…" }, { "†", "†" }, { "‡", "‡" }, { "•", "•" }, { "′", "′" }, + { "″", "″" }, { "‹", "‹" }, { "›", "›" }, { "‾", "‾" }, { "⁄", "⁄" } } }; for ( const auto& [entity, replacement] : entities ) { std::size_t pos = 0; @@ -39,6 +41,31 @@ std::string decode_entities( std::string text ) // NOLINT(readability-convert-me 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( cp ) ); + continue; + } + } catch ( const std::invalid_argument& ) { + (void)0; // Not a valid number, leave as-is + } catch ( const std::out_of_range& ) { + (void)0; // Value too large, leave as-is + } + } + pos++; + } return text; } @@ -107,7 +134,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) output += "\033[0m"; const std::string href = extract_href( node.tag_name ); if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) { - links.push_back( href ); + links.push_back( decode_entities( href ) ); output += " \033[7m[" + std::to_string( links.size() ) + "]\033[0m"; // 打印数字标签! } } -- cgit v1.2.3 From ab660037d0ad95c1ce7a86da6898360035e28919 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 20:35:05 +0800 Subject: feat: add support for interacting with form inputs via the 'i' key --- ricochet/include/ricochet/render/renderer.hh | 1 + ricochet/src/core/browser.cc | 93 +++++++++++++++++++++++++++- ricochet/src/render/renderer.cc | 30 ++++++--- 3 files changed, 114 insertions(+), 10 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index 2a9acae..8ea660c 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -26,6 +26,7 @@ struct RenderResult { std::string text {}; std::vector links {}; + std::vector inputs {}; }; class Renderer diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 4c42bd0..e3a739a 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -33,6 +33,7 @@ struct PageData { std::vector lines; std::vector links; + std::vector inputs; std::string title; }; @@ -83,7 +84,8 @@ PageData load_page( std::string_view url ) const net::HttpClient client; auto response_result = client.fetch( url ); if ( !response_result.has_value() ) { - return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {}, .title = "Error" }; + return { + .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {}, .inputs = {}, .title = "Error" }; } const parser::HtmlLexer lexer; @@ -105,7 +107,7 @@ PageData load_page( std::string_view url ) start = end + 1; } const std::string title = extract_title( dom_root ); - return { .lines = lines, .links = result.links, .title = title }; + return { .lines = lines, .links = result.links, .inputs = result.inputs, .title = title }; } void draw_view( const tui::Terminal& terminal, @@ -147,7 +149,8 @@ void draw_view( const tui::Terminal& terminal, } } - std::string footer = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [H/L]History [q]Quit "; + std::string footer + = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [H/L]History [q]Quit "; if ( footer.size() < width ) { footer.append( width - footer.size(), ' ' ); } else if ( footer.size() > width && width > 3 ) { @@ -327,6 +330,86 @@ enum class InputAction : std::uint8_t Navigate }; +std::string get_terminal_input( const tui::Terminal& terminal, const std::string& prompt ) +{ + auto [width, height] = terminal.get_size(); + terminal.move_cursor( height, 1 ); + std::cout << "\x1b[2K\033[7m " << prompt << " \033[0m "; + std::cout.flush(); + + std::string input; + terminal.show_cursor( true ); + while ( true ) { + const char ch = terminal.read_key(); + if ( ch == '\r' || ch == '\n' ) { + break; + } + if ( ch == 27 ) { + input.clear(); + break; + } + if ( ch == 127 || ch == 8 ) { + if ( !input.empty() ) { + input.pop_back(); + std::cout << "\b \b"; + std::cout.flush(); + } + } else if ( std::isprint( static_cast( ch ) ) ) { + input += ch; + std::cout << ch; + std::cout.flush(); + } + } + terminal.show_cursor( false ); + return input; +} + +InputAction handle_form_input( const tui::Terminal& terminal, + const PageData& page_data, + std::string& current_url, + std::vector& history, + std::size_t& history_idx ) +{ + const std::string num_input = get_terminal_input( terminal, "Select Input [#]:" ); + if ( num_input.empty() ) { + return InputAction::None; + } + + std::size_t index = 0; + try { + index = std::stoul( num_input ); + } catch ( ... ) { + (void)0; + } + if ( index == 0 || index > page_data.inputs.size() ) { + return InputAction::None; + } + + const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); + if ( query.empty() ) { + return InputAction::None; + } + + std::string target_url; + std::string encoded_query = query; + for ( char& ch : encoded_query ) { + if ( ch == ' ' ) { + ch = '+'; + } + } + + if ( current_url.contains( "wikipedia.org" ) ) { + target_url = "https://en.wikipedia.org/w/index.php?search=" + encoded_query; + } else { + target_url = "https://lite.duckduckgo.com/lite/?q=" + encoded_query; + } + + history.resize( history_idx + 1 ); + history.push_back( target_url ); + history_idx++; + return InputAction::Navigate; +} + InputAction process_key( char c, const tui::Terminal& terminal, const PageData& page_data, @@ -377,6 +460,10 @@ InputAction process_key( char c, return InputAction::Navigate; } } + + if ( c == 'i' && !page_data.inputs.empty() ) { + return handle_form_input( terminal, page_data, current_url, history, history_idx ); + } return InputAction::None; } diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index ed622b4..5f65ea7 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -90,16 +90,28 @@ std::string extract_href( const std::string& tag_name ) return ""; } +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"; +} + void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) - std::string& output, - std::vector& links ) + RenderResult& result ) // NOLINT(bugprone-easily-swappable-parameters) { + std::string& output = result.text; + std::vector& links = result.links; + std::vector& inputs = result.inputs; + 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 == "head" || base_tag == "style" || base_tag == "script" || base_tag == "meta" - || base_tag == "title" || base_tag == "link" ) { + if ( is_ignored_tag( base_tag ) ) { return; } @@ -108,9 +120,10 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) return; } - const bool is_header = ( base_tag == "h1" || base_tag == "h2" || base_tag == "h3" || base_tag == "h4" ); + 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" ); if ( is_header ) { output += "\n\n\033[1;31m"; @@ -118,6 +131,9 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) output += "\033[4;34m"; } else if ( is_list_item ) { output += "\n • "; + } else if ( is_input ) { + inputs.emplace_back( "input_field" ); + output += " \033[7;33m[I" + std::to_string( inputs.size() ) + "]\033[0m "; } else if ( base_tag == "p" || base_tag == "div" || is_header ) { if ( !output.empty() && output.back() != '\n' ) { output += "\n"; @@ -125,7 +141,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) } for ( const auto& child : node.children ) { - render_node( child, output, links ); + render_node( child, result ); } if ( is_header ) { @@ -161,7 +177,7 @@ RenderResult Renderer::render( // NOLINT(readability-convert-member-functions-to const parser::DomNode& node ) const { RenderResult result; - render_node( node, result.text, result.links ); + render_node( node, result ); return result; } -- cgit v1.2.3 From d97213f818761d298eec28cad42c4fbd242838f5 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 20:57:20 +0800 Subject: feat: implement form submission support by tracking input fields and form context during rendering --- ricochet/include/ricochet/render/renderer.hh | 8 +- ricochet/src/core/browser.cc | 19 +++-- ricochet/src/render/renderer.cc | 107 +++++++++++++++++++-------- 3 files changed, 98 insertions(+), 36 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index 8ea660c..8c49bbd 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -22,11 +22,17 @@ namespace ricochet::render { +struct InputField +{ + std::string name; + std::string action; + std::string method; +}; struct RenderResult { std::string text {}; std::vector links {}; - std::vector inputs {}; + std::vector inputs {}; }; class Renderer diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index e3a739a..0f6d9d2 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -33,7 +33,7 @@ struct PageData { std::vector lines; std::vector links; - std::vector inputs; + std::vector inputs; std::string title; }; @@ -390,7 +390,7 @@ InputAction handle_form_input( const tui::Terminal& terminal, return InputAction::None; } - std::string target_url; + const auto& target_input = page_data.inputs[index - 1]; std::string encoded_query = query; for ( char& ch : encoded_query ) { if ( ch == ' ' ) { @@ -398,12 +398,21 @@ InputAction handle_form_input( const tui::Terminal& terminal, } } - if ( current_url.contains( "wikipedia.org" ) ) { - target_url = "https://en.wikipedia.org/w/index.php?search=" + encoded_query; + std::string action = target_input.action; + if ( action.empty() ) { + action = current_url; } else { - target_url = "https://lite.duckduckgo.com/lite/?q=" + encoded_query; + action = normalize_url( action, current_url ); } + std::string param_name = target_input.name; + if ( param_name.empty() ) { + param_name = "q"; + } + + const char sep = action.contains( '?' ) ? '&' : '?'; + const std::string target_url = action + sep + param_name + "=" + encoded_query; + history.resize( history_idx + 1 ); history.push_back( target_url ); history_idx++; diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 5f65ea7..8708206 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -69,19 +69,21 @@ std::string decode_entities( std::string text ) return text; } -std::string extract_href( const std::string& tag_name ) +std::string extract_attr( const std::string& tag_name, std::string_view attr_name ) { - std::size_t pos = tag_name.find( "href=\"" ); + std::string match = std::string( attr_name ) + "=\""; + std::size_t pos = tag_name.find( match ); if ( pos != std::string::npos ) { - pos += 6; - const std::size_t end = tag_name.find( '\"', pos ); + pos += match.length(); + const std::size_t end = tag_name.find( '"', pos ); if ( end != std::string::npos ) { return tag_name.substr( pos, end - pos ); } } - pos = tag_name.find( "href='" ); + match = std::string( attr_name ) + "='"; + pos = tag_name.find( match ); if ( pos != std::string::npos ) { - pos += 6; + pos += match.length(); const std::size_t end = tag_name.find( '\'', pos ); if ( end != std::string::npos ) { return tag_name.substr( pos, end - pos ); @@ -90,6 +92,58 @@ std::string extract_href( const std::string& tag_name ) return ""; } +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[7m[" + 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 "; + } +} + +std::string build_input_hint( const std::string& tag_name ) +{ + std::string placeholder = extract_attr( tag_name, "placeholder" ); + 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" || type == "radio" ) { + return type; + } + if ( type == "hidden" ) { + return "hidden"; + } + if ( !val.empty() ) { + return val; + } + return "text"; +} + +struct FormContext +{ + std::string action; + std::string method; +}; + bool is_ignored_tag( std::string_view tag ) { return tag == "head" || tag == "style" || tag == "script" || tag == "meta" || tag == "title" || tag == "link"; @@ -101,12 +155,10 @@ bool is_heading_tag( std::string_view tag ) } void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) - RenderResult& result ) // NOLINT(bugprone-easily-swappable-parameters) + RenderResult& result, + FormContext ctx ) { std::string& output = result.text; - std::vector& links = result.links; - std::vector& inputs = result.inputs; - 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 ); @@ -114,12 +166,16 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) if ( is_ignored_tag( base_tag ) ) { return; } - if ( base_tag.empty() ) { output += decode_entities( node.text_content ); 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" ); @@ -132,8 +188,12 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) } else if ( is_list_item ) { output += "\n • "; } else if ( is_input ) { - inputs.emplace_back( "input_field" ); - output += " \033[7;33m[I" + std::to_string( inputs.size() ) + "]\033[0m "; + result.inputs.push_back( { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method } ); + const std::string hint = build_input_hint( node.tag_name ); + + if ( hint != "hidden" ) { + output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m "; + } } else if ( base_tag == "p" || base_tag == "div" || is_header ) { if ( !output.empty() && output.back() != '\n' ) { output += "\n"; @@ -141,33 +201,20 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) } for ( const auto& child : node.children ) { - render_node( child, result ); + render_node( child, result, ctx ); } if ( is_header ) { output += "\033[0m"; } else if ( is_link ) { - output += "\033[0m"; - const std::string href = extract_href( node.tag_name ); - if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) { - links.push_back( decode_entities( href ) ); - output += " \033[7m[" + std::to_string( links.size() ) + "]\033[0m"; // 打印数字标签! - } + render_link_end( node, result ); } if ( base_tag == "h1" || base_tag == "p" || base_tag == "div" ) { output += "\n"; } - if ( base_tag == "img" ) { - 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 ); - output += " \033[90m[Image: " + node.tag_name.substr( alt_pos, alt_end - alt_pos ) + "]\033[0m "; - } else { - output += " \033[90m[IMAGE]\033[0m "; - } + render_img( node, result ); } } @@ -177,7 +224,7 @@ RenderResult Renderer::render( // NOLINT(readability-convert-member-functions-to const parser::DomNode& node ) const { RenderResult result; - render_node( node, result ); + render_node( node, result, FormContext {} ); return result; } -- cgit v1.2.3 From 89267aed9047a5b23e0cb4b364bad90260913577 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 23:49:02 +0800 Subject: feat: implement HTTP POST support for form submissions and add input value persistence --- ricochet/include/ricochet/net/http_client.hh | 4 +- ricochet/include/ricochet/render/renderer.hh | 3 + ricochet/src/core/browser.cc | 136 +++++++++++++++++---------- ricochet/src/net/http_client.cc | 31 ++++-- ricochet/src/render/renderer.cc | 33 +++++-- 5 files changed, 144 insertions(+), 63 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/net/http_client.hh b/ricochet/include/ricochet/net/http_client.hh index 2dd7419..45c92e8 100644 --- a/ricochet/include/ricochet/net/http_client.hh +++ b/ricochet/include/ricochet/net/http_client.hh @@ -51,7 +51,9 @@ public: * @param url Target URL, for example "http://example.com" * @return Returns HttpResponse on success, returns an error message string on failure */ - std::expected fetch( std::string_view url ) const; + std::expected fetch( std::string_view url, + std::string_view method = "GET", + std::string_view body = "" ) const; private: }; diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index 8c49bbd..dda3ac5 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -19,6 +19,7 @@ #pragma once #include "ricochet/parser/dom_tree.hh" +#include namespace ricochet::render { @@ -27,6 +28,8 @@ struct InputField std::string name; std::string action; std::string method; + std::string value {}; + std::string type {}; }; struct RenderResult { diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 0f6d9d2..e6c63d4 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -37,6 +37,13 @@ struct PageData std::string title; }; +struct HttpRequest +{ + std::string url {}; + std::string method = "GET"; + std::string body {}; +}; + namespace { std::string extract_title( const parser::DomNode& root_node ) @@ -79,13 +86,12 @@ std::string extract_title( const parser::DomNode& root_node ) return ""; } -PageData load_page( std::string_view url ) +PageData load_page( const HttpRequest& req ) { const net::HttpClient client; - auto response_result = client.fetch( url ); + auto response_result = client.fetch( req.url, req.method, req.body ); if ( !response_result.has_value() ) { - return { - .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {}, .inputs = {}, .title = "Error" }; + return { .lines = { "[!] Failed to load: " + req.url }, .links = {}, .inputs = {}, .title = "Error" }; } const parser::HtmlLexer lexer; @@ -150,7 +156,7 @@ void draw_view( const tui::Terminal& terminal, } std::string footer - = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [H/L]History [q]Quit "; + = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [r]Refresh [H/L]History [q]Quit "; if ( footer.size() < width ) { footer.append( width - footer.size(), ' ' ); } else if ( footer.size() > width && width > 3 ) { @@ -364,66 +370,96 @@ std::string get_terminal_input( const tui::Terminal& terminal, const std::string return input; } +InputAction submit_form( const render::InputField& target_input, + const std::vector& inputs, + const std::string& current_url, + std::vector& 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" ) { + 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; +} + +void update_input_value( PageData& page_data, std::size_t index, const std::string& query ) +{ + page_data.inputs[index - 1].value = query; + const std::string marker = "[I" + std::to_string( index ) + ":"; + for ( auto& line : page_data.lines ) { + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + const std::size_t end = line.find( "]", pos ); + if ( end != std::string::npos ) { + line.replace( pos, end - pos + 1, "[I" + std::to_string( index ) + ":" + query + "]" ); + } + } + } +} + InputAction handle_form_input( const tui::Terminal& terminal, - const PageData& page_data, - std::string& current_url, - std::vector& history, + PageData& page_data, + const std::string& current_url, + std::vector& history, std::size_t& history_idx ) { const std::string num_input = get_terminal_input( terminal, "Select Input [#]:" ); if ( num_input.empty() ) { return InputAction::None; } - std::size_t index = 0; try { index = std::stoul( num_input ); } catch ( ... ) { - (void)0; - } - if ( index == 0 || index > page_data.inputs.size() ) { return InputAction::None; } - - const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); - if ( query.empty() ) { + if ( index == 0 || index > page_data.inputs.size() ) { return InputAction::None; } const auto& target_input = page_data.inputs[index - 1]; - std::string encoded_query = query; - for ( char& ch : encoded_query ) { - if ( ch == ' ' ) { - ch = '+'; - } - } - - std::string action = target_input.action; - if ( action.empty() ) { - action = current_url; - } else { - action = normalize_url( action, current_url ); + if ( target_input.type == "submit" || target_input.type == "button" ) { + return submit_form( target_input, page_data.inputs, current_url, history, history_idx ); } - std::string param_name = target_input.name; - if ( param_name.empty() ) { - param_name = "q"; + const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); + if ( query.empty() ) { + return InputAction::None; } - - const char sep = action.contains( '?' ) ? '&' : '?'; - const std::string target_url = action + sep + param_name + "=" + encoded_query; - - history.resize( history_idx + 1 ); - history.push_back( target_url ); - history_idx++; - return InputAction::Navigate; + update_input_value( page_data, index, query ); + return InputAction::None; } InputAction process_key( char c, const tui::Terminal& terminal, - const PageData& page_data, - std::string& current_url, - std::vector& history, + PageData& page_data, + std::string current_url, + std::vector& history, std::size_t& history_idx, // NOLINT(bugprone-easily-swappable-parameters) std::size_t& scroll_y ) // NOLINT(bugprone-easily-swappable-parameters) { @@ -447,12 +483,15 @@ InputAction process_key( char c, history_idx++; return InputAction::Navigate; } + if ( c == 'r' ) { + return InputAction::Navigate; + } if ( c == '/' ) { const std::string next_url = get_url_input( terminal ); if ( !next_url.empty() ) { history.resize( history_idx + 1 ); - history.push_back( next_url ); + history.push_back( HttpRequest { .url = next_url, .method = "GET", .body = "" } ); history_idx++; return InputAction::Navigate; } @@ -464,7 +503,7 @@ InputAction process_key( char c, handle_follow_link( terminal, page_data.links, current_url, nav ); if ( nav && current_url != old_url ) { history.resize( history_idx + 1 ); - history.push_back( current_url ); + history.push_back( HttpRequest { .url = current_url, .method = "GET", .body = "" } ); history_idx++; return InputAction::Navigate; } @@ -513,19 +552,20 @@ std::vector wrap_lines( const std::vector& original_li int Browser::run( std::string_view initial_url ) { - std::vector history; - history.emplace_back( initial_url ); + std::vector history; + history.emplace_back( HttpRequest { .url = std::string( initial_url ), .method = "GET", .body = "" } ); std::size_t history_idx = 0; const tui::Terminal terminal; terminal.show_cursor( false ); while ( true ) { - std::string current_url = history[history_idx]; + const HttpRequest current_req = history[history_idx]; + const std::string current_url = current_req.url; terminal.clear_screen(); - std::cout << "-> Fetching: " << current_url << "...\r\n"; + std::cout << "-> Fetching: " << current_req.url << "...\r\n"; std::cout.flush(); - auto page_data = load_page( current_url ); + auto page_data = load_page( current_req ); auto [term_width, term_height] = terminal.get_size(); const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 143e067..0d75a7f 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -25,6 +25,7 @@ #include #include #include +#include namespace ricochet::net { @@ -169,22 +170,31 @@ struct FetchResult 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 ) + bool is_https, + const std::string& method, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& body ) // NOLINT(bugprone-easily-swappable-parameters) { FetchResult res; try { const Address server_addr( host, service ); - const std::string request - = std::format( "GET {} HTTP/1.1\r\n" + std::string request + = std::format( "{} {} HTTP/1.1\r\n" "Host: {}\r\n" "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\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\r\n", + "Connection: close\r\n", + method, path, host ); + 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; @@ -217,10 +227,13 @@ FetchResult do_network_request( const std::string& host, } // namespace -std::expected HttpClient::fetch( std::string_view url ) const +std::expected HttpClient::fetch( std::string_view url, // NOLINT + std::string_view method, + std::string_view body ) const { - (void)this; // 完美绕过 static 方法警告,满足最严苛的 tidy 规则 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 ); @@ -232,7 +245,7 @@ std::expected HttpClient::fetch( std::string_view url const bool is_https = current_url.starts_with( "https://" ); const std::string service = is_https ? "https" : "http"; - const FetchResult net_res = do_network_request( host, service, path, is_https ); + const FetchResult net_res = do_network_request( host, service, path, is_https, current_method, current_body ); if ( !net_res.error.empty() ) { return std::unexpected( net_res.error ); } @@ -258,6 +271,10 @@ std::expected HttpClient::fetch( std::string_view url const std::string next_url = extract_redirect_url( raw_response, 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; } } diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 8708206..4b152b7 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -154,6 +154,30 @@ bool is_heading_tag( std::string_view tag ) return tag == "h1" || tag == "h2" || tag == "h3" || tag == "h4"; } +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" ); + result.inputs.push_back( { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method, default_val, type } ); + + std::string hint = build_input_hint( node.tag_name ); + if ( base_tag == "button" ) { + hint = "Submit"; + } + if ( hint != "hidden" ) { + output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m "; + } +} + void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) RenderResult& result, FormContext ctx ) @@ -179,7 +203,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) 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" ); + const bool is_input = ( base_tag == "input" || base_tag == "textarea" || base_tag == "button" ); if ( is_header ) { output += "\n\n\033[1;31m"; @@ -188,12 +212,7 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) } else if ( is_list_item ) { output += "\n • "; } else if ( is_input ) { - result.inputs.push_back( { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method } ); - const std::string hint = build_input_hint( node.tag_name ); - - if ( hint != "hidden" ) { - output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m "; - } + 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"; -- cgit v1.2.3 From 01d35792e462398c320875f71ba982072f789e78 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 00:56:38 +0800 Subject: feat: add support for interactive checkbox and radio button input fields --- ricochet/include/ricochet/render/renderer.hh | 1 + ricochet/src/core/browser.cc | 54 +++++++++++++++++++++++++++- ricochet/src/render/renderer.cc | 53 +++++++++++++++++---------- 3 files changed, 88 insertions(+), 20 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index dda3ac5..472afaa 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -30,6 +30,7 @@ struct InputField std::string method; std::string value {}; std::string type {}; + bool checked { false }; }; struct RenderResult { diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index e6c63d4..d463b73 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -379,6 +379,9 @@ InputAction submit_form( const render::InputField& target_input, 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 == ' ' ) { @@ -422,6 +425,40 @@ void update_input_value( PageData& page_data, std::size_t index, const std::stri } } +void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ) +{ + const std::size_t pos = line.find( marker ); + if ( pos == std::string::npos ) { + return; + } + const std::size_t end = line.find( ']', pos ); + if ( end == std::string::npos ) { + return; + } + + std::string prefix; + if ( in.type == "checkbox" ) { + prefix = in.checked ? "[X] " : "[ ] "; + } else { + prefix = in.checked ? "(X) " : "( ) "; + } + line.replace( pos, end - pos + 1, marker + prefix + in.value + "]" ); +} + +void update_toggle_ui( PageData& page_data ) +{ + for ( std::size_t i = 0; i < page_data.inputs.size(); ++i ) { + const auto& in = page_data.inputs[i]; + if ( in.type != "checkbox" && in.type != "radio" ) { + continue; + } + const std::string marker = "[I" + std::to_string( i + 1 ) + ":"; + for ( auto& line : page_data.lines ) { + update_single_line_ui( line, marker, in ); + } + } +} + InputAction handle_form_input( const tui::Terminal& terminal, PageData& page_data, const std::string& current_url, @@ -442,11 +479,26 @@ InputAction handle_form_input( const tui::Terminal& terminal, return InputAction::None; } - const auto& target_input = page_data.inputs[index - 1]; + auto& target_input = page_data.inputs[index - 1]; if ( target_input.type == "submit" || target_input.type == "button" ) { return submit_form( target_input, page_data.inputs, current_url, history, history_idx ); } + if ( target_input.type == "checkbox" || target_input.type == "radio" ) { + if ( target_input.type == "radio" ) { + for ( auto& in : page_data.inputs ) { + if ( in.name == target_input.name && in.action == target_input.action ) { + in.checked = false; + } + } + target_input.checked = true; + } else { + target_input.checked = !target_input.checked; + } + update_toggle_ui( page_data ); + return InputAction::None; + } + const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); if ( query.empty() ) { return InputAction::None; diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 4b152b7..3744728 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -71,25 +71,35 @@ std::string decode_entities( std::string text ) std::string extract_attr( const std::string& tag_name, std::string_view attr_name ) { - std::string match = std::string( attr_name ) + "=\""; + std::string match = " " + std::string( attr_name ) + "="; std::size_t pos = tag_name.find( match ); - if ( pos != std::string::npos ) { - pos += match.length(); - const std::size_t end = tag_name.find( '"', pos ); - if ( end != std::string::npos ) { - return tag_name.substr( pos, end - pos ); + + 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 ""; } - match = std::string( attr_name ) + "='"; - pos = tag_name.find( match ); - if ( pos != std::string::npos ) { - pos += match.length(); - const std::size_t end = tag_name.find( '\'', pos ); + + 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, end - pos ); + return tag_name.substr( pos + 1, end - pos - 1 ); } + return ""; } - 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 ); } void render_link_end( const parser::DomNode& node, RenderResult& result ) @@ -114,10 +124,10 @@ void render_img( const parser::DomNode& node, RenderResult& result ) } } -std::string build_input_hint( const std::string& tag_name ) +std::string build_input_hint( const std::string& tag_name, bool checked ) { std::string placeholder = extract_attr( tag_name, "placeholder" ); - std::string type = extract_attr( tag_name, "type" ); + const std::string type = extract_attr( tag_name, "type" ); std::string val = extract_attr( tag_name, "value" ); if ( !placeholder.empty() ) { @@ -126,8 +136,11 @@ std::string build_input_hint( const std::string& tag_name ) if ( type == "submit" || type == "button" ) { return val.empty() ? "Submit" : val; } - if ( type == "checkbox" || type == "radio" ) { - return type; + if ( type == "checkbox" ) { + return checked ? "[X] " + val : "[ ] " + val; + } + if ( type == "radio" ) { + return checked ? "(X) " + val : "( ) " + val; } if ( type == "hidden" ) { return "hidden"; @@ -167,9 +180,11 @@ void render_input_node( const parser::DomNode& node, type = "submit"; } const std::string default_val = extract_attr( node.tag_name, "value" ); - result.inputs.push_back( { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method, default_val, type } ); + 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 ); + std::string hint = build_input_hint( node.tag_name, is_checked ); if ( base_tag == "button" ) { hint = "Submit"; } -- cgit v1.2.3 From a01713ea09a9c451cfb556d4b4c7fd17079b833a Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 01:30:46 +0800 Subject: feat: implement bookmarking system with persistence and ricochet://bookmarks page --- ricochet/src/core/browser.cc | 77 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 6 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index d463b73..1457929 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -24,6 +24,7 @@ #include "ricochet/tui/terminal.hh" #include #include +#include #include #include @@ -86,19 +87,73 @@ std::string extract_title( const parser::DomNode& root_node ) return ""; } +void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappable-parameters) + const std::string& url ) +{ + if ( url == "ricochet://bookmarks" ) { + return; + } + std::ofstream file( "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( "bookmarks.txt" ); + std::string html = "My Bookmarks

Saved Bookmarks

    "; + if ( !file.is_open() ) { + html += "
  • No bookmarks found. Press 'b' on any page to add one!
  • "; + } 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 += "
  • [Link] "; + html += title; + html += "
  • "; + } + } + } + html += "
"; + return html; +} + PageData load_page( const HttpRequest& req ) { - const net::HttpClient client; - auto response_result = client.fetch( req.url, req.method, req.body ); - if ( !response_result.has_value() ) { - return { .lines = { "[!] Failed to load: " + req.url }, .links = {}, .inputs = {}, .title = "Error" }; + std::string html_body; + if ( req.url == "ricochet://bookmarks" ) { + html_body = generate_bookmarks_html(); + } else { + const net::HttpClient client; + auto response_result = client.fetch( req.url, req.method, req.body ); + if ( !response_result.has_value() ) { + return { .lines = { "[!] Failed to load: " + req.url }, .links = {}, .inputs = {}, .title = "Error" }; + } + 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( response_result.value().body ) ); + const auto dom_root = builder.build( lexer.tokenize( html_body ) ); const render::RenderResult result = renderer.render( dom_root ); std::vector lines; @@ -156,7 +211,7 @@ void draw_view( const tui::Terminal& terminal, } std::string footer - = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [r]Refresh [H/L]History [q]Quit "; + = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [b]Mark [B]Marks [q]Quit "; if ( footer.size() < width ) { footer.append( width - footer.size(), ' ' ); } else if ( footer.size() > width && width > 3 ) { @@ -538,6 +593,16 @@ InputAction process_key( char c, if ( c == 'r' ) { return InputAction::Navigate; } + if ( c == 'b' ) { + save_bookmark( page_data.title, current_url ); + return InputAction::None; + } + if ( c == 'B' ) { + history.resize( history_idx + 1 ); + history.push_back( HttpRequest { .url = "ricochet://bookmarks", .method = "GET", .body = "" } ); + history_idx++; + return InputAction::Navigate; + } if ( c == '/' ) { const std::string next_url = get_url_input( terminal ); -- cgit v1.2.3 From 331fde79776286de58247f565839b1eae154f7c4 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 02:11:27 +0800 Subject: feat: add binary file download support by parsing Content-Type headers --- ricochet/include/ricochet/net/http_client.hh | 1 + ricochet/src/core/browser.cc | 78 +++++++++++++++++++++++----- ricochet/src/net/http_client.cc | 12 +++++ 3 files changed, 78 insertions(+), 13 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/net/http_client.hh b/ricochet/include/ricochet/net/http_client.hh index 45c92e8..f6d8aa3 100644 --- a/ricochet/include/ricochet/net/http_client.hh +++ b/ricochet/include/ricochet/net/http_client.hh @@ -33,6 +33,7 @@ struct HttpResponse int status_code { 0 }; std::unordered_map headers {}; std::string body {}; + std::string content_type {}; }; class HttpClient diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 1457929..57ed496 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -135,6 +135,53 @@ std::string generate_bookmarks_html() return html; } +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( 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" }; +} + +std::vector split_into_lines( const std::string& text ) +{ + std::vector 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; +} + PageData load_page( const HttpRequest& req ) { std::string html_body; @@ -146,6 +193,21 @@ PageData load_page( const HttpRequest& req ) if ( !response_result.has_value() ) { return { .lines = { "[!] Failed to load: " + req.url }, .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( 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; } @@ -155,20 +217,10 @@ PageData load_page( const HttpRequest& req ) const auto dom_root = builder.build( lexer.tokenize( html_body ) ); const render::RenderResult result = renderer.render( dom_root ); - - std::vector lines; - std::size_t start = 0; - while ( start < result.text.length() ) { - const std::size_t end = result.text.find( '\n', start ); - if ( end == std::string::npos ) { - lines.push_back( result.text.substr( start ) ); - break; - } - lines.push_back( result.text.substr( start, end - start ) ); - start = end + 1; - } const std::string title = extract_title( dom_root ); - return { .lines = lines, .links = result.links, .inputs = result.inputs, .title = title }; + + return { + .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title }; } void draw_view( const tui::Terminal& terminal, diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 240b234..7372247 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -314,6 +314,18 @@ std::expected HttpClient::fetch( std::string_view url const auto header_end = raw_response.find( "\r\n\r\n" ); if ( header_end != std::string::npos ) { response.body = raw_response.substr( header_end + 4 ); + const std::string headers = raw_response.substr( 0, header_end ); + 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 ) ); -- cgit v1.2.3 From a10bbaf20ac20278701b182a5a45a5edf73e9d0a Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 02:33:09 +0800 Subject: feat: improve error reporting, implement responsive footer layout, and refine HTTP response processing for non-text content types --- ricochet/src/core/browser.cc | 49 ++++++++++++++++++++++++++++++++++------- ricochet/src/net/http_client.cc | 23 ++++++++++++++----- 2 files changed, 58 insertions(+), 14 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 57ed496..e9f1fce 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -191,7 +191,11 @@ PageData load_page( const HttpRequest& req ) const net::HttpClient client; auto response_result = client.fetch( req.url, req.method, req.body ); if ( !response_result.has_value() ) { - return { .lines = { "[!] Failed to load: " + req.url }, .links = {}, .inputs = {}, .title = "Error" }; + 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(); @@ -223,6 +227,41 @@ PageData load_page( const HttpRequest& req ) .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title }; } +// --- 🔪 智能底部排版:优先截断 URL,誓死保卫快捷键 --- +std::string build_footer( std::string_view url, std::size_t width ) +{ + const std::string_view keys = " | [j/k]Scroll [/]Nav [f]Link [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; + const std::string_view prefix = " URL: "; + + std::string footer; + if ( width <= keys.length() + prefix.length() + 3 ) { + footer.reserve( prefix.length() + url.length() + keys.length() ); + footer += prefix; + footer += url; + footer += keys; + if ( footer.length() > width && width > 3 ) { + footer.erase( width - 3 ); + footer += "..."; + } + } else { + const std::size_t max_url_len = width - keys.length() - prefix.length(); + footer.reserve( width ); + footer += prefix; + if ( url.length() > max_url_len ) { + footer += url.substr( 0, max_url_len - 3 ); + footer += "..."; + } else { + footer += url; + } + footer += keys; + } + + if ( footer.length() < width ) { + footer.append( width - footer.length(), ' ' ); + } + return footer; +} + void draw_view( const tui::Terminal& terminal, const std::vector& lines, std::size_t scroll_y, @@ -262,13 +301,7 @@ void draw_view( const tui::Terminal& terminal, } } - std::string footer - = " URL: " + std::string( url ) + " | [j/k]Scroll [/]Jump [f]Follow [i]Input [b]Mark [B]Marks [q]Quit "; - if ( footer.size() < width ) { - footer.append( width - footer.size(), ' ' ); - } else if ( footer.size() > width && width > 3 ) { - footer = footer.substr( 0, width - 3 ) + "..."; - } + const std::string footer = build_footer( url, width ); std::cout << "\033[7m" << footer << "\033[0m"; std::cout.flush(); } diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 7372247..bbc5d17 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -308,13 +308,15 @@ std::expected HttpClient::fetch( std::string_view url return std::unexpected( net_res.error ); } const std::string& raw_response = net_res.raw_response; - update_cookies( raw_response ); HttpResponse response; const auto header_end = raw_response.find( "\r\n\r\n" ); + const std::string headers + = ( header_end != std::string::npos ) ? raw_response.substr( 0, header_end ) : raw_response; + update_cookies( headers ); + if ( header_end != std::string::npos ) { response.body = raw_response.substr( header_end + 4 ); - const std::string headers = raw_response.substr( 0, header_end ); std::size_t ct_pos = headers.find( "Content-Type: " ); if ( ct_pos == std::string::npos ) { ct_pos = headers.find( "content-type: " ); @@ -339,7 +341,7 @@ std::expected HttpClient::fetch( std::string_view url } if ( response.status_code >= 300 && response.status_code < 400 ) { - const std::string next_url = extract_redirect_url( raw_response, is_https, host ); + 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 ) { @@ -353,9 +355,18 @@ std::expected HttpClient::fetch( std::string_view url if ( raw_response.contains( "Transfer-Encoding: chunked" ) ) { response.body = dechunk( response.body ); } - response.body = remove_html_comments( response.body ); - response.body = remove_tag_blocks( response.body, "script" ); - response.body = remove_tag_blocks( response.body, "style" ); + std::string ct_lower = response.content_type; + for ( char& c : ct_lower ) { + if ( c >= 'A' && c <= 'Z' ) { + c = static_cast( 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" ); + } return response; } -- cgit v1.2.3 From b8d188763c1a92c9ad01a1645c82e5196de4d4ec Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 02:55:36 +0800 Subject: feat: implement persistent cookie storage and migrate configuration files to ~/.ricochet directory --- ricochet/src/core/browser.cc | 14 ++++++++++-- ricochet/src/net/http_client.cc | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index e9f1fce..03c34bc 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -23,10 +23,12 @@ #include "ricochet/render/renderer.hh" #include "ricochet/tui/terminal.hh" #include +#include #include #include #include #include +#include namespace ricochet::core { @@ -47,6 +49,14 @@ struct HttpRequest namespace { +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( mkdir( dir.c_str(), 0755 ) ); + return dir + "/" + filename; +} + std::string extract_title( const parser::DomNode& root_node ) { std::vector stack; @@ -93,7 +103,7 @@ void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappabl if ( url == "ricochet://bookmarks" ) { return; } - std::ofstream file( "bookmarks.txt", std::ios::app ); + std::ofstream file( get_config_path( "bookmarks.txt" ), std::ios::app ); if ( !file.is_open() ) { return; } @@ -112,7 +122,7 @@ void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappabl std::string generate_bookmarks_html() { - std::ifstream file( "bookmarks.txt" ); + std::ifstream file( get_config_path( "bookmarks.txt" ) ); std::string html = "My Bookmarks

Saved Bookmarks

    "; if ( !file.is_open() ) { html += "
  • No bookmarks found. Press 'b' on any page to add one!
  • "; diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index bbc5d17..5a56ac3 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -23,9 +23,12 @@ #include "ricochet/net/sys/socket.hh" #include +#include #include +#include #include #include +#include #include namespace ricochet::net { @@ -163,15 +166,55 @@ std::string extract_redirect_url( const std::string& raw_response, bool is_https return ""; } +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( mkdir( dir.c_str(), 0755 ) ); + return dir + "/" + filename; +} + +void load_cookies_from_file( std::unordered_map& 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 tab_pos = line.find( '\t' ); + if ( tab_pos != std::string::npos ) { + jar[line.substr( 0, tab_pos )] = line.substr( tab_pos + 1 ); + } + } +} + std::unordered_map& get_cookie_jar() { static std::unordered_map jar; + static bool initialized = false; + if ( !initialized ) { + load_cookies_from_file( jar ); + initialized = true; + } return jar; } +void save_cookies_to_file( const std::unordered_map& jar ) +{ + std::ofstream file( get_config_path( "cookies.txt" ), std::ios::trunc ); + if ( !file.is_open() ) { + return; + } + for ( const auto& [k, v] : jar ) { + file << k << '\t' << v << '\n'; + } +} + void update_cookies( const std::string& raw_response ) { 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 ); @@ -189,8 +232,12 @@ void update_cookies( const std::string& raw_response ) const std::string key = cookie_pair.substr( 0, eq_pos ); const std::string val = cookie_pair.substr( eq_pos + 1 ); get_cookie_jar()[key] = val; + updated = true; } } + if ( updated ) { + save_cookies_to_file( get_cookie_jar() ); + } } std::string build_cookie_header() -- cgit v1.2.3 From 56fdb119894074efbda90766277f484a8820fb12 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 03:17:55 +0800 Subject: feat: implement text search functionality with highlighting and navigation support --- ricochet/src/core/browser.cc | 99 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 9 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 03c34bc..f73f5b1 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -38,6 +38,10 @@ struct PageData std::vector links; std::vector inputs; std::string title; + std::vector original_lines {}; + std::string last_search {}; + std::size_t current_match_idx { 0 }; + std::vector match_lines {}; }; struct HttpRequest @@ -237,10 +241,9 @@ PageData load_page( const HttpRequest& req ) .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title }; } -// --- 🔪 智能底部排版:优先截断 URL,誓死保卫快捷键 --- std::string build_footer( std::string_view url, std::size_t width ) { - const std::string_view keys = " | [j/k]Scroll [/]Nav [f]Link [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; + const std::string_view keys = " | [j/k]Scroll [n/N]Find [/]Nav [f]Link [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; const std::string_view prefix = " URL: "; std::string footer; @@ -560,19 +563,83 @@ InputAction submit_form( const render::InputField& target_input, return InputAction::Navigate; } +void find_search_matches( PageData& page_data, const std::string& query ) +{ + page_data.last_search = query; + page_data.match_lines.clear(); + page_data.current_match_idx = 0; + for ( std::size_t i = 0; i < page_data.original_lines.size(); ++i ) { + if ( page_data.original_lines[i].find( query ) != std::string::npos ) { + page_data.match_lines.push_back( i ); + } + } +} + +void apply_search_highlight( PageData& page_data, const std::string& query ) +{ + page_data.lines = page_data.original_lines; + if ( page_data.match_lines.empty() ) { + return; + } + + const std::string hl_start = "\033[43;30m"; + const std::string hl_end = "\033[0m"; + + for ( const auto match_idx : page_data.match_lines ) { + auto& line = page_data.lines[match_idx]; + std::size_t pos = 0; + std::string replacement; + replacement.reserve( hl_start.length() + query.length() + hl_end.length() ); + replacement += hl_start; + replacement += query; + replacement += hl_end; + while ( ( pos = line.find( query, pos ) ) != std::string::npos ) { + line.replace( pos, query.length(), replacement ); + pos += hl_start.length() + query.length() + hl_end.length(); + } + } +} + +void execute_search( PageData& page_data, const std::string& query, std::size_t& scroll_y, bool next_match ) +{ + if ( query.empty() ) { + return; + } + + if ( !next_match || query != page_data.last_search ) { + find_search_matches( page_data, query ); + apply_search_highlight( page_data, query ); + } else if ( !page_data.match_lines.empty() ) { + page_data.current_match_idx = ( page_data.current_match_idx + 1 ) % page_data.match_lines.size(); + } + + if ( page_data.match_lines.empty() ) { + return; + } + + const std::size_t target_line = page_data.match_lines[page_data.current_match_idx]; + scroll_y = ( target_line > 5 ) ? ( target_line - 5 ) : 0; +} + void update_input_value( PageData& page_data, std::size_t index, const std::string& query ) { page_data.inputs[index - 1].value = query; const std::string marker = "[I" + std::to_string( index ) + ":"; - for ( auto& line : page_data.lines ) { - const std::size_t pos = line.find( marker ); - if ( pos != std::string::npos ) { - const std::size_t end = line.find( "]", pos ); - if ( end != std::string::npos ) { - line.replace( pos, end - pos + 1, "[I" + std::to_string( index ) + ":" + query + "]" ); + const std::string replacement = "[I" + std::to_string( index ) + ":" + query + "]"; + + auto update_lines = [&]( std::vector& lines_arr ) { + for ( auto& line : lines_arr ) { + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + const std::size_t end = line.find( ']', pos ); + if ( end != std::string::npos ) { + line.replace( pos, end - pos + 1, replacement ); + } } } - } + }; + update_lines( page_data.lines ); + update_lines( page_data.original_lines ); } void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ) @@ -606,6 +673,9 @@ void update_toggle_ui( PageData& page_data ) for ( auto& line : page_data.lines ) { update_single_line_ui( line, marker, in ); } + for ( auto& line : page_data.original_lines ) { + update_single_line_ui( line, marker, in ); + } } } @@ -699,6 +769,16 @@ InputAction process_key( char c, return InputAction::Navigate; } + if ( c == 'n' ) { + const std::string query = get_terminal_input( terminal, "Find in Page:" ); + execute_search( page_data, query, scroll_y, false ); + return InputAction::None; + } + if ( c == 'N' ) { + execute_search( page_data, page_data.last_search, scroll_y, true ); + return InputAction::None; + } + if ( c == '/' ) { const std::string next_url = get_url_input( terminal ); if ( !next_url.empty() ) { @@ -782,6 +862,7 @@ int Browser::run( std::string_view initial_url ) auto [term_width, term_height] = terminal.get_size(); const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; page_data.lines = wrap_lines( page_data.lines, wrap_width ); + page_data.original_lines = page_data.lines; std::size_t scroll_y = 0; bool navigate = false; -- cgit v1.2.3 From 08fdcf8e8a83d433db7e6f5d08442dd27a729a0f Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 03:52:07 +0800 Subject: feat: replace numeric link selection with two-character link hints --- ricochet/src/core/browser.cc | 160 ++++++++++++++++++++++++++------------ ricochet/src/parser/html_lexer.cc | 2 +- ricochet/src/render/renderer.cc | 2 +- 3 files changed, 111 insertions(+), 53 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index f73f5b1..779bdb2 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -243,7 +243,7 @@ PageData load_page( const HttpRequest& req ) std::string build_footer( std::string_view url, std::size_t width ) { - const std::string_view keys = " | [j/k]Scroll [n/N]Find [/]Nav [f]Link [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; + const std::string_view keys = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; const std::string_view prefix = " URL: "; std::string footer; @@ -378,39 +378,39 @@ std::string get_url_input( const tui::Terminal& terminal ) return format_url( std::move( input ) ); } -std::string get_link_input( const tui::Terminal& terminal ) -{ - auto [width, height] = terminal.get_size(); - terminal.move_cursor( height, 1 ); - std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; - std::cout.flush(); - - std::string num_input; - terminal.show_cursor( true ); - while ( true ) { - const char ch = terminal.read_key(); - if ( ch == '\r' || ch == '\n' ) { - break; - } - if ( ch == 27 ) { - num_input.clear(); - break; - } - if ( ch == 127 || ch == 8 ) { - if ( !num_input.empty() ) { - num_input.pop_back(); - std::cout << "\b \b"; - std::cout.flush(); - } - } else if ( std::isdigit( static_cast( ch ) ) ) { - num_input += ch; - std::cout << ch; - std::cout.flush(); - } - } - terminal.show_cursor( false ); - return num_input; -} +// std::string get_link_input( const tui::Terminal& terminal ) +// { +// auto [width, height] = terminal.get_size(); +// terminal.move_cursor( height, 1 ); +// std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; +// std::cout.flush(); + +// std::string num_input; +// terminal.show_cursor( true ); +// while ( true ) { +// const char ch = terminal.read_key(); +// if ( ch == '\r' || ch == '\n' ) { +// break; +// } +// if ( ch == 27 ) { +// num_input.clear(); +// break; +// } +// if ( ch == 127 || ch == 8 ) { +// if ( !num_input.empty() ) { +// num_input.pop_back(); +// std::cout << "\b \b"; +// std::cout.flush(); +// } +// } else if ( std::isdigit( static_cast( ch ) ) ) { +// num_input += ch; +// std::cout << ch; +// std::cout.flush(); +// } +// } +// terminal.show_cursor( false ); +// return num_input; +// } std::string normalize_url( const std::string& target, const std::string& current_url ) { @@ -458,28 +458,86 @@ std::string strip_ddg_tracker( std::string target ) return target; } +std::string index_to_hint( std::size_t index ) +{ + const std::string alphabet = "asdfghjklqwertyuiopzxcvbnm"; + std::string hint; + hint += alphabet[( index / alphabet.length() ) % alphabet.length()]; + hint += alphabet[index % alphabet.length()]; + return hint; +} + +void toggle_link_hints( PageData& page_data, bool show ) +{ + const std::string hl_start = "\033[0;1;35;7m "; + const std::string hl_end = " \033[0m"; + + std::vector formatted_hints; + if ( show ) { + formatted_hints.reserve( page_data.links.size() ); + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + std::string h = hl_start; + h += index_to_hint( i ); + h += hl_end; + formatted_hints.push_back( std::move( h ) ); + } + } + + for ( auto& line : page_data.lines ) { + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + const std::string marker = "{L:" + std::to_string( i + 1 ) + "}"; + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + if ( show ) { + line.replace( pos, marker.length(), formatted_hints[i] ); + } else { + const std::size_t end_pos = line.find( hl_end, pos ); + if ( end_pos != std::string::npos ) { + line.replace( pos, end_pos + hl_end.length() - pos, marker ); + } + } + } + } + } +} + void handle_follow_link( const tui::Terminal& terminal, - const std::vector& links, + PageData& page_data, std::string& current_url, + std::size_t& scroll_y, // NOLINT(bugprone-easily-swappable-parameters) bool& navigate ) { - const std::string num_input = get_link_input( terminal ); - if ( !num_input.empty() ) { - try { - const std::size_t index = std::stoul( num_input ); - if ( index > 0 && index <= links.size() ) { - std::string target = links[index - 1]; + toggle_link_hints( page_data, true ); - target = normalize_url( target, current_url ); - target = strip_ddg_tracker( target ); + draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); - current_url = target; - navigate = true; - } - } catch ( const std::exception& e ) { - (void)e; // NOLINT + auto [w, h] = terminal.get_size(); + terminal.move_cursor( h, 1 ); + std::cout << "\x1b[2K\033[1;35;7m Follow Hint (2 keys): \033[0m "; + std::cout.flush(); + + std::string input; + input += terminal.read_key(); + std::cout << input[0]; + std::cout.flush(); + input += terminal.read_key(); + + bool found = false; + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + if ( index_to_hint( i ) == input ) { + std::string target = page_data.links[i]; + target = normalize_url( target, current_url ); + target = strip_ddg_tracker( target ); + current_url = target; + navigate = true; + found = true; + break; } } + + if ( !found ) { + toggle_link_hints( page_data, false ); + } } enum class InputAction : std::uint8_t @@ -730,7 +788,7 @@ InputAction handle_form_input( const tui::Terminal& terminal, InputAction process_key( char c, const tui::Terminal& terminal, PageData& page_data, - std::string current_url, + std::string& current_url, std::vector& history, std::size_t& history_idx, // NOLINT(bugprone-easily-swappable-parameters) std::size_t& scroll_y ) // NOLINT(bugprone-easily-swappable-parameters) @@ -792,7 +850,7 @@ InputAction process_key( char c, if ( c == 'f' ) { const std::string old_url = current_url; bool nav = false; - handle_follow_link( terminal, page_data.links, current_url, nav ); + handle_follow_link( terminal, page_data, current_url, scroll_y, nav ); if ( nav && current_url != old_url ) { history.resize( history_idx + 1 ); history.push_back( HttpRequest { .url = current_url, .method = "GET", .body = "" } ); @@ -852,7 +910,7 @@ int Browser::run( std::string_view initial_url ) while ( true ) { const HttpRequest current_req = history[history_idx]; - const std::string current_url = current_req.url; + std::string current_url = current_req.url; terminal.clear_screen(); std::cout << "-> Fetching: " << current_req.url << "...\r\n"; std::cout.flush(); diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc index 2aa05b1..4d70288 100644 --- a/ricochet/src/parser/html_lexer.cc +++ b/ricochet/src/parser/html_lexer.cc @@ -36,7 +36,7 @@ enum class LexerState : std::uint8_t void emit_text( std::string& buffer, std::vector& tokens ) { if ( buffer.empty() ) { - return; // 提前返回,减少 if 嵌套层级 + return; } bool all_whitespace = true; diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index fda21c3..53eca1b 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -109,7 +109,7 @@ void render_link_end( const parser::DomNode& node, RenderResult& result ) 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[7m[" + std::to_string( result.links.size() ) + "]\033[0m"; + result.text += "\033[8m{L:" + std::to_string( result.links.size() ) + "}\033[0m"; } } -- cgit v1.2.3 From 683d9bea3890cf59e660231147aba02dd52fa619 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 04:08:27 +0800 Subject: feat: implement browsing history tracking and navigation via ricochet://history --- ricochet/src/core/browser.cc | 72 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 779bdb2..fb9af83 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -149,6 +149,60 @@ std::string generate_bookmarks_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 = "History

    Browsing History

      "; + if ( !file.is_open() ) { + html += "
    • No history found. Start browsing!
    • "; + } else { + std::vector 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 += "
    • [Link] "; + html += title; + html += "
    • "; + } + } + } + html += "
    "; + return html; +} + PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp ) { std::string fname = "download.dat"; @@ -201,6 +255,8 @@ 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 { const net::HttpClient client; auto response_result = client.fetch( req.url, req.method, req.body ); @@ -243,7 +299,8 @@ PageData load_page( const HttpRequest& req ) std::string build_footer( std::string_view url, std::size_t width ) { - const std::string_view keys = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; + const std::string_view keys + = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Back [h]Hist [b/B]Mark [q]Quit "; const std::string_view prefix = " URL: "; std::string footer; @@ -827,6 +884,13 @@ InputAction process_key( char c, return InputAction::Navigate; } + if ( c == 'h' ) { + history.resize( history_idx + 1 ); + history.push_back( HttpRequest { .url = "ricochet://history", .method = "GET", .body = "" } ); + history_idx++; + return InputAction::Navigate; + } + if ( c == 'n' ) { const std::string query = get_terminal_input( terminal, "Find in Page:" ); execute_search( page_data, query, scroll_y, false ); @@ -917,6 +981,12 @@ int Browser::run( std::string_view initial_url ) auto page_data = load_page( current_req ); + static std::string last_recorded_url; + if ( current_url != last_recorded_url && !current_url.starts_with( "ricochet://" ) ) { + append_to_history( current_url, page_data.title ); + last_recorded_url = current_url; + } + auto [term_width, term_height] = terminal.get_size(); const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; page_data.lines = wrap_lines( page_data.lines, wrap_width ); -- cgit v1.2.3 From c495d71d413da0db8be510e290787fd576835842 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 04:18:17 +0800 Subject: feat: add help manual page and refactor key processing with switch statement --- ricochet/src/core/browser.cc | 179 ++++++++++++++++++++++++++----------------- 1 file changed, 109 insertions(+), 70 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index fb9af83..dd96c7c 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -203,6 +203,40 @@ std::string generate_history_html() return html; } +std::string generate_help_html() +{ + return R"( + Ricochet Help Manual + +

    Ricochet User Manual

    +

    1. Navigation & Browsing

    +
      +
    • j / k : Scroll down / up
    • +
    • H / L : History backward / forward
    • +
    • / : Open prompt to enter a new URL
    • +
    +

    2. Page Interaction

    +
      +
    • f : Toggle Link Hints (type the two letters to follow a link instantly)
    • +
    • n : Find in page (search for text)
    • +
    • N : Jump to the next search match
    • +
    • i : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)
    • +
    +

    3. Bookmarks & History

    +
      +
    • b : Bookmark the current page
    • +
    • B : View all saved bookmarks
    • +
    • h : View browsing history
    • +
    +

    4. System

    +
      +
    • ? : Show this help manual
    • +
    • q : Quit Ricochet
    • +
    + + )"; +} + PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp ) { std::string fname = "download.dat"; @@ -257,6 +291,8 @@ PageData load_page( const HttpRequest& req ) 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 ); @@ -300,7 +336,7 @@ PageData load_page( const HttpRequest& req ) std::string build_footer( std::string_view url, std::size_t width ) { const std::string_view keys - = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Back [h]Hist [b/B]Mark [q]Quit "; + = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Back [h]Hist [b/B]Mark [?]Help [q]Quit "; const std::string_view prefix = " URL: "; std::string footer; @@ -842,6 +878,14 @@ InputAction handle_form_input( const tui::Terminal& terminal, return InputAction::None; } +InputAction push_history( const std::string& url, std::vector& history, std::size_t& history_idx ) +{ + history.resize( history_idx + 1 ); + history.push_back( HttpRequest { .url = url, .method = "GET", .body = "" } ); + history_idx++; + return InputAction::Navigate; +} + InputAction process_key( char c, const tui::Terminal& terminal, PageData& page_data, @@ -853,78 +897,73 @@ InputAction process_key( char c, auto [width, height] = terminal.get_size(); const std::size_t view_h = ( height > 1 ) ? ( height - 1 ) : 1; - if ( c == 'q' ) { - return InputAction::Quit; - } - if ( c == 'j' && scroll_y + view_h < page_data.lines.size() ) { - scroll_y++; - } - if ( c == 'k' && scroll_y > 0 ) { - scroll_y--; - } - if ( c == 'H' && history_idx > 0 ) { - history_idx--; - return InputAction::Navigate; - } - if ( c == 'L' && history_idx + 1 < history.size() ) { - history_idx++; - return InputAction::Navigate; - } - if ( c == 'r' ) { - return InputAction::Navigate; - } - if ( c == 'b' ) { - save_bookmark( page_data.title, current_url ); - return InputAction::None; - } - if ( c == 'B' ) { - history.resize( history_idx + 1 ); - history.push_back( HttpRequest { .url = "ricochet://bookmarks", .method = "GET", .body = "" } ); - history_idx++; - return InputAction::Navigate; - } - - if ( c == 'h' ) { - history.resize( history_idx + 1 ); - history.push_back( HttpRequest { .url = "ricochet://history", .method = "GET", .body = "" } ); - history_idx++; - return InputAction::Navigate; - } - - if ( c == 'n' ) { - const std::string query = get_terminal_input( terminal, "Find in Page:" ); - execute_search( page_data, query, scroll_y, false ); - return InputAction::None; - } - if ( c == 'N' ) { - execute_search( page_data, page_data.last_search, scroll_y, true ); - return InputAction::None; - } - - if ( c == '/' ) { - const std::string next_url = get_url_input( terminal ); - if ( !next_url.empty() ) { - history.resize( history_idx + 1 ); - history.push_back( HttpRequest { .url = next_url, .method = "GET", .body = "" } ); - history_idx++; + switch ( c ) { + case 'q': + return InputAction::Quit; + case 'j': + if ( scroll_y + view_h < page_data.lines.size() ) { + scroll_y++; + } + break; + case 'k': + if ( scroll_y > 0 ) { + scroll_y--; + } + break; + case 'H': + if ( history_idx > 0 ) { + history_idx--; + return InputAction::Navigate; + } + break; + case 'L': + if ( history_idx + 1 < history.size() ) { + history_idx++; + return InputAction::Navigate; + } + break; + case 'r': return InputAction::Navigate; + case 'b': + save_bookmark( page_data.title, current_url ); + break; + case 'B': + return push_history( "ricochet://bookmarks", history, history_idx ); + case '?': + return push_history( "ricochet://help", history, history_idx ); + case 'h': + return push_history( "ricochet://history", history, history_idx ); + case 'n': { + const std::string query = get_terminal_input( terminal, "Find in Page:" ); + execute_search( page_data, query, scroll_y, false ); + break; } - } - - if ( c == 'f' ) { - const std::string old_url = current_url; - bool nav = false; - handle_follow_link( terminal, page_data, current_url, scroll_y, nav ); - if ( nav && current_url != old_url ) { - history.resize( history_idx + 1 ); - history.push_back( HttpRequest { .url = current_url, .method = "GET", .body = "" } ); - history_idx++; - return InputAction::Navigate; + case 'N': + execute_search( page_data, page_data.last_search, scroll_y, true ); + break; + case '/': { + const std::string next_url = get_url_input( terminal ); + if ( !next_url.empty() ) { + return push_history( next_url, history, history_idx ); + } + break; } - } - - if ( c == 'i' && !page_data.inputs.empty() ) { - return handle_form_input( terminal, page_data, current_url, history, history_idx ); + case 'f': { + const std::string old_url = current_url; + bool nav = false; + handle_follow_link( terminal, page_data, current_url, scroll_y, nav ); + if ( nav && current_url != old_url ) { + return push_history( current_url, history, history_idx ); + } + break; + } + case 'i': + if ( !page_data.inputs.empty() ) { + return handle_form_input( terminal, page_data, current_url, history, history_idx ); + } + break; + default: + break; } return InputAction::None; } -- cgit v1.2.3 From 2141017d7c688a0f6659c3ffe5730f281f7551ae Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 04:34:50 +0800 Subject: feat: implement configurable homepage and user-agent via config.txt --- ricochet/include/ricochet/core/browser.hh | 1 + ricochet/src/core/browser.cc | 15 ++++++++ ricochet/src/main.cc | 28 +++++++------- ricochet/src/net/http_client.cc | 61 ++++++++++++++++++++++++------- 4 files changed, 77 insertions(+), 28 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/include/ricochet/core/browser.hh b/ricochet/include/ricochet/core/browser.hh index a8d2cd3..36e876d 100644 --- a/ricochet/include/ricochet/core/browser.hh +++ b/ricochet/include/ricochet/core/browser.hh @@ -33,6 +33,7 @@ public: Browser( Browser&& ) = delete; Browser& operator=( Browser&& ) = delete; + static std::string get_configured_homepage(); static int run( std::string_view initial_url ); }; diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index dd96c7c..571783a 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -1003,6 +1003,21 @@ std::vector wrap_lines( const std::vector& original_li } // namespace +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"; +} + int Browser::run( std::string_view initial_url ) { std::vector history; diff --git a/ricochet/src/main.cc b/ricochet/src/main.cc index 1037833..562861a 100644 --- a/ricochet/src/main.cc +++ b/ricochet/src/main.cc @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +#include #include #include #include @@ -41,28 +42,27 @@ int main( int argc, char* argv[] ) ricochet::core::print_license_notice(); + std::string start_url; if ( args.size() < 2 ) { - std::cerr << "Error: No URL provided.\n"; - print_usage( args[0] ); - return 1; + start_url = ricochet::core::Browser::get_configured_homepage(); + } else { + start_url = std::string( args[1] ); } - const std::string_view input = args[1]; - - if ( input == "show" ) { - if ( args[2] == "w" ) { + if ( args.size() >= 2 && args[1] == "show" ) { + if ( args.size() >= 3 && args[2] == "w" ) { ricochet::core::show_warranty(); - } else if ( args[2] == "c" ) { + return 0; + } + if ( args.size() >= 3 && args[2] == "c" ) { ricochet::core::show_copying(); - } else { - std::cerr << "Error: Invalid argument for show command.\n"; - print_usage( args[0] ); - return 1; + return 0; } - return 0; + std::cerr << "Error: Invalid argument for show command.\n"; + print_usage( args[0] ); + return 1; } - std::string start_url( input ); if ( !start_url.starts_with( "http://" ) && !start_url.starts_with( "https://" ) ) { start_url.insert( 0, "https://" ); } diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 2ed33e3..5f5b449 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -174,6 +174,40 @@ std::string get_config_path( const std::string& filename ) return dir + "/" + filename; } +struct BrowserConfig +{ + std::string 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"; + std::string homepage = "https://wikipedia.org"; +}; + +BrowserConfig& get_browser_config() +{ + static BrowserConfig config; + 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; +} + using CookieJar = std::unordered_map>; void load_cookies_from_file( CookieJar& jar ) @@ -290,20 +324,19 @@ FetchResult do_network_request( const std::string& host, FetchResult res; try { const Address server_addr( host, service ); - std::string request - = std::format( "{} {} HTTP/1.1\r\n" - "Host: {}\r\n" - "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\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, - cookie_header ); + 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", -- cgit v1.2.3 From e84e117735f7d922f84cd13f56f6eea93b204e5c Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 04:50:49 +0800 Subject: feat: move URL formatting to Browser class and update documentation --- ricochet/README.md | 83 ++++++++++++++++++++++++------- ricochet/include/ricochet/core/browser.hh | 1 + ricochet/src/core/browser.cc | 50 +++++++++---------- ricochet/src/main.cc | 17 +++---- 4 files changed, 99 insertions(+), 52 deletions(-) (limited to 'ricochet/src/core') diff --git a/ricochet/README.md b/ricochet/README.md index fd300d2..a09bf4a 100644 --- a/ricochet/README.md +++ b/ricochet/README.md @@ -9,6 +9,19 @@ Modern browsing has become a minefield of non-free scripts, tracking pixels, and * **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. @@ -23,6 +36,40 @@ Ricochet is built with a strictly modular approach: * **`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 @@ -33,26 +80,35 @@ To build Ricochet, ensure your environment meets the following requirements: * `clang-format` for automated code styling. * `clang-tidy` for static analysis and linting. -### Build Instructions +### 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 -# 1. Clone the repository 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 +#### 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 +#### 3. Compile the project using all available cores +```bash cmake --build build -j$(nproc) +``` -# 4. Launch Ricochet -# The executable is located in the bin/ directory -./build/bin/ricochet https://wikipedia.org +### 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=` to the initial `cmake -B build` command. @@ -94,15 +150,8 @@ To install the compiled binaries to your system, use: cmake --install build --prefix /usr/local ``` This installs the `ricochet` binary to `bin` and supporting libraries to `lib`. -## Navigation -| Key | Action | -| :--- | :--- | -| `j / k` | Scroll Down / Up | -| `Tab` | Cycle through links | -| `Enter` | Follow selected link | -| `b` | Go Back in history | -| `u` | Enter new URL | -| `q` | Quit Ricochet | + + ## 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. diff --git a/ricochet/include/ricochet/core/browser.hh b/ricochet/include/ricochet/core/browser.hh index 36e876d..ab7a473 100644 --- a/ricochet/include/ricochet/core/browser.hh +++ b/ricochet/include/ricochet/core/browser.hh @@ -33,6 +33,7 @@ public: Browser( Browser&& ) = delete; Browser& operator=( Browser&& ) = delete; + static std::string format_url( std::string input ); static std::string get_configured_homepage(); static int run( std::string_view initial_url ); }; diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 571783a..c4dbb08 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -412,30 +412,6 @@ void draw_view( const tui::Terminal& terminal, std::cout.flush(); } -std::string 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 get_url_input( const tui::Terminal& terminal ) { auto [width, height] = terminal.get_size(); @@ -468,7 +444,7 @@ std::string get_url_input( const tui::Terminal& terminal ) } terminal.show_cursor( false ); - return format_url( std::move( input ) ); + return Browser::format_url( std::move( input ) ); } // std::string get_link_input( const tui::Terminal& terminal ) @@ -1003,6 +979,30 @@ std::vector wrap_lines( const std::vector& original_li } // namespace +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" ) ); diff --git a/ricochet/src/main.cc b/ricochet/src/main.cc index 562861a..d27ab55 100644 --- a/ricochet/src/main.cc +++ b/ricochet/src/main.cc @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -#include #include #include #include @@ -42,13 +41,6 @@ int main( int argc, char* argv[] ) ricochet::core::print_license_notice(); - std::string start_url; - if ( args.size() < 2 ) { - start_url = ricochet::core::Browser::get_configured_homepage(); - } else { - start_url = std::string( args[1] ); - } - if ( args.size() >= 2 && args[1] == "show" ) { if ( args.size() >= 3 && args[2] == "w" ) { ricochet::core::show_warranty(); @@ -63,10 +55,15 @@ int main( int argc, char* argv[] ) return 1; } - if ( !start_url.starts_with( "http://" ) && !start_url.starts_with( "https://" ) ) { - start_url.insert( 0, "https://" ); + 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 ) { -- cgit v1.2.3 From 77e910cc68f08eb547c45318a050a23191f65209 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Tue, 14 Apr 2026 14:38:42 +0800 Subject: refactor: adjust code structure --- ricochet/include/ricochet/core/form_handler.hh | 38 + ricochet/include/ricochet/core/link_hints.hh | 27 + ricochet/include/ricochet/core/local_data.hh | 32 + ricochet/include/ricochet/core/page_loader.hh | 32 + ricochet/include/ricochet/core/page_processor.hh | 32 + ricochet/include/ricochet/core/search_handler.hh | 23 + ricochet/include/ricochet/core/types.hh | 54 ++ ricochet/include/ricochet/core/ui_input.hh | 29 + ricochet/include/ricochet/core/ui_view.hh | 37 + ricochet/include/ricochet/core/utils.hh | 32 + ricochet/include/ricochet/net/config.hh | 35 + ricochet/include/ricochet/net/cookie_jar.hh | 35 + ricochet/include/ricochet/net/http_client.hh | 3 +- ricochet/include/ricochet/net/http_utils.hh | 30 + ricochet/include/ricochet/net/url_parser.hh | 37 + ricochet/include/ricochet/render/input_renderer.hh | 34 + ricochet/include/ricochet/render/internal.hh | 35 + ricochet/include/ricochet/render/table_renderer.hh | 30 + ricochet/include/ricochet/render/utils.hh | 33 + ricochet/src/core/browser.cc | 870 +-------------------- ricochet/src/core/form_handler.cc | 165 ++++ ricochet/src/core/link_hints.cc | 104 +++ ricochet/src/core/local_data.cc | 171 ++++ ricochet/src/core/page_loader.cc | 78 ++ ricochet/src/core/page_processor.cc | 97 +++ ricochet/src/core/search_handler.cc | 81 ++ ricochet/src/core/ui_input.cc | 94 +++ ricochet/src/core/ui_view.cc | 103 +++ ricochet/src/core/utils.cc | 118 +++ ricochet/src/net/config.cc | 64 ++ ricochet/src/net/cookie_jar.cc | 124 +++ ricochet/src/net/http_client.cc | 393 ++-------- ricochet/src/net/http_utils.cc | 93 +++ ricochet/src/net/url_parser.cc | 87 +++ ricochet/src/render/input_renderer.cc | 82 ++ ricochet/src/render/renderer.cc | 303 +------ ricochet/src/render/table_renderer.cc | 147 ++++ ricochet/src/render/utils.cc | 135 ++++ 38 files changed, 2434 insertions(+), 1483 deletions(-) create mode 100644 ricochet/include/ricochet/core/form_handler.hh create mode 100644 ricochet/include/ricochet/core/link_hints.hh create mode 100644 ricochet/include/ricochet/core/local_data.hh create mode 100644 ricochet/include/ricochet/core/page_loader.hh create mode 100644 ricochet/include/ricochet/core/page_processor.hh create mode 100644 ricochet/include/ricochet/core/search_handler.hh create mode 100644 ricochet/include/ricochet/core/types.hh create mode 100644 ricochet/include/ricochet/core/ui_input.hh create mode 100644 ricochet/include/ricochet/core/ui_view.hh create mode 100644 ricochet/include/ricochet/core/utils.hh create mode 100644 ricochet/include/ricochet/net/config.hh create mode 100644 ricochet/include/ricochet/net/cookie_jar.hh create mode 100644 ricochet/include/ricochet/net/http_utils.hh create mode 100644 ricochet/include/ricochet/net/url_parser.hh create mode 100644 ricochet/include/ricochet/render/input_renderer.hh create mode 100644 ricochet/include/ricochet/render/internal.hh create mode 100644 ricochet/include/ricochet/render/table_renderer.hh create mode 100644 ricochet/include/ricochet/render/utils.hh create mode 100644 ricochet/src/core/form_handler.cc create mode 100644 ricochet/src/core/link_hints.cc create mode 100644 ricochet/src/core/local_data.cc create mode 100644 ricochet/src/core/page_loader.cc create mode 100644 ricochet/src/core/page_processor.cc create mode 100644 ricochet/src/core/search_handler.cc create mode 100644 ricochet/src/core/ui_input.cc create mode 100644 ricochet/src/core/ui_view.cc create mode 100644 ricochet/src/core/utils.cc create mode 100644 ricochet/src/net/config.cc create mode 100644 ricochet/src/net/cookie_jar.cc create mode 100644 ricochet/src/net/http_utils.cc create mode 100644 ricochet/src/net/url_parser.cc create mode 100644 ricochet/src/render/input_renderer.cc create mode 100644 ricochet/src/render/table_renderer.cc create mode 100644 ricochet/src/render/utils.cc (limited to 'ricochet/src/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..5abf148 --- /dev/null +++ b/ricochet/include/ricochet/core/form_handler.hh @@ -0,0 +1,38 @@ +/* + * 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 "ricochet/tui/terminal.hh" +#include +#include +#include + +namespace ricochet::core { + +InputAction submit_form( const render::InputField& target_input, + const std::vector& inputs, + const std::string& current_url, + std::vector& history, + std::size_t& history_idx ); + +void update_input_value( PageData& page_data, std::size_t index, const std::string& query ); +void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ); +void update_toggle_ui( PageData& page_data ); + +InputAction handle_form_input( const tui::Terminal& terminal, + PageData& page_data, + const std::string& current_url, + std::vector& history, + std::size_t& history_idx ); + +} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/link_hints.hh b/ricochet/include/ricochet/core/link_hints.hh new file mode 100644 index 0000000..5da4846 --- /dev/null +++ b/ricochet/include/ricochet/core/link_hints.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/tui/terminal.hh" +#include + +namespace ricochet::core { + +std::string index_to_hint( std::size_t index ); +void toggle_link_hints( PageData& page_data, bool show ); +void handle_follow_link( const tui::Terminal& terminal, + PageData& page_data, + std::string& current_url, + std::size_t& scroll_y, + bool& navigate ); + +} // namespace ricochet::core 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 . + */ + +#pragma once + +#include + +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 . + */ + +#pragma once + +#include "ricochet/core/types.hh" +// #include "ricochet/net/http_client.hh" +// #include "ricochet/parser/dom_tree.hh" +// #include + +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 . + */ + +#pragma once + +#include "ricochet/core/types.hh" +#include "ricochet/net/http_client.hh" +#include "ricochet/parser/dom_tree.hh" +#include + +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/search_handler.hh b/ricochet/include/ricochet/core/search_handler.hh new file mode 100644 index 0000000..7053be8 --- /dev/null +++ b/ricochet/include/ricochet/core/search_handler.hh @@ -0,0 +1,23 @@ +/* + * 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 +#include + +namespace ricochet::core { + +void find_search_matches( PageData& page_data, const std::string& query ); +void apply_search_highlight( PageData& page_data, const std::string& query ); +void execute_search( PageData& page_data, const std::string& query, std::size_t& scroll_y, bool next_match ); + +} // 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..bd7e819 --- /dev/null +++ b/ricochet/include/ricochet/core/types.hh @@ -0,0 +1,54 @@ +/* + * 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 . + */ + +#pragma once + +#include "ricochet/render/renderer.hh" +#include +#include +#include + +namespace ricochet::core { + +struct PageData +{ + std::vector lines; + std::vector links; + std::vector inputs; + std::string title; + std::vector original_lines {}; + std::string last_search {}; + std::size_t current_match_idx { 0 }; + std::vector match_lines {}; +}; + +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/ui_input.hh b/ricochet/include/ricochet/core/ui_input.hh new file mode 100644 index 0000000..bf71685 --- /dev/null +++ b/ricochet/include/ricochet/core/ui_input.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 . + */ + +#pragma once + +#include "ricochet/tui/terminal.hh" +#include + +namespace ricochet::core { + +std::string get_url_input( const tui::Terminal& terminal ); +std::string get_terminal_input( const tui::Terminal& terminal, const std::string& prompt ); + +} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/ui_view.hh b/ricochet/include/ricochet/core/ui_view.hh new file mode 100644 index 0000000..d9a55fa --- /dev/null +++ b/ricochet/include/ricochet/core/ui_view.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 . + */ + +#pragma once + +#include "ricochet/tui/terminal.hh" + +#include +#include +#include + +namespace ricochet::core { + +std::string build_footer( std::string_view url, std::size_t width ); + +void draw_view( const tui::Terminal& terminal, + const std::vector& lines, + std::size_t scroll_y, + std::string_view url, + std::string_view title ); + +} // 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..d9f58dd --- /dev/null +++ b/ricochet/include/ricochet/core/utils.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 . + */ + +#pragma once + +#include +#include +#include + +namespace ricochet::core { + +std::vector 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 ); +std::vector wrap_lines( const std::vector& original_lines, std::size_t wrap_width ); + +} // 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 . + */ + +#pragma once + +#include + +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 . + */ + +#pragma once + +#include +#include + +namespace ricochet::net { + +using CookieJar = std::unordered_map>; + +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 index f6d8aa3..f57cbcf 100644 --- a/ricochet/include/ricochet/net/http_client.hh +++ b/ricochet/include/ricochet/net/http_client.hh @@ -53,7 +53,8 @@ public: * @return Returns HttpResponse on success, returns an error message string on failure */ std::expected fetch( std::string_view url, - std::string_view method = "GET", + std::string_view method + = "GET", // NOLINT(bugprone-easily-swappable-parameters) std::string_view body = "" ) const; private: 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 . + */ + +#pragma once + +#include +#include + +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/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 . + */ + +#pragma once + +#include +#include +#include + +namespace ricochet::net { + +struct ParsedUrl +{ + std::string host {}; + std::string path {}; +}; + +std::expected 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/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 . + */ + +#pragma once + +#include "ricochet/parser/dom_tree.hh" +#include "ricochet/render/renderer.hh" +#include + +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 . + */ + +#pragma once + +#include "ricochet/parser/dom_tree.hh" +#include "ricochet/render/renderer.hh" +#include + +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/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 . + */ + +#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 . + */ + +#pragma once + +#include +#include + +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/src/core/browser.cc b/ricochet/src/core/browser.cc index c4dbb08..86a5096 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -17,843 +17,26 @@ */ #include "ricochet/core/browser.hh" -#include "ricochet/net/http_client.hh" -#include "ricochet/parser/html_lexer.hh" -#include "ricochet/parser/tree_builder.hh" -#include "ricochet/render/renderer.hh" -#include "ricochet/tui/terminal.hh" + #include #include -#include #include #include -#include #include -namespace ricochet::core { +#include "ricochet/core/form_handler.hh" +#include "ricochet/core/link_hints.hh" +#include "ricochet/core/local_data.hh" +#include "ricochet/core/page_loader.hh" +#include "ricochet/core/search_handler.hh" +#include "ricochet/core/ui_input.hh" +#include "ricochet/core/ui_view.hh" +#include "ricochet/core/utils.hh" -struct PageData -{ - std::vector lines; - std::vector links; - std::vector inputs; - std::string title; - std::vector original_lines {}; - std::string last_search {}; - std::size_t current_match_idx { 0 }; - std::vector match_lines {}; -}; - -struct HttpRequest -{ - std::string url {}; - std::string method = "GET"; - std::string body {}; -}; +namespace ricochet::core { namespace { -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( mkdir( dir.c_str(), 0755 ) ); - return dir + "/" + filename; -} - -std::string extract_title( const parser::DomNode& root_node ) -{ - std::vector 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( 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 ""; -} - -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 = "My Bookmarks

    Saved Bookmarks

      "; - if ( !file.is_open() ) { - html += "
    • No bookmarks found. Press 'b' on any page to add one!
    • "; - } 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 += "
    • [Link] "; - html += title; - html += "
    • "; - } - } - } - 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 = "History

    Browsing History

      "; - if ( !file.is_open() ) { - html += "
    • No history found. Start browsing!
    • "; - } else { - std::vector 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 += "
    • [Link] "; - html += title; - html += "
    • "; - } - } - } - html += "
    "; - return html; -} - -std::string generate_help_html() -{ - return R"( - Ricochet Help Manual - -

    Ricochet User Manual

    -

    1. Navigation & Browsing

    -
      -
    • j / k : Scroll down / up
    • -
    • H / L : History backward / forward
    • -
    • / : Open prompt to enter a new URL
    • -
    -

    2. Page Interaction

    -
      -
    • f : Toggle Link Hints (type the two letters to follow a link instantly)
    • -
    • n : Find in page (search for text)
    • -
    • N : Jump to the next search match
    • -
    • i : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)
    • -
    -

    3. Bookmarks & History

    -
      -
    • b : Bookmark the current page
    • -
    • B : View all saved bookmarks
    • -
    • h : View browsing history
    • -
    -

    4. System

    -
      -
    • ? : Show this help manual
    • -
    • q : Quit Ricochet
    • -
    - - )"; -} - -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( 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" }; -} - -std::vector split_into_lines( const std::string& text ) -{ - std::vector 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; -} - -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( 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 }; -} - -std::string build_footer( std::string_view url, std::size_t width ) -{ - const std::string_view keys - = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Back [h]Hist [b/B]Mark [?]Help [q]Quit "; - const std::string_view prefix = " URL: "; - - std::string footer; - if ( width <= keys.length() + prefix.length() + 3 ) { - footer.reserve( prefix.length() + url.length() + keys.length() ); - footer += prefix; - footer += url; - footer += keys; - if ( footer.length() > width && width > 3 ) { - footer.erase( width - 3 ); - footer += "..."; - } - } else { - const std::size_t max_url_len = width - keys.length() - prefix.length(); - footer.reserve( width ); - footer += prefix; - if ( url.length() > max_url_len ) { - footer += url.substr( 0, max_url_len - 3 ); - footer += "..."; - } else { - footer += url; - } - footer += keys; - } - - if ( footer.length() < width ) { - footer.append( width - footer.length(), ' ' ); - } - return footer; -} - -void draw_view( const tui::Terminal& terminal, - const std::vector& lines, - std::size_t scroll_y, - std::string_view url, // NOLINT(bugprone-easily-swappable-parameters) - std::string_view title ) // NOLINT(bugprone-easily-swappable-parameters) -{ - terminal.clear_screen(); - auto [width, height] = terminal.get_size(); - const std::size_t usable_height = ( height > 2 ) ? ( height - 2 ) : 1; - - std::string clean_title( title ); - if ( clean_title.empty() ) { - const std::size_t start = url.find( "://" ); - const std::size_t host_start = ( start == std::string::npos ) ? 0 : start + 3; - const std::size_t host_end = url.find( '/', host_start ); - clean_title = std::string( url.substr( host_start, host_end - host_start ) ); - } - for ( char& c : clean_title ) { - if ( c == '\n' || c == '\r' ) { - c = ' '; - } - } - - std::string header = " PAGE: " + clean_title; - if ( header.size() < width ) { - header.append( width - header.size(), ' ' ); - } else if ( header.size() > width && width > 3 ) { - header = header.substr( 0, width - 3 ) + "..."; - } - std::cout << "\033[7m" << header << "\033[0m\r\n"; - - for ( std::size_t i = 0; i < usable_height; ++i ) { - if ( scroll_y + i < lines.size() ) { - std::cout << lines[scroll_y + i] << "\r\n"; - } else { - std::cout << "~\r\n"; - } - } - - const std::string footer = build_footer( url, width ); - std::cout << "\033[7m" << footer << "\033[0m"; - std::cout.flush(); -} - -std::string get_url_input( const tui::Terminal& terminal ) -{ - auto [width, height] = terminal.get_size(); - terminal.move_cursor( height, 1 ); - std::cout << "\x1b[2K\033[7m URL: \033[0m "; - std::cout.flush(); - - std::string input; - terminal.show_cursor( true ); - while ( true ) { - const char ch = terminal.read_key(); - if ( ch == '\r' || ch == '\n' ) { - break; - } - if ( ch == 27 ) { - input.clear(); - break; - } - if ( ch == 127 || ch == 8 ) { - if ( !input.empty() ) { - input.pop_back(); - std::cout << "\b \b"; - std::cout.flush(); - } - } else if ( std::isprint( static_cast( ch ) ) ) { - input += ch; - std::cout << ch; - std::cout.flush(); - } - } - terminal.show_cursor( false ); - - return Browser::format_url( std::move( input ) ); -} - -// std::string get_link_input( const tui::Terminal& terminal ) -// { -// auto [width, height] = terminal.get_size(); -// terminal.move_cursor( height, 1 ); -// std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; -// std::cout.flush(); - -// std::string num_input; -// terminal.show_cursor( true ); -// while ( true ) { -// const char ch = terminal.read_key(); -// if ( ch == '\r' || ch == '\n' ) { -// break; -// } -// if ( ch == 27 ) { -// num_input.clear(); -// break; -// } -// if ( ch == 127 || ch == 8 ) { -// if ( !num_input.empty() ) { -// num_input.pop_back(); -// std::cout << "\b \b"; -// std::cout.flush(); -// } -// } else if ( std::isdigit( static_cast( ch ) ) ) { -// num_input += ch; -// std::cout << ch; -// std::cout.flush(); -// } -// } -// terminal.show_cursor( false ); -// return num_input; -// } - -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( std::stoi( hex, nullptr, 16 ) ); - } catch ( const std::exception& ) { - (void)0; - } - i += 2; - } else { - decoded += encoded[i]; - } - } - return decoded; - } - return target; -} - -std::string index_to_hint( std::size_t index ) -{ - const std::string alphabet = "asdfghjklqwertyuiopzxcvbnm"; - std::string hint; - hint += alphabet[( index / alphabet.length() ) % alphabet.length()]; - hint += alphabet[index % alphabet.length()]; - return hint; -} - -void toggle_link_hints( PageData& page_data, bool show ) -{ - const std::string hl_start = "\033[0;1;35;7m "; - const std::string hl_end = " \033[0m"; - - std::vector formatted_hints; - if ( show ) { - formatted_hints.reserve( page_data.links.size() ); - for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { - std::string h = hl_start; - h += index_to_hint( i ); - h += hl_end; - formatted_hints.push_back( std::move( h ) ); - } - } - - for ( auto& line : page_data.lines ) { - for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { - const std::string marker = "{L:" + std::to_string( i + 1 ) + "}"; - const std::size_t pos = line.find( marker ); - if ( pos != std::string::npos ) { - if ( show ) { - line.replace( pos, marker.length(), formatted_hints[i] ); - } else { - const std::size_t end_pos = line.find( hl_end, pos ); - if ( end_pos != std::string::npos ) { - line.replace( pos, end_pos + hl_end.length() - pos, marker ); - } - } - } - } - } -} - -void handle_follow_link( const tui::Terminal& terminal, - PageData& page_data, - std::string& current_url, - std::size_t& scroll_y, // NOLINT(bugprone-easily-swappable-parameters) - bool& navigate ) -{ - toggle_link_hints( page_data, true ); - - draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); - - auto [w, h] = terminal.get_size(); - terminal.move_cursor( h, 1 ); - std::cout << "\x1b[2K\033[1;35;7m Follow Hint (2 keys): \033[0m "; - std::cout.flush(); - - std::string input; - input += terminal.read_key(); - std::cout << input[0]; - std::cout.flush(); - input += terminal.read_key(); - - bool found = false; - for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { - if ( index_to_hint( i ) == input ) { - std::string target = page_data.links[i]; - target = normalize_url( target, current_url ); - target = strip_ddg_tracker( target ); - current_url = target; - navigate = true; - found = true; - break; - } - } - - if ( !found ) { - toggle_link_hints( page_data, false ); - } -} - -enum class InputAction : std::uint8_t -{ - None, - Quit, - Navigate -}; - -std::string get_terminal_input( const tui::Terminal& terminal, const std::string& prompt ) -{ - auto [width, height] = terminal.get_size(); - terminal.move_cursor( height, 1 ); - std::cout << "\x1b[2K\033[7m " << prompt << " \033[0m "; - std::cout.flush(); - - std::string input; - terminal.show_cursor( true ); - while ( true ) { - const char ch = terminal.read_key(); - if ( ch == '\r' || ch == '\n' ) { - break; - } - if ( ch == 27 ) { - input.clear(); - break; - } - if ( ch == 127 || ch == 8 ) { - if ( !input.empty() ) { - input.pop_back(); - std::cout << "\b \b"; - std::cout.flush(); - } - } else if ( std::isprint( static_cast( ch ) ) ) { - input += ch; - std::cout << ch; - std::cout.flush(); - } - } - terminal.show_cursor( false ); - return input; -} - -InputAction submit_form( const render::InputField& target_input, - const std::vector& inputs, - const std::string& current_url, - std::vector& 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; -} - -void find_search_matches( PageData& page_data, const std::string& query ) -{ - page_data.last_search = query; - page_data.match_lines.clear(); - page_data.current_match_idx = 0; - for ( std::size_t i = 0; i < page_data.original_lines.size(); ++i ) { - if ( page_data.original_lines[i].find( query ) != std::string::npos ) { - page_data.match_lines.push_back( i ); - } - } -} - -void apply_search_highlight( PageData& page_data, const std::string& query ) -{ - page_data.lines = page_data.original_lines; - if ( page_data.match_lines.empty() ) { - return; - } - - const std::string hl_start = "\033[43;30m"; - const std::string hl_end = "\033[0m"; - - for ( const auto match_idx : page_data.match_lines ) { - auto& line = page_data.lines[match_idx]; - std::size_t pos = 0; - std::string replacement; - replacement.reserve( hl_start.length() + query.length() + hl_end.length() ); - replacement += hl_start; - replacement += query; - replacement += hl_end; - while ( ( pos = line.find( query, pos ) ) != std::string::npos ) { - line.replace( pos, query.length(), replacement ); - pos += hl_start.length() + query.length() + hl_end.length(); - } - } -} - -void execute_search( PageData& page_data, const std::string& query, std::size_t& scroll_y, bool next_match ) -{ - if ( query.empty() ) { - return; - } - - if ( !next_match || query != page_data.last_search ) { - find_search_matches( page_data, query ); - apply_search_highlight( page_data, query ); - } else if ( !page_data.match_lines.empty() ) { - page_data.current_match_idx = ( page_data.current_match_idx + 1 ) % page_data.match_lines.size(); - } - - if ( page_data.match_lines.empty() ) { - return; - } - - const std::size_t target_line = page_data.match_lines[page_data.current_match_idx]; - scroll_y = ( target_line > 5 ) ? ( target_line - 5 ) : 0; -} - -void update_input_value( PageData& page_data, std::size_t index, const std::string& query ) -{ - page_data.inputs[index - 1].value = query; - const std::string marker = "[I" + std::to_string( index ) + ":"; - const std::string replacement = "[I" + std::to_string( index ) + ":" + query + "]"; - - auto update_lines = [&]( std::vector& lines_arr ) { - for ( auto& line : lines_arr ) { - const std::size_t pos = line.find( marker ); - if ( pos != std::string::npos ) { - const std::size_t end = line.find( ']', pos ); - if ( end != std::string::npos ) { - line.replace( pos, end - pos + 1, replacement ); - } - } - } - }; - update_lines( page_data.lines ); - update_lines( page_data.original_lines ); -} - -void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ) -{ - const std::size_t pos = line.find( marker ); - if ( pos == std::string::npos ) { - return; - } - const std::size_t end = line.find( ']', pos ); - if ( end == std::string::npos ) { - return; - } - - std::string prefix; - if ( in.type == "checkbox" ) { - prefix = in.checked ? "[X] " : "[ ] "; - } else { - prefix = in.checked ? "(X) " : "( ) "; - } - line.replace( pos, end - pos + 1, marker + prefix + in.value + "]" ); -} - -void update_toggle_ui( PageData& page_data ) -{ - for ( std::size_t i = 0; i < page_data.inputs.size(); ++i ) { - const auto& in = page_data.inputs[i]; - if ( in.type != "checkbox" && in.type != "radio" ) { - continue; - } - const std::string marker = "[I" + std::to_string( i + 1 ) + ":"; - for ( auto& line : page_data.lines ) { - update_single_line_ui( line, marker, in ); - } - for ( auto& line : page_data.original_lines ) { - update_single_line_ui( line, marker, in ); - } - } -} - -InputAction handle_form_input( const tui::Terminal& terminal, - PageData& page_data, - const std::string& current_url, - std::vector& history, - std::size_t& history_idx ) -{ - const std::string num_input = get_terminal_input( terminal, "Select Input [#]:" ); - if ( num_input.empty() ) { - return InputAction::None; - } - std::size_t index = 0; - try { - index = std::stoul( num_input ); - } catch ( ... ) { - return InputAction::None; - } - if ( index == 0 || index > page_data.inputs.size() ) { - return InputAction::None; - } - - auto& target_input = page_data.inputs[index - 1]; - if ( target_input.type == "submit" || target_input.type == "button" ) { - return submit_form( target_input, page_data.inputs, current_url, history, history_idx ); - } - - if ( target_input.type == "checkbox" || target_input.type == "radio" ) { - if ( target_input.type == "radio" ) { - for ( auto& in : page_data.inputs ) { - if ( in.name == target_input.name && in.action == target_input.action ) { - in.checked = false; - } - } - target_input.checked = true; - } else { - target_input.checked = !target_input.checked; - } - update_toggle_ui( page_data ); - return InputAction::None; - } - - const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); - if ( query.empty() ) { - return InputAction::None; - } - update_input_value( page_data, index, query ); - return InputAction::None; -} - InputAction push_history( const std::string& url, std::vector& history, std::size_t& history_idx ) { history.resize( history_idx + 1 ); @@ -944,39 +127,6 @@ InputAction process_key( char c, return InputAction::None; } -std::vector wrap_lines( const std::vector& original_lines, std::size_t wrap_width ) -{ - std::vector wrapped_lines; - for ( const auto& line : original_lines ) { - std::string current_line; - std::size_t vis_count = 0; - bool in_ansi = false; - for ( const char c : line ) { - if ( c == '\x1b' ) { - in_ansi = true; - } - if ( in_ansi ) { - current_line += c; - if ( c == 'm' ) { - in_ansi = false; - } - } else { - if ( vis_count >= wrap_width ) { - wrapped_lines.push_back( current_line + "\033[0m" ); - current_line.clear(); - vis_count = 0; - } - current_line += c; - vis_count++; - } - } - if ( !current_line.empty() || line.empty() ) { - wrapped_lines.push_back( current_line + "\033[0m" ); - } - } - return wrapped_lines; -} - } // namespace std::string Browser::format_url( std::string input ) diff --git a/ricochet/src/core/form_handler.cc b/ricochet/src/core/form_handler.cc new file mode 100644 index 0000000..2d018c9 --- /dev/null +++ b/ricochet/src/core/form_handler.cc @@ -0,0 +1,165 @@ +/* + * 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 . + */ + +#include "ricochet/core/form_handler.hh" +#include "ricochet/core/ui_input.hh" +#include "ricochet/core/utils.hh" + +namespace ricochet::core { + +InputAction submit_form( const render::InputField& target_input, + const std::vector& inputs, + const std::string& current_url, + std::vector& 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; +} + +void update_input_value( PageData& page_data, std::size_t index, const std::string& query ) +{ + page_data.inputs[index - 1].value = query; + const std::string marker = "[I" + std::to_string( index ) + ":"; + const std::string replacement = "[I" + std::to_string( index ) + ":" + query + "]"; + + auto update_lines = [&]( std::vector& lines_arr ) { + for ( auto& line : lines_arr ) { + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + const std::size_t end = line.find( ']', pos ); + if ( end != std::string::npos ) { + line.replace( pos, end - pos + 1, replacement ); + } + } + } + }; + update_lines( page_data.lines ); + update_lines( page_data.original_lines ); +} + +void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ) +{ + const std::string checked_str = marker + ( in.type == "checkbox" ? "[X] " : "(X) " ) + in.value + "]"; + const std::string unchecked_str = marker + ( in.type == "checkbox" ? "[ ] " : "( ) " ) + in.value + "]"; + + const std::string& target_str = in.checked ? checked_str : unchecked_str; + const std::string& old_str = in.checked ? unchecked_str : checked_str; + + const std::size_t pos = line.find( old_str ); + if ( pos != std::string::npos ) { + line.replace( pos, old_str.length(), target_str ); + } +} + +void update_toggle_ui( PageData& page_data ) +{ + for ( std::size_t i = 0; i < page_data.inputs.size(); ++i ) { + const auto& in = page_data.inputs[i]; + if ( in.type != "checkbox" && in.type != "radio" ) { + continue; + } + const std::string marker = "[I" + std::to_string( i + 1 ) + ":"; + for ( auto& line : page_data.lines ) { + update_single_line_ui( line, marker, in ); + } + for ( auto& line : page_data.original_lines ) { + update_single_line_ui( line, marker, in ); + } + } +} + +InputAction handle_form_input( const tui::Terminal& terminal, + PageData& page_data, + const std::string& current_url, + std::vector& history, + std::size_t& history_idx ) +{ + const std::string num_input = get_terminal_input( terminal, "Select Input [#]:" ); + if ( num_input.empty() ) { + return InputAction::None; + } + std::size_t index = 0; + try { + index = std::stoul( num_input ); + } catch ( ... ) { + return InputAction::None; + } + if ( index == 0 || index > page_data.inputs.size() ) { + return InputAction::None; + } + + auto& target_input = page_data.inputs[index - 1]; + if ( target_input.type == "submit" || target_input.type == "button" ) { + return submit_form( target_input, page_data.inputs, current_url, history, history_idx ); + } + + if ( target_input.type == "checkbox" || target_input.type == "radio" ) { + if ( target_input.type == "radio" ) { + for ( auto& in : page_data.inputs ) { + if ( in.name == target_input.name && in.action == target_input.action ) { + in.checked = false; + } + } + target_input.checked = true; + } else { + target_input.checked = !target_input.checked; + } + update_toggle_ui( page_data ); + return InputAction::None; + } + + const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); + if ( query.empty() ) { + return InputAction::None; + } + update_input_value( page_data, index, query ); + return InputAction::None; +} + +} // namespace ricochet::core diff --git a/ricochet/src/core/link_hints.cc b/ricochet/src/core/link_hints.cc new file mode 100644 index 0000000..77b91b8 --- /dev/null +++ b/ricochet/src/core/link_hints.cc @@ -0,0 +1,104 @@ +/* + * 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 . + */ + +#include "ricochet/core/link_hints.hh" +#include "ricochet/core/ui_view.hh" +#include "ricochet/core/utils.hh" +#include + +namespace ricochet::core { + +std::string index_to_hint( std::size_t index ) +{ + const std::string alphabet = "asdfghjklqwertyuiopzxcvbnm"; + std::string hint; + hint += alphabet[( index / alphabet.length() ) % alphabet.length()]; + hint += alphabet[index % alphabet.length()]; + return hint; +} + +void toggle_link_hints( PageData& page_data, bool show ) +{ + const std::string hl_start = "\033[0;1;35;7m "; + const std::string hl_end = " \033[0m"; + + std::vector formatted_hints; + formatted_hints.reserve( page_data.links.size() ); + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + std::string h = hl_start; + h += index_to_hint( i ); + h += hl_end; + formatted_hints.push_back( std::move( h ) ); + } + + for ( auto& line : page_data.lines ) { + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + const std::string marker = "{L:" + std::to_string( i + 1 ) + "}"; + if ( show ) { + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + line.replace( pos, marker.length(), formatted_hints[i] ); + } + } else { + const std::size_t pos = line.find( formatted_hints[i] ); + if ( pos != std::string::npos ) { + line.replace( pos, formatted_hints[i].length(), marker ); + } + } + } + } +} + +void handle_follow_link( const tui::Terminal& terminal, + PageData& page_data, + std::string& current_url, + std::size_t& scroll_y, // NOLINT(bugprone-easily-swappable-parameters) + bool& navigate ) +{ + toggle_link_hints( page_data, true ); + + draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); + + auto [w, h] = terminal.get_size(); + terminal.move_cursor( h, 1 ); + std::cout << "\x1b[2K\033[1;35;7m Follow Hint (2 keys): \033[0m "; + std::cout.flush(); + + std::string input; + input += terminal.read_key(); + std::cout << input[0]; + std::cout.flush(); + input += terminal.read_key(); + + // bool found = false; + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + if ( index_to_hint( i ) == input ) { + std::string target = page_data.links[i]; + target = normalize_url( target, current_url ); + target = strip_ddg_tracker( target ); + current_url = target; + navigate = true; + // found = true; + break; + } + } + + toggle_link_hints( page_data, false ); +} + +} // namespace ricochet::core 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 . + */ + +#include "ricochet/core/local_data.hh" +#include +#include +#include +#include + +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( 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 = "My Bookmarks

    Saved Bookmarks

      "; + if ( !file.is_open() ) { + html += "
    • No bookmarks found. Press 'b' on any page to add one!
    • "; + } 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 += "
    • [Link] "; + html += title; + html += "
    • "; + } + } + } + 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 = "History

    Browsing History

      "; + if ( !file.is_open() ) { + html += "
    • No history found. Start browsing!
    • "; + } else { + std::vector 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 += "
    • [Link] "; + html += title; + html += "
    • "; + } + } + } + html += "
    "; + return html; +} + +std::string generate_help_html() +{ + return R"( + Ricochet Help Manual + +

    Ricochet User Manual

    +

    1. Navigation & Browsing

    +
      +
    • j / k : Scroll down / up
    • +
    • H / L : History backward / forward
    • +
    • / : Open prompt to enter a new URL
    • +
    +

    2. Page Interaction

    +
      +
    • f : Toggle Link Hints (type the two letters to follow a link instantly)
    • +
    • n : Find in page (search for text)
    • +
    • N : Jump to the next search match
    • +
    • i : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)
    • +
    +

    3. Bookmarks & History

    +
      +
    • b : Bookmark the current page
    • +
    • B : View all saved bookmarks
    • +
    • h : View browsing history
    • +
    +

    4. System

    +
      +
    • ? : Show this help manual
    • +
    • q : Quit Ricochet
    • +
    + + )"; +} + +} // 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 . + */ + +#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( 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 . + */ + +#include "ricochet/core/page_processor.hh" +#include +#include +#include + +namespace ricochet::core { + +std::string extract_title( const parser::DomNode& root_node ) +{ + std::vector 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( 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( 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/search_handler.cc b/ricochet/src/core/search_handler.cc new file mode 100644 index 0000000..cab9e9c --- /dev/null +++ b/ricochet/src/core/search_handler.cc @@ -0,0 +1,81 @@ +/* + * 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 . + */ + +#include "ricochet/core/search_handler.hh" + +namespace ricochet::core { + +void find_search_matches( PageData& page_data, const std::string& query ) +{ + page_data.last_search = query; + page_data.match_lines.clear(); + page_data.current_match_idx = 0; + for ( std::size_t i = 0; i < page_data.original_lines.size(); ++i ) { + if ( page_data.original_lines[i].find( query ) != std::string::npos ) { + page_data.match_lines.push_back( i ); + } + } +} + +void apply_search_highlight( PageData& page_data, const std::string& query ) +{ + page_data.lines = page_data.original_lines; + if ( page_data.match_lines.empty() ) { + return; + } + + const std::string hl_start = "\033[43;30m"; + const std::string hl_end = "\033[0m"; + + for ( const auto match_idx : page_data.match_lines ) { + auto& line = page_data.lines[match_idx]; + std::size_t pos = 0; + std::string replacement; + replacement.reserve( hl_start.length() + query.length() + hl_end.length() ); + replacement += hl_start; + replacement += query; + replacement += hl_end; + while ( ( pos = line.find( query, pos ) ) != std::string::npos ) { + line.replace( pos, query.length(), replacement ); + pos += hl_start.length() + query.length() + hl_end.length(); + } + } +} + +void execute_search( PageData& page_data, const std::string& query, std::size_t& scroll_y, bool next_match ) +{ + if ( query.empty() ) { + return; + } + + if ( !next_match || query != page_data.last_search ) { + find_search_matches( page_data, query ); + apply_search_highlight( page_data, query ); + } else if ( !page_data.match_lines.empty() ) { + page_data.current_match_idx = ( page_data.current_match_idx + 1 ) % page_data.match_lines.size(); + } + + if ( page_data.match_lines.empty() ) { + return; + } + + const std::size_t target_line = page_data.match_lines[page_data.current_match_idx]; + scroll_y = ( target_line > 5 ) ? ( target_line - 5 ) : 0; +} + +} // namespace ricochet::core diff --git a/ricochet/src/core/ui_input.cc b/ricochet/src/core/ui_input.cc new file mode 100644 index 0000000..0f57dd1 --- /dev/null +++ b/ricochet/src/core/ui_input.cc @@ -0,0 +1,94 @@ +/* + * 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 . + */ + +#include "ricochet/core/ui_input.hh" +#include "ricochet/core/browser.hh" +#include + +namespace ricochet::core { + +std::string get_url_input( const tui::Terminal& terminal ) +{ + auto [width, height] = terminal.get_size(); + terminal.move_cursor( height, 1 ); + std::cout << "\x1b[2K\033[7m URL: \033[0m "; + std::cout.flush(); + + std::string input; + terminal.show_cursor( true ); + while ( true ) { + const char ch = terminal.read_key(); + if ( ch == '\r' || ch == '\n' ) { + break; + } + if ( ch == 27 ) { + input.clear(); + break; + } + if ( ch == 127 || ch == 8 ) { + if ( !input.empty() ) { + input.pop_back(); + std::cout << "\b \b"; + std::cout.flush(); + } + } else if ( std::isprint( static_cast( ch ) ) ) { + input += ch; + std::cout << ch; + std::cout.flush(); + } + } + terminal.show_cursor( false ); + + return Browser::format_url( std::move( input ) ); +} + +std::string get_terminal_input( const tui::Terminal& terminal, const std::string& prompt ) +{ + auto [width, height] = terminal.get_size(); + terminal.move_cursor( height, 1 ); + std::cout << "\x1b[2K\033[7m " << prompt << " \033[0m "; + std::cout.flush(); + + std::string input; + terminal.show_cursor( true ); + while ( true ) { + const char ch = terminal.read_key(); + if ( ch == '\r' || ch == '\n' ) { + break; + } + if ( ch == 27 ) { + input.clear(); + break; + } + if ( ch == 127 || ch == 8 ) { + if ( !input.empty() ) { + input.pop_back(); + std::cout << "\b \b"; + std::cout.flush(); + } + } else if ( std::isprint( static_cast( ch ) ) ) { + input += ch; + std::cout << ch; + std::cout.flush(); + } + } + terminal.show_cursor( false ); + return input; +} + +} // namespace ricochet::core diff --git a/ricochet/src/core/ui_view.cc b/ricochet/src/core/ui_view.cc new file mode 100644 index 0000000..5164fe0 --- /dev/null +++ b/ricochet/src/core/ui_view.cc @@ -0,0 +1,103 @@ +/* + * 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 . + */ + +#include "ricochet/core/ui_view.hh" +#include + +namespace ricochet::core { + +std::string build_footer( std::string_view url, std::size_t width ) +{ + const std::string_view keys + = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Back [h]Hist [b/B]Mark [?]Help [q]Quit "; + const std::string_view prefix = " URL: "; + + std::string footer; + if ( width <= keys.length() + prefix.length() + 3 ) { + footer.reserve( prefix.length() + url.length() + keys.length() ); + footer += prefix; + footer += url; + footer += keys; + if ( footer.length() > width && width > 3 ) { + footer.erase( width - 3 ); + footer += "..."; + } + } else { + const std::size_t max_url_len = width - keys.length() - prefix.length(); + footer.reserve( width ); + footer += prefix; + if ( url.length() > max_url_len ) { + footer += url.substr( 0, max_url_len - 3 ); + footer += "..."; + } else { + footer += url; + } + footer += keys; + } + + if ( footer.length() < width ) { + footer.append( width - footer.length(), ' ' ); + } + return footer; +} + +void draw_view( const tui::Terminal& terminal, + const std::vector& lines, + std::size_t scroll_y, + std::string_view url, // NOLINT(bugprone-easily-swappable-parameters) + std::string_view title ) // NOLINT(bugprone-easily-swappable-parameters) +{ + terminal.clear_screen(); + auto [width, height] = terminal.get_size(); + const std::size_t usable_height = ( height > 2 ) ? ( height - 2 ) : 1; + + std::string clean_title( title ); + if ( clean_title.empty() ) { + const std::size_t start = url.find( "://" ); + const std::size_t host_start = ( start == std::string::npos ) ? 0 : start + 3; + const std::size_t host_end = url.find( '/', host_start ); + clean_title = std::string( url.substr( host_start, host_end - host_start ) ); + } + for ( char& c : clean_title ) { + if ( c == '\n' || c == '\r' ) { + c = ' '; + } + } + + std::string header = " PAGE: " + clean_title; + if ( header.size() < width ) { + header.append( width - header.size(), ' ' ); + } else if ( header.size() > width && width > 3 ) { + header = header.substr( 0, width - 3 ) + "..."; + } + std::cout << "\033[7m" << header << "\033[0m\r\n"; + + for ( std::size_t i = 0; i < usable_height; ++i ) { + if ( scroll_y + i < lines.size() ) { + std::cout << lines[scroll_y + i] << "\r\n"; + } else { + std::cout << "~\r\n"; + } + } + + const std::string footer = build_footer( url, width ); + std::cout << "\033[7m" << footer << "\033[0m"; + std::cout.flush(); +} + +} // namespace ricochet::core diff --git a/ricochet/src/core/utils.cc b/ricochet/src/core/utils.cc new file mode 100644 index 0000000..ddc6321 --- /dev/null +++ b/ricochet/src/core/utils.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 . + */ + +#include "ricochet/core/utils.hh" + +namespace ricochet::core { + +std::vector split_into_lines( const std::string& text ) +{ + std::vector 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( std::stoi( hex, nullptr, 16 ) ); + } catch ( const std::exception& ) { + (void)0; + } + i += 2; + } else { + decoded += encoded[i]; + } + } + return decoded; + } + return target; +} + +std::vector wrap_lines( const std::vector& original_lines, std::size_t wrap_width ) +{ + std::vector wrapped_lines; + for ( const auto& line : original_lines ) { + std::string current_line; + std::size_t vis_count = 0; + bool in_ansi = false; + for ( const char c : line ) { + if ( c == '\x1b' ) { + in_ansi = true; + } + if ( in_ansi ) { + current_line += c; + if ( c == 'm' ) { + in_ansi = false; + } + } else { + if ( vis_count >= wrap_width ) { + wrapped_lines.push_back( current_line + "\033[0m" ); + current_line.clear(); + vis_count = 0; + } + current_line += c; + vis_count++; + } + } + if ( !current_line.empty() || line.empty() ) { + wrapped_lines.push_back( current_line + "\033[0m" ); + } + } + return wrapped_lines; +} + +} // namespace ricochet::core 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 . + */ + +#include "ricochet/net/config.hh" + +#include +#include +#include + +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( 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 . + */ + +#include "ricochet/net/cookie_jar.hh" +#include "ricochet/net/config.hh" + +#include + +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 index 5f5b449..4a10c02 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -17,302 +17,30 @@ */ #include "ricochet/net/http_client.hh" -#include "ricochet/net/ssl_socket.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 -#include #include -#include #include #include -#include -#include namespace ricochet::net { namespace { -struct ParsedUrl -{ - std::string host {}; - std::string path {}; -}; - -std::expected 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 ) ); - result.path = std::string( url.substr( sep_pos ) ); - if ( result.path[0] != '/' ) { - result.path.insert( 0, "/" ); - } - } - return result; -} - -std::string dechunk( std::string_view body ) // NOLINT(readability-convert-member-functions-to-static) -{ - std::string result; - std::size_t pos = 0; - while ( pos < body.size() ) { - // find hex length end with \r\n - auto line_end = body.find( "\r\n", pos ); - if ( line_end == std::string_view::npos ) { - break; - } - - // parse hex length - 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; // end of stream - } - - // extract data block (skip \r\n) - pos = line_end + 2; - if ( pos + chunk_size > body.size() ) { - break; - } - result.append( body.substr( pos, chunk_size ) ); - - // skip data block's \r\n - pos += chunk_size + 2; - } - return result; -} - -std::string remove_html_comments( std::string html ) // NOLINT(readability-convert-member-functions-to-static) -{ - std::size_t start_pos = 0; - while ( ( start_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, const std::string& tag_name ) // NOLINT -{ - const std::string open_tag = "<" + tag_name; - const std::string close_tag = ""; - 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; -} - -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( next_url.front() ) ) ) { - next_url.erase( 0, 1 ); - } - if ( next_url.starts_with( "/" ) ) { - next_url = ( is_https ? "https://" : "http://" ) + host + next_url; - } - return next_url; - } - } - return ""; -} - -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( mkdir( dir.c_str(), 0755 ) ); - return dir + "/" + filename; -} - -struct BrowserConfig -{ - std::string 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"; - std::string homepage = "https://wikipedia.org"; -}; - -BrowserConfig& get_browser_config() -{ - static BrowserConfig config; - 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; -} - -using CookieJar = std::unordered_map>; - -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; -} - 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) @@ -373,11 +101,67 @@ FetchResult do_network_request( const std::string& host, 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( 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 HttpClient::fetch( std::string_view url, // NOLINT - std::string_view method, - std::string_view body ) const +std::expected +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 ); @@ -403,35 +187,8 @@ std::expected HttpClient::fetch( std::string_view url const std::string& raw_response = net_res.raw_response; HttpResponse response; - const auto header_end = raw_response.find( "\r\n\r\n" ); - const std::string 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; - } + 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 ); @@ -445,21 +202,7 @@ std::expected HttpClient::fetch( std::string_view url } } - 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( 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" ); - } + sanitize_response_body( response, raw_response ); return response; } 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 . + */ + +#include "ricochet/net/http_utils.hh" + +#include +#include + +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 ); + 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 = ""; + 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/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 . + */ + +#include "ricochet/net/url_parser.hh" + +#include + +namespace ricochet::net { + +std::expected 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( 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/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 . + */ + +#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 index 53eca1b..7fb665f 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -17,92 +17,15 @@ */ #include "ricochet/render/renderer.hh" -#include -#include -#include -#include +#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 { -std::string decode_entities( std::string text ) -{ - constexpr std::array, 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( cp ) ); - continue; - } - } catch ( const std::invalid_argument& ) { - (void)0; // Not a valid number, leave as-is - } catch ( const std::out_of_range& ) { - (void)0; // Value too large, leave as-is - } - } - 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 ); -} - void render_link_end( const parser::DomNode& node, RenderResult& result ) { result.text += "\033[0m"; @@ -125,221 +48,9 @@ void render_img( const parser::DomNode& node, RenderResult& result ) } } -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"; -} - -struct FormContext -{ - std::string action; - std::string method; -}; - -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"; -} - -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 "; - } -} - -void render_node( const parser::DomNode& node, RenderResult& result, FormContext ctx ); - -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( 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; - } // ASCII (width 1) Latin/Greek (width 1) - else if ( ( c & 0xF0 ) == 0xE0 || ( c & 0xF8 ) == 0xF0 ) { - len += 2; - } // CJK (width 2) Emoji/Rare (width 2) - } - } - return len; -} - -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& 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 calc_table_widths( const parser::DomNode& table_node ) -{ - std::vector 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( w, 30 ); - } - return widths; -} - -void render_table_row( const parser::DomNode& row, // NOLINT(misc-no-recursion) - RenderResult& result, - const FormContext& ctx, - const std::vector& 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"; -} - -void render_table( const parser::DomNode& node, // NOLINT(misc-no-recursion) - RenderResult& result, - const FormContext& ctx ) -{ - std::string& output = result.text; - const std::vector 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 -void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) - RenderResult& result, - FormContext ctx ) +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( ' ' ); @@ -401,8 +112,6 @@ void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) } } -} // namespace - RenderResult Renderer::render( // NOLINT(readability-convert-member-functions-to-static) const parser::DomNode& node ) const { 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 . + */ + +#include "ricochet/render/table_renderer.hh" +#include "ricochet/render/internal.hh" +#include "ricochet/render/utils.hh" +#include + +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& 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 calc_table_widths( const parser::DomNode& table_node ) +{ + std::vector 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( w, 30 ); + } + return widths; +} + +void render_table_row( const parser::DomNode& row, + RenderResult& result, + const FormContext& ctx, + const std::vector& 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 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 . + */ + +#include "ricochet/render/utils.hh" + +#include +#include +#include + +namespace ricochet::render { + +std::string decode_entities( std::string text ) +{ + constexpr std::array, 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( 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( 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 -- cgit v1.2.3 From a8c5e1794e4c4b8ca1ad9e6212c1d7ae54dbff6f Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Fri, 16 Oct 2026 22:35:09 +0800 Subject: Remove legacy terminal UI infrastructure and rename license file --- ricochet/COPYING | 674 +++++++++++++++++++++++ ricochet/LICENSE | 674 ----------------------- ricochet/include/ricochet/core/browser.hh | 5 +- ricochet/include/ricochet/core/form_handler.hh | 11 - ricochet/include/ricochet/core/link_hints.hh | 27 - ricochet/include/ricochet/core/search_handler.hh | 23 - ricochet/include/ricochet/core/types.hh | 4 - ricochet/include/ricochet/core/ui_input.hh | 29 - ricochet/include/ricochet/core/ui_view.hh | 37 -- ricochet/include/ricochet/core/utils.hh | 2 - ricochet/include/ricochet/tui/terminal.hh | 56 -- ricochet/src/CMakeLists.txt | 1 - ricochet/src/core/browser.cc | 154 ------ ricochet/src/core/form_handler.cc | 101 ---- ricochet/src/core/link_hints.cc | 104 ---- ricochet/src/core/search_handler.cc | 81 --- ricochet/src/core/ui_input.cc | 94 ---- ricochet/src/core/ui_view.cc | 103 ---- ricochet/src/core/utils.cc | 33 -- ricochet/src/tui/terminal.cc | 90 --- 20 files changed, 676 insertions(+), 1627 deletions(-) create mode 100644 ricochet/COPYING delete mode 100644 ricochet/LICENSE delete mode 100644 ricochet/include/ricochet/core/link_hints.hh delete mode 100644 ricochet/include/ricochet/core/search_handler.hh delete mode 100644 ricochet/include/ricochet/core/ui_input.hh delete mode 100644 ricochet/include/ricochet/core/ui_view.hh delete mode 100644 ricochet/include/ricochet/tui/terminal.hh delete mode 100644 ricochet/src/core/link_hints.cc delete mode 100644 ricochet/src/core/search_handler.cc delete mode 100644 ricochet/src/core/ui_input.cc delete mode 100644 ricochet/src/core/ui_view.cc delete mode 100644 ricochet/src/tui/terminal.cc (limited to 'ricochet/src/core') 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. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/ricochet/LICENSE b/ricochet/LICENSE deleted file mode 100644 index f288702..0000000 --- a/ricochet/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - 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. - - - Copyright (C) - - 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 . - -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: - - Copyright (C) - 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 -. - - 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 -. diff --git a/ricochet/include/ricochet/core/browser.hh b/ricochet/include/ricochet/core/browser.hh index ab7a473..fabcba4 100644 --- a/ricochet/include/ricochet/core/browser.hh +++ b/ricochet/include/ricochet/core/browser.hh @@ -18,7 +18,7 @@ #pragma once -#include +#include namespace ricochet::core { @@ -35,7 +35,6 @@ public: static std::string format_url( std::string input ); static std::string get_configured_homepage(); - static int run( std::string_view initial_url ); }; -} // namespace ricochet::core \ No newline at end of file +} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/form_handler.hh b/ricochet/include/ricochet/core/form_handler.hh index 5abf148..d95bd02 100644 --- a/ricochet/include/ricochet/core/form_handler.hh +++ b/ricochet/include/ricochet/core/form_handler.hh @@ -12,7 +12,6 @@ #include "ricochet/core/types.hh" #include "ricochet/render/renderer.hh" -#include "ricochet/tui/terminal.hh" #include #include #include @@ -25,14 +24,4 @@ InputAction submit_form( const render::InputField& target_input, std::vector& history, std::size_t& history_idx ); -void update_input_value( PageData& page_data, std::size_t index, const std::string& query ); -void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ); -void update_toggle_ui( PageData& page_data ); - -InputAction handle_form_input( const tui::Terminal& terminal, - PageData& page_data, - const std::string& current_url, - std::vector& history, - std::size_t& history_idx ); - } // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/link_hints.hh b/ricochet/include/ricochet/core/link_hints.hh deleted file mode 100644 index 5da4846..0000000 --- a/ricochet/include/ricochet/core/link_hints.hh +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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/tui/terminal.hh" -#include - -namespace ricochet::core { - -std::string index_to_hint( std::size_t index ); -void toggle_link_hints( PageData& page_data, bool show ); -void handle_follow_link( const tui::Terminal& terminal, - PageData& page_data, - std::string& current_url, - std::size_t& scroll_y, - bool& navigate ); - -} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/search_handler.hh b/ricochet/include/ricochet/core/search_handler.hh deleted file mode 100644 index 7053be8..0000000 --- a/ricochet/include/ricochet/core/search_handler.hh +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 -#include - -namespace ricochet::core { - -void find_search_matches( PageData& page_data, const std::string& query ); -void apply_search_highlight( PageData& page_data, const std::string& query ); -void execute_search( PageData& page_data, const std::string& query, std::size_t& scroll_y, bool next_match ); - -} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/types.hh b/ricochet/include/ricochet/core/types.hh index bd7e819..ef48016 100644 --- a/ricochet/include/ricochet/core/types.hh +++ b/ricochet/include/ricochet/core/types.hh @@ -31,10 +31,6 @@ struct PageData std::vector links; std::vector inputs; std::string title; - std::vector original_lines {}; - std::string last_search {}; - std::size_t current_match_idx { 0 }; - std::vector match_lines {}; }; struct HttpRequest diff --git a/ricochet/include/ricochet/core/ui_input.hh b/ricochet/include/ricochet/core/ui_input.hh deleted file mode 100644 index bf71685..0000000 --- a/ricochet/include/ricochet/core/ui_input.hh +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 . - */ - -#pragma once - -#include "ricochet/tui/terminal.hh" -#include - -namespace ricochet::core { - -std::string get_url_input( const tui::Terminal& terminal ); -std::string get_terminal_input( const tui::Terminal& terminal, const std::string& prompt ); - -} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/ui_view.hh b/ricochet/include/ricochet/core/ui_view.hh deleted file mode 100644 index d9a55fa..0000000 --- a/ricochet/include/ricochet/core/ui_view.hh +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 . - */ - -#pragma once - -#include "ricochet/tui/terminal.hh" - -#include -#include -#include - -namespace ricochet::core { - -std::string build_footer( std::string_view url, std::size_t width ); - -void draw_view( const tui::Terminal& terminal, - const std::vector& lines, - std::size_t scroll_y, - std::string_view url, - std::string_view title ); - -} // namespace ricochet::core diff --git a/ricochet/include/ricochet/core/utils.hh b/ricochet/include/ricochet/core/utils.hh index d9f58dd..61d480b 100644 --- a/ricochet/include/ricochet/core/utils.hh +++ b/ricochet/include/ricochet/core/utils.hh @@ -18,7 +18,6 @@ #pragma once -#include #include #include @@ -27,6 +26,5 @@ namespace ricochet::core { std::vector 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 ); -std::vector wrap_lines( const std::vector& original_lines, std::size_t wrap_width ); } // namespace ricochet::core diff --git a/ricochet/include/ricochet/tui/terminal.hh b/ricochet/include/ricochet/tui/terminal.hh deleted file mode 100644 index 2942a2a..0000000 --- a/ricochet/include/ricochet/tui/terminal.hh +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 . - */ - -#pragma once - -#include -#include - -namespace ricochet::tui { - -class Terminal -{ -public: - Terminal(); - ~Terminal(); - - Terminal( const Terminal& ) = delete; - Terminal& operator=( const Terminal& ) = delete; - Terminal( Terminal&& ) = delete; - Terminal& operator=( Terminal&& ) = delete; - - // Read a single key - char read_key() const; - - // Clear screen and move cursor to top-left - void clear_screen() const; - - // Get terminal size - std::pair get_size() const; - - // Move cursor to a specific position - void move_cursor( std::size_t row, std::size_t col ) const; - - // Show or hide cursor - void show_cursor( bool visible ) const; - -private: - struct termios original_termios_ {}; // Save terminal original state -}; - -} // namespace ricochet::tui \ No newline at end of file diff --git a/ricochet/src/CMakeLists.txt b/ricochet/src/CMakeLists.txt index dcc8f49..e46ac9a 100644 --- a/ricochet/src/CMakeLists.txt +++ b/ricochet/src/CMakeLists.txt @@ -17,4 +17,3 @@ target_link_libraries(ricochet_core add_executable(ricochet ${MAIN_SRC}) target_link_libraries(ricochet PRIVATE ricochet_core) -# target_link_libraries(ricochet PRIVATE ricochet_core curl ncurses) \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 86a5096..c413a0d 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -18,117 +18,12 @@ #include "ricochet/core/browser.hh" -#include -#include #include -#include -#include -#include "ricochet/core/form_handler.hh" -#include "ricochet/core/link_hints.hh" #include "ricochet/core/local_data.hh" -#include "ricochet/core/page_loader.hh" -#include "ricochet/core/search_handler.hh" -#include "ricochet/core/ui_input.hh" -#include "ricochet/core/ui_view.hh" -#include "ricochet/core/utils.hh" namespace ricochet::core { -namespace { - -InputAction push_history( const std::string& url, std::vector& history, std::size_t& history_idx ) -{ - history.resize( history_idx + 1 ); - history.push_back( HttpRequest { .url = url, .method = "GET", .body = "" } ); - history_idx++; - return InputAction::Navigate; -} - -InputAction process_key( char c, - const tui::Terminal& terminal, - PageData& page_data, - std::string& current_url, - std::vector& history, - std::size_t& history_idx, // NOLINT(bugprone-easily-swappable-parameters) - std::size_t& scroll_y ) // NOLINT(bugprone-easily-swappable-parameters) -{ - auto [width, height] = terminal.get_size(); - const std::size_t view_h = ( height > 1 ) ? ( height - 1 ) : 1; - - switch ( c ) { - case 'q': - return InputAction::Quit; - case 'j': - if ( scroll_y + view_h < page_data.lines.size() ) { - scroll_y++; - } - break; - case 'k': - if ( scroll_y > 0 ) { - scroll_y--; - } - break; - case 'H': - if ( history_idx > 0 ) { - history_idx--; - return InputAction::Navigate; - } - break; - case 'L': - if ( history_idx + 1 < history.size() ) { - history_idx++; - return InputAction::Navigate; - } - break; - case 'r': - return InputAction::Navigate; - case 'b': - save_bookmark( page_data.title, current_url ); - break; - case 'B': - return push_history( "ricochet://bookmarks", history, history_idx ); - case '?': - return push_history( "ricochet://help", history, history_idx ); - case 'h': - return push_history( "ricochet://history", history, history_idx ); - case 'n': { - const std::string query = get_terminal_input( terminal, "Find in Page:" ); - execute_search( page_data, query, scroll_y, false ); - break; - } - case 'N': - execute_search( page_data, page_data.last_search, scroll_y, true ); - break; - case '/': { - const std::string next_url = get_url_input( terminal ); - if ( !next_url.empty() ) { - return push_history( next_url, history, history_idx ); - } - break; - } - case 'f': { - const std::string old_url = current_url; - bool nav = false; - handle_follow_link( terminal, page_data, current_url, scroll_y, nav ); - if ( nav && current_url != old_url ) { - return push_history( current_url, history, history_idx ); - } - break; - } - case 'i': - if ( !page_data.inputs.empty() ) { - return handle_form_input( terminal, page_data, current_url, history, history_idx ); - } - break; - default: - break; - } - return InputAction::None; -} - -} // namespace - std::string Browser::format_url( std::string input ) { if ( input.empty() ) { @@ -168,53 +63,4 @@ std::string Browser::get_configured_homepage() return "https://wikipedia.org"; } -int Browser::run( std::string_view initial_url ) -{ - std::vector history; - history.emplace_back( HttpRequest { .url = std::string( initial_url ), .method = "GET", .body = "" } ); - std::size_t history_idx = 0; - const tui::Terminal terminal; - terminal.show_cursor( false ); - - while ( true ) { - const HttpRequest current_req = history[history_idx]; - std::string current_url = current_req.url; - terminal.clear_screen(); - std::cout << "-> Fetching: " << current_req.url << "...\r\n"; - std::cout.flush(); - - auto page_data = load_page( current_req ); - - static std::string last_recorded_url; - if ( current_url != last_recorded_url && !current_url.starts_with( "ricochet://" ) ) { - append_to_history( current_url, page_data.title ); - last_recorded_url = current_url; - } - - auto [term_width, term_height] = terminal.get_size(); - const std::size_t wrap_width = ( term_width > 1 ) ? ( term_width - 1 ) : 80; - page_data.lines = wrap_lines( page_data.lines, wrap_width ); - page_data.original_lines = page_data.lines; - - std::size_t scroll_y = 0; - bool navigate = false; - - while ( !navigate ) { - draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); - const char c = terminal.read_key(); - - const InputAction action = process_key( c, terminal, page_data, current_url, history, history_idx, scroll_y ); - if ( action == InputAction::Quit ) { - terminal.clear_screen(); - return 0; - } - if ( action == InputAction::Navigate ) { - navigate = true; - } - } - } - terminal.clear_screen(); - return 0; -} - } // namespace ricochet::core diff --git a/ricochet/src/core/form_handler.cc b/ricochet/src/core/form_handler.cc index 2d018c9..6100a30 100644 --- a/ricochet/src/core/form_handler.cc +++ b/ricochet/src/core/form_handler.cc @@ -17,7 +17,6 @@ */ #include "ricochet/core/form_handler.hh" -#include "ricochet/core/ui_input.hh" #include "ricochet/core/utils.hh" namespace ricochet::core { @@ -62,104 +61,4 @@ InputAction submit_form( const render::InputField& target_input, return InputAction::Navigate; } -void update_input_value( PageData& page_data, std::size_t index, const std::string& query ) -{ - page_data.inputs[index - 1].value = query; - const std::string marker = "[I" + std::to_string( index ) + ":"; - const std::string replacement = "[I" + std::to_string( index ) + ":" + query + "]"; - - auto update_lines = [&]( std::vector& lines_arr ) { - for ( auto& line : lines_arr ) { - const std::size_t pos = line.find( marker ); - if ( pos != std::string::npos ) { - const std::size_t end = line.find( ']', pos ); - if ( end != std::string::npos ) { - line.replace( pos, end - pos + 1, replacement ); - } - } - } - }; - update_lines( page_data.lines ); - update_lines( page_data.original_lines ); -} - -void update_single_line_ui( std::string& line, const std::string& marker, const render::InputField& in ) -{ - const std::string checked_str = marker + ( in.type == "checkbox" ? "[X] " : "(X) " ) + in.value + "]"; - const std::string unchecked_str = marker + ( in.type == "checkbox" ? "[ ] " : "( ) " ) + in.value + "]"; - - const std::string& target_str = in.checked ? checked_str : unchecked_str; - const std::string& old_str = in.checked ? unchecked_str : checked_str; - - const std::size_t pos = line.find( old_str ); - if ( pos != std::string::npos ) { - line.replace( pos, old_str.length(), target_str ); - } -} - -void update_toggle_ui( PageData& page_data ) -{ - for ( std::size_t i = 0; i < page_data.inputs.size(); ++i ) { - const auto& in = page_data.inputs[i]; - if ( in.type != "checkbox" && in.type != "radio" ) { - continue; - } - const std::string marker = "[I" + std::to_string( i + 1 ) + ":"; - for ( auto& line : page_data.lines ) { - update_single_line_ui( line, marker, in ); - } - for ( auto& line : page_data.original_lines ) { - update_single_line_ui( line, marker, in ); - } - } -} - -InputAction handle_form_input( const tui::Terminal& terminal, - PageData& page_data, - const std::string& current_url, - std::vector& history, - std::size_t& history_idx ) -{ - const std::string num_input = get_terminal_input( terminal, "Select Input [#]:" ); - if ( num_input.empty() ) { - return InputAction::None; - } - std::size_t index = 0; - try { - index = std::stoul( num_input ); - } catch ( ... ) { - return InputAction::None; - } - if ( index == 0 || index > page_data.inputs.size() ) { - return InputAction::None; - } - - auto& target_input = page_data.inputs[index - 1]; - if ( target_input.type == "submit" || target_input.type == "button" ) { - return submit_form( target_input, page_data.inputs, current_url, history, history_idx ); - } - - if ( target_input.type == "checkbox" || target_input.type == "radio" ) { - if ( target_input.type == "radio" ) { - for ( auto& in : page_data.inputs ) { - if ( in.name == target_input.name && in.action == target_input.action ) { - in.checked = false; - } - } - target_input.checked = true; - } else { - target_input.checked = !target_input.checked; - } - update_toggle_ui( page_data ); - return InputAction::None; - } - - const std::string query = get_terminal_input( terminal, "Enter Text [I" + std::to_string( index ) + "]:" ); - if ( query.empty() ) { - return InputAction::None; - } - update_input_value( page_data, index, query ); - return InputAction::None; -} - } // namespace ricochet::core diff --git a/ricochet/src/core/link_hints.cc b/ricochet/src/core/link_hints.cc deleted file mode 100644 index 77b91b8..0000000 --- a/ricochet/src/core/link_hints.cc +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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 . - */ - -#include "ricochet/core/link_hints.hh" -#include "ricochet/core/ui_view.hh" -#include "ricochet/core/utils.hh" -#include - -namespace ricochet::core { - -std::string index_to_hint( std::size_t index ) -{ - const std::string alphabet = "asdfghjklqwertyuiopzxcvbnm"; - std::string hint; - hint += alphabet[( index / alphabet.length() ) % alphabet.length()]; - hint += alphabet[index % alphabet.length()]; - return hint; -} - -void toggle_link_hints( PageData& page_data, bool show ) -{ - const std::string hl_start = "\033[0;1;35;7m "; - const std::string hl_end = " \033[0m"; - - std::vector formatted_hints; - formatted_hints.reserve( page_data.links.size() ); - for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { - std::string h = hl_start; - h += index_to_hint( i ); - h += hl_end; - formatted_hints.push_back( std::move( h ) ); - } - - for ( auto& line : page_data.lines ) { - for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { - const std::string marker = "{L:" + std::to_string( i + 1 ) + "}"; - if ( show ) { - const std::size_t pos = line.find( marker ); - if ( pos != std::string::npos ) { - line.replace( pos, marker.length(), formatted_hints[i] ); - } - } else { - const std::size_t pos = line.find( formatted_hints[i] ); - if ( pos != std::string::npos ) { - line.replace( pos, formatted_hints[i].length(), marker ); - } - } - } - } -} - -void handle_follow_link( const tui::Terminal& terminal, - PageData& page_data, - std::string& current_url, - std::size_t& scroll_y, // NOLINT(bugprone-easily-swappable-parameters) - bool& navigate ) -{ - toggle_link_hints( page_data, true ); - - draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); - - auto [w, h] = terminal.get_size(); - terminal.move_cursor( h, 1 ); - std::cout << "\x1b[2K\033[1;35;7m Follow Hint (2 keys): \033[0m "; - std::cout.flush(); - - std::string input; - input += terminal.read_key(); - std::cout << input[0]; - std::cout.flush(); - input += terminal.read_key(); - - // bool found = false; - for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { - if ( index_to_hint( i ) == input ) { - std::string target = page_data.links[i]; - target = normalize_url( target, current_url ); - target = strip_ddg_tracker( target ); - current_url = target; - navigate = true; - // found = true; - break; - } - } - - toggle_link_hints( page_data, false ); -} - -} // namespace ricochet::core diff --git a/ricochet/src/core/search_handler.cc b/ricochet/src/core/search_handler.cc deleted file mode 100644 index cab9e9c..0000000 --- a/ricochet/src/core/search_handler.cc +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 . - */ - -#include "ricochet/core/search_handler.hh" - -namespace ricochet::core { - -void find_search_matches( PageData& page_data, const std::string& query ) -{ - page_data.last_search = query; - page_data.match_lines.clear(); - page_data.current_match_idx = 0; - for ( std::size_t i = 0; i < page_data.original_lines.size(); ++i ) { - if ( page_data.original_lines[i].find( query ) != std::string::npos ) { - page_data.match_lines.push_back( i ); - } - } -} - -void apply_search_highlight( PageData& page_data, const std::string& query ) -{ - page_data.lines = page_data.original_lines; - if ( page_data.match_lines.empty() ) { - return; - } - - const std::string hl_start = "\033[43;30m"; - const std::string hl_end = "\033[0m"; - - for ( const auto match_idx : page_data.match_lines ) { - auto& line = page_data.lines[match_idx]; - std::size_t pos = 0; - std::string replacement; - replacement.reserve( hl_start.length() + query.length() + hl_end.length() ); - replacement += hl_start; - replacement += query; - replacement += hl_end; - while ( ( pos = line.find( query, pos ) ) != std::string::npos ) { - line.replace( pos, query.length(), replacement ); - pos += hl_start.length() + query.length() + hl_end.length(); - } - } -} - -void execute_search( PageData& page_data, const std::string& query, std::size_t& scroll_y, bool next_match ) -{ - if ( query.empty() ) { - return; - } - - if ( !next_match || query != page_data.last_search ) { - find_search_matches( page_data, query ); - apply_search_highlight( page_data, query ); - } else if ( !page_data.match_lines.empty() ) { - page_data.current_match_idx = ( page_data.current_match_idx + 1 ) % page_data.match_lines.size(); - } - - if ( page_data.match_lines.empty() ) { - return; - } - - const std::size_t target_line = page_data.match_lines[page_data.current_match_idx]; - scroll_y = ( target_line > 5 ) ? ( target_line - 5 ) : 0; -} - -} // namespace ricochet::core diff --git a/ricochet/src/core/ui_input.cc b/ricochet/src/core/ui_input.cc deleted file mode 100644 index 0f57dd1..0000000 --- a/ricochet/src/core/ui_input.cc +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 . - */ - -#include "ricochet/core/ui_input.hh" -#include "ricochet/core/browser.hh" -#include - -namespace ricochet::core { - -std::string get_url_input( const tui::Terminal& terminal ) -{ - auto [width, height] = terminal.get_size(); - terminal.move_cursor( height, 1 ); - std::cout << "\x1b[2K\033[7m URL: \033[0m "; - std::cout.flush(); - - std::string input; - terminal.show_cursor( true ); - while ( true ) { - const char ch = terminal.read_key(); - if ( ch == '\r' || ch == '\n' ) { - break; - } - if ( ch == 27 ) { - input.clear(); - break; - } - if ( ch == 127 || ch == 8 ) { - if ( !input.empty() ) { - input.pop_back(); - std::cout << "\b \b"; - std::cout.flush(); - } - } else if ( std::isprint( static_cast( ch ) ) ) { - input += ch; - std::cout << ch; - std::cout.flush(); - } - } - terminal.show_cursor( false ); - - return Browser::format_url( std::move( input ) ); -} - -std::string get_terminal_input( const tui::Terminal& terminal, const std::string& prompt ) -{ - auto [width, height] = terminal.get_size(); - terminal.move_cursor( height, 1 ); - std::cout << "\x1b[2K\033[7m " << prompt << " \033[0m "; - std::cout.flush(); - - std::string input; - terminal.show_cursor( true ); - while ( true ) { - const char ch = terminal.read_key(); - if ( ch == '\r' || ch == '\n' ) { - break; - } - if ( ch == 27 ) { - input.clear(); - break; - } - if ( ch == 127 || ch == 8 ) { - if ( !input.empty() ) { - input.pop_back(); - std::cout << "\b \b"; - std::cout.flush(); - } - } else if ( std::isprint( static_cast( ch ) ) ) { - input += ch; - std::cout << ch; - std::cout.flush(); - } - } - terminal.show_cursor( false ); - return input; -} - -} // namespace ricochet::core diff --git a/ricochet/src/core/ui_view.cc b/ricochet/src/core/ui_view.cc deleted file mode 100644 index 5164fe0..0000000 --- a/ricochet/src/core/ui_view.cc +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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 . - */ - -#include "ricochet/core/ui_view.hh" -#include - -namespace ricochet::core { - -std::string build_footer( std::string_view url, std::size_t width ) -{ - const std::string_view keys - = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Back [h]Hist [b/B]Mark [?]Help [q]Quit "; - const std::string_view prefix = " URL: "; - - std::string footer; - if ( width <= keys.length() + prefix.length() + 3 ) { - footer.reserve( prefix.length() + url.length() + keys.length() ); - footer += prefix; - footer += url; - footer += keys; - if ( footer.length() > width && width > 3 ) { - footer.erase( width - 3 ); - footer += "..."; - } - } else { - const std::size_t max_url_len = width - keys.length() - prefix.length(); - footer.reserve( width ); - footer += prefix; - if ( url.length() > max_url_len ) { - footer += url.substr( 0, max_url_len - 3 ); - footer += "..."; - } else { - footer += url; - } - footer += keys; - } - - if ( footer.length() < width ) { - footer.append( width - footer.length(), ' ' ); - } - return footer; -} - -void draw_view( const tui::Terminal& terminal, - const std::vector& lines, - std::size_t scroll_y, - std::string_view url, // NOLINT(bugprone-easily-swappable-parameters) - std::string_view title ) // NOLINT(bugprone-easily-swappable-parameters) -{ - terminal.clear_screen(); - auto [width, height] = terminal.get_size(); - const std::size_t usable_height = ( height > 2 ) ? ( height - 2 ) : 1; - - std::string clean_title( title ); - if ( clean_title.empty() ) { - const std::size_t start = url.find( "://" ); - const std::size_t host_start = ( start == std::string::npos ) ? 0 : start + 3; - const std::size_t host_end = url.find( '/', host_start ); - clean_title = std::string( url.substr( host_start, host_end - host_start ) ); - } - for ( char& c : clean_title ) { - if ( c == '\n' || c == '\r' ) { - c = ' '; - } - } - - std::string header = " PAGE: " + clean_title; - if ( header.size() < width ) { - header.append( width - header.size(), ' ' ); - } else if ( header.size() > width && width > 3 ) { - header = header.substr( 0, width - 3 ) + "..."; - } - std::cout << "\033[7m" << header << "\033[0m\r\n"; - - for ( std::size_t i = 0; i < usable_height; ++i ) { - if ( scroll_y + i < lines.size() ) { - std::cout << lines[scroll_y + i] << "\r\n"; - } else { - std::cout << "~\r\n"; - } - } - - const std::string footer = build_footer( url, width ); - std::cout << "\033[7m" << footer << "\033[0m"; - std::cout.flush(); -} - -} // namespace ricochet::core diff --git a/ricochet/src/core/utils.cc b/ricochet/src/core/utils.cc index ddc6321..a410c8a 100644 --- a/ricochet/src/core/utils.cc +++ b/ricochet/src/core/utils.cc @@ -82,37 +82,4 @@ std::string strip_ddg_tracker( std::string target ) return target; } -std::vector wrap_lines( const std::vector& original_lines, std::size_t wrap_width ) -{ - std::vector wrapped_lines; - for ( const auto& line : original_lines ) { - std::string current_line; - std::size_t vis_count = 0; - bool in_ansi = false; - for ( const char c : line ) { - if ( c == '\x1b' ) { - in_ansi = true; - } - if ( in_ansi ) { - current_line += c; - if ( c == 'm' ) { - in_ansi = false; - } - } else { - if ( vis_count >= wrap_width ) { - wrapped_lines.push_back( current_line + "\033[0m" ); - current_line.clear(); - vis_count = 0; - } - current_line += c; - vis_count++; - } - } - if ( !current_line.empty() || line.empty() ) { - wrapped_lines.push_back( current_line + "\033[0m" ); - } - } - return wrapped_lines; -} - } // namespace ricochet::core diff --git a/ricochet/src/tui/terminal.cc b/ricochet/src/tui/terminal.cc deleted file mode 100644 index 44c0f3b..0000000 --- a/ricochet/src/tui/terminal.cc +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 . - */ - -#include "ricochet/tui/terminal.hh" -#include -#include -#include -#include - -namespace ricochet::tui { - -Terminal::Terminal() -{ - // get terminal attributes - tcgetattr( STDIN_FILENO, &original_termios_ ); - - struct termios raw = original_termios_; - - // close echo, close canonical mode - // close terminal signals (ISIG disable Ctrl+C and Ctrl+Z, let the program handle it) - raw.c_lflag &= ~( ECHO | ICANON | ISIG ); - - // close software flow control (Ctrl+S, Ctrl+Q) - raw.c_iflag &= ~( IXON | ICRNL ); - - // apply new terminal attributes - tcsetattr( STDIN_FILENO, TCSAFLUSH, &raw ); -} - -Terminal::~Terminal() -{ - show_cursor( true ); - // RAII: restore terminal attributes when program exits - tcsetattr( STDIN_FILENO, TCSAFLUSH, &original_termios_ ); -} - -char Terminal::read_key() const // NOLINT(readability-convert-member-functions-to-static) -{ - char c = '\0'; - // read 1 byte - if ( read( STDIN_FILENO, &c, 1 ) == -1 ) { - return '\0'; - } - return c; -} - -void Terminal::clear_screen() const // NOLINT(readability-convert-member-functions-to-static) -{ - // ANSI escape codes: \x1b[2J clear screen, \x1b[H move cursor to (1,1) - std::cout << "\x1b[2J\x1b[H"; -} - -std::pair Terminal::get_size() // NOLINT(readability-convert-member-functions-to-static) - const -{ - struct winsize w {}; - if ( ioctl( STDOUT_FILENO, TIOCGWINSZ, &w ) == -1 ) { - return { 80, 24 }; - } - return { static_cast( w.ws_col ), static_cast( w.ws_row ) }; -} - -void Terminal::move_cursor( std::size_t row, std::size_t col ) const // NOLINT -{ - // ANSI: \x1b[H move to 1,1; \x1b[row;colH move to specified row and column - std::cout << "\x1b[" << row << ";" << col << "H"; -} - -void Terminal::show_cursor( bool visible ) const // NOLINT -{ - // \x1b[?25h show cursor, \x1b[?25l hide cursor - std::cout << ( visible ? "\x1b[?25h" : "\x1b[?25l" ); -} - -} // namespace ricochet::tui \ No newline at end of file -- cgit v1.2.3