aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--include/ricochet/core/form_handler.hh38
-rw-r--r--include/ricochet/core/link_hints.hh27
-rw-r--r--include/ricochet/core/local_data.hh32
-rw-r--r--include/ricochet/core/page_loader.hh32
-rw-r--r--include/ricochet/core/page_processor.hh32
-rw-r--r--include/ricochet/core/search_handler.hh23
-rw-r--r--include/ricochet/core/types.hh54
-rw-r--r--include/ricochet/core/ui_input.hh29
-rw-r--r--include/ricochet/core/ui_view.hh37
-rw-r--r--include/ricochet/core/utils.hh32
-rw-r--r--include/ricochet/net/config.hh35
-rw-r--r--include/ricochet/net/cookie_jar.hh35
-rw-r--r--include/ricochet/net/http_client.hh3
-rw-r--r--include/ricochet/net/http_utils.hh30
-rw-r--r--include/ricochet/net/url_parser.hh37
-rw-r--r--include/ricochet/render/input_renderer.hh34
-rw-r--r--include/ricochet/render/internal.hh35
-rw-r--r--include/ricochet/render/table_renderer.hh30
-rw-r--r--include/ricochet/render/utils.hh33
-rw-r--r--src/core/browser.cc870
-rw-r--r--src/core/form_handler.cc165
-rw-r--r--src/core/link_hints.cc104
-rw-r--r--src/core/local_data.cc171
-rw-r--r--src/core/page_loader.cc78
-rw-r--r--src/core/page_processor.cc97
-rw-r--r--src/core/search_handler.cc81
-rw-r--r--src/core/ui_input.cc94
-rw-r--r--src/core/ui_view.cc103
-rw-r--r--src/core/utils.cc118
-rw-r--r--src/net/config.cc64
-rw-r--r--src/net/cookie_jar.cc124
-rw-r--r--src/net/http_client.cc393
-rw-r--r--src/net/http_utils.cc93
-rw-r--r--src/net/url_parser.cc87
-rw-r--r--src/render/input_renderer.cc82
-rw-r--r--src/render/renderer.cc303
-rw-r--r--src/render/table_renderer.cc147
-rw-r--r--src/render/utils.cc135
38 files changed, 2434 insertions, 1483 deletions
diff --git a/include/ricochet/core/form_handler.hh b/include/ricochet/core/form_handler.hh
new file mode 100644
index 0000000..5abf148
--- /dev/null
+++ b/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 <cstddef>
+#include <string>
+#include <vector>
+
+namespace ricochet::core {
+
+InputAction submit_form( const render::InputField& target_input,
+ const std::vector<render::InputField>& inputs,
+ const std::string& current_url,
+ std::vector<HttpRequest>& history,
+ std::size_t& history_idx );
+
+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<HttpRequest>& history,
+ std::size_t& history_idx );
+
+} // namespace ricochet::core
diff --git a/include/ricochet/core/link_hints.hh b/include/ricochet/core/link_hints.hh
new file mode 100644
index 0000000..5da4846
--- /dev/null
+++ b/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 <string>
+
+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/include/ricochet/core/local_data.hh b/include/ricochet/core/local_data.hh
new file mode 100644
index 0000000..ca07846
--- /dev/null
+++ b/include/ricochet/core/local_data.hh
@@ -0,0 +1,32 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <string>
+
+namespace ricochet::core {
+
+std::string get_config_path( const std::string& filename );
+void save_bookmark( const std::string& title, const std::string& url );
+std::string generate_bookmarks_html();
+void append_to_history( const std::string& url, const std::string& title );
+std::string generate_history_html();
+std::string generate_help_html();
+
+} // namespace ricochet::core
diff --git a/include/ricochet/core/page_loader.hh b/include/ricochet/core/page_loader.hh
new file mode 100644
index 0000000..11131e2
--- /dev/null
+++ b/include/ricochet/core/page_loader.hh
@@ -0,0 +1,32 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/core/types.hh"
+// #include "ricochet/net/http_client.hh"
+// #include "ricochet/parser/dom_tree.hh"
+// #include <string>
+
+namespace ricochet::core {
+
+// std::string extract_title( const parser::DomNode& root_node );
+// PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp );
+PageData load_page( const HttpRequest& req );
+
+} // namespace ricochet::core
diff --git a/include/ricochet/core/page_processor.hh b/include/ricochet/core/page_processor.hh
new file mode 100644
index 0000000..8f40d3a
--- /dev/null
+++ b/include/ricochet/core/page_processor.hh
@@ -0,0 +1,32 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/core/types.hh"
+#include "ricochet/net/http_client.hh"
+#include "ricochet/parser/dom_tree.hh"
+#include <string>
+
+namespace ricochet::core {
+
+std::string extract_title( const parser::DomNode& root_node );
+
+PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp );
+
+} // namespace ricochet::core
diff --git a/include/ricochet/core/search_handler.hh b/include/ricochet/core/search_handler.hh
new file mode 100644
index 0000000..7053be8
--- /dev/null
+++ b/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 <cstddef>
+#include <string>
+
+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/include/ricochet/core/types.hh b/include/ricochet/core/types.hh
new file mode 100644
index 0000000..bd7e819
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/render/renderer.hh"
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace ricochet::core {
+
+struct PageData
+{
+ std::vector<std::string> lines;
+ std::vector<std::string> links;
+ std::vector<render::InputField> inputs;
+ std::string title;
+ std::vector<std::string> original_lines {};
+ std::string last_search {};
+ std::size_t current_match_idx { 0 };
+ std::vector<std::size_t> 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/include/ricochet/core/ui_input.hh b/include/ricochet/core/ui_input.hh
new file mode 100644
index 0000000..bf71685
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/tui/terminal.hh"
+#include <string>
+
+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/include/ricochet/core/ui_view.hh b/include/ricochet/core/ui_view.hh
new file mode 100644
index 0000000..d9a55fa
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/tui/terminal.hh"
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+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<std::string>& lines,
+ std::size_t scroll_y,
+ std::string_view url,
+ std::string_view title );
+
+} // namespace ricochet::core
diff --git a/include/ricochet/core/utils.hh b/include/ricochet/core/utils.hh
new file mode 100644
index 0000000..d9f58dd
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <cstddef>
+#include <string>
+#include <vector>
+
+namespace ricochet::core {
+
+std::vector<std::string> split_into_lines( const std::string& text );
+std::string normalize_url( const std::string& target, const std::string& current_url );
+std::string strip_ddg_tracker( std::string target );
+std::vector<std::string> wrap_lines( const std::vector<std::string>& original_lines, std::size_t wrap_width );
+
+} // namespace ricochet::core
diff --git a/include/ricochet/net/config.hh b/include/ricochet/net/config.hh
new file mode 100644
index 0000000..fa73193
--- /dev/null
+++ b/include/ricochet/net/config.hh
@@ -0,0 +1,35 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <string>
+
+namespace ricochet::net {
+
+std::string get_config_path( const std::string& filename );
+
+struct BrowserConfig
+{
+ std::string user_agent;
+ std::string homepage;
+};
+
+BrowserConfig& get_browser_config();
+
+} // namespace ricochet::net
diff --git a/include/ricochet/net/cookie_jar.hh b/include/ricochet/net/cookie_jar.hh
new file mode 100644
index 0000000..5c5befb
--- /dev/null
+++ b/include/ricochet/net/cookie_jar.hh
@@ -0,0 +1,35 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <string>
+#include <unordered_map>
+
+namespace ricochet::net {
+
+using CookieJar = std::unordered_map<std::string, std::unordered_map<std::string, std::string>>;
+
+void load_cookies_from_file( CookieJar& jar );
+CookieJar& get_cookie_jar();
+void save_cookies_to_file( const CookieJar& jar );
+
+void update_cookies( const std::string& raw_response, const std::string& host );
+std::string build_cookie_header( const std::string& host );
+
+} // namespace ricochet::net
diff --git a/include/ricochet/net/http_client.hh b/include/ricochet/net/http_client.hh
index f6d8aa3..f57cbcf 100644
--- a/include/ricochet/net/http_client.hh
+++ b/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<HttpResponse, std::string> 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/include/ricochet/net/http_utils.hh b/include/ricochet/net/http_utils.hh
new file mode 100644
index 0000000..3d96e79
--- /dev/null
+++ b/include/ricochet/net/http_utils.hh
@@ -0,0 +1,30 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+std::string dechunk( std::string_view body );
+std::string remove_html_comments( std::string html );
+std::string remove_tag_blocks( std::string html, const std::string& tag_name );
+
+} // namespace ricochet::net
diff --git a/include/ricochet/net/url_parser.hh b/include/ricochet/net/url_parser.hh
new file mode 100644
index 0000000..039e4dc
--- /dev/null
+++ b/include/ricochet/net/url_parser.hh
@@ -0,0 +1,37 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <expected>
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+struct ParsedUrl
+{
+ std::string host {};
+ std::string path {};
+};
+
+std::expected<ParsedUrl, std::string> parse_url( std::string_view url );
+
+std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host );
+
+} // namespace ricochet::net
diff --git a/include/ricochet/render/input_renderer.hh b/include/ricochet/render/input_renderer.hh
new file mode 100644
index 0000000..0114673
--- /dev/null
+++ b/include/ricochet/render/input_renderer.hh
@@ -0,0 +1,34 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/parser/dom_tree.hh"
+#include "ricochet/render/renderer.hh"
+#include <string>
+
+namespace ricochet::render {
+
+struct FormContext;
+
+void render_input_node( const parser::DomNode& node,
+ RenderResult& result,
+ const FormContext& ctx,
+ std::string& output );
+
+} // namespace ricochet::render
diff --git a/include/ricochet/render/internal.hh b/include/ricochet/render/internal.hh
new file mode 100644
index 0000000..5e7cac0
--- /dev/null
+++ b/include/ricochet/render/internal.hh
@@ -0,0 +1,35 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/parser/dom_tree.hh"
+#include "ricochet/render/renderer.hh"
+#include <string>
+
+namespace ricochet::render {
+
+struct FormContext
+{
+ std::string action;
+ std::string method;
+};
+
+void render_node( const parser::DomNode& node, RenderResult& result, FormContext ctx );
+
+} // namespace ricochet::render
diff --git a/include/ricochet/render/table_renderer.hh b/include/ricochet/render/table_renderer.hh
new file mode 100644
index 0000000..5efc42c
--- /dev/null
+++ b/include/ricochet/render/table_renderer.hh
@@ -0,0 +1,30 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ricochet/parser/dom_tree.hh"
+#include "ricochet/render/renderer.hh"
+
+namespace ricochet::render {
+
+struct FormContext;
+
+void render_table( const parser::DomNode& node, RenderResult& result, const FormContext& ctx );
+
+} // namespace ricochet::render
diff --git a/include/ricochet/render/utils.hh b/include/ricochet/render/utils.hh
new file mode 100644
index 0000000..38b3022
--- /dev/null
+++ b/include/ricochet/render/utils.hh
@@ -0,0 +1,33 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <string>
+#include <string_view>
+
+namespace ricochet::render {
+
+std::string decode_entities( std::string text );
+std::string extract_attr( const std::string& tag_name, std::string_view attr_name );
+std::size_t get_visible_length( std::string_view str );
+
+bool is_ignored_tag( std::string_view tag );
+bool is_heading_tag( std::string_view tag );
+
+} // namespace ricochet::render
diff --git a/src/core/browser.cc b/src/core/browser.cc
index c4dbb08..86a5096 100644
--- a/src/core/browser.cc
+++ b/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 <cstddef>
#include <cstdlib>
-#include <exception>
#include <fstream>
#include <iostream>
-#include <ranges>
#include <sys/stat.h>
-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<std::string> lines;
- std::vector<std::string> links;
- std::vector<render::InputField> inputs;
- std::string title;
- std::vector<std::string> original_lines {};
- std::string last_search {};
- std::size_t current_match_idx { 0 };
- std::vector<std::size_t> 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<void>( mkdir( dir.c_str(), 0755 ) );
- return dir + "/" + filename;
-}
-
-std::string extract_title( const parser::DomNode& root_node )
-{
- std::vector<const parser::DomNode*> stack;
- stack.push_back( &root_node );
-
- while ( !stack.empty() ) {
- const parser::DomNode* current = stack.back();
- stack.pop_back();
-
- std::string base_tag = current->tag_name;
- const std::size_t space_pos = base_tag.find( ' ' );
- if ( space_pos != std::string::npos ) {
- base_tag = base_tag.substr( 0, space_pos );
- }
-
- for ( char& c : base_tag ) {
- if ( c >= 'A' && c <= 'Z' ) {
- c = static_cast<char>( c + 32 );
- }
- }
-
- if ( base_tag == "title" ) {
- std::string full_title;
- for ( const auto& child : current->children ) {
- if ( child.tag_name.empty() ) {
- full_title += child.text_content;
- }
- }
- if ( !full_title.empty() ) {
- return full_title;
- }
- }
-
- for ( const auto& child : std::ranges::reverse_view( current->children ) ) {
- stack.push_back( &child );
- }
- }
- return "";
-}
-
-void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappable-parameters)
- const std::string& url )
-{
- if ( url == "ricochet://bookmarks" ) {
- return;
- }
- std::ofstream file( get_config_path( "bookmarks.txt" ), std::ios::app );
- if ( !file.is_open() ) {
- return;
- }
-
- std::string safe_title = title;
- if ( safe_title.empty() ) {
- safe_title = url;
- }
- for ( char& c : safe_title ) {
- if ( c == '\n' || c == '\r' || c == '\t' ) {
- c = ' ';
- }
- }
- file << url << '\t' << safe_title << '\n';
-}
-
-std::string generate_bookmarks_html()
-{
- std::ifstream file( get_config_path( "bookmarks.txt" ) );
- std::string html = "<html><head><title>My Bookmarks</title></head><body><h1>Saved Bookmarks</h1><ul>";
- if ( !file.is_open() ) {
- html += "<li>No bookmarks found. Press 'b' on any page to add one!</li>";
- } else {
- std::string line;
- while ( std::getline( file, line ) ) {
- const std::size_t tab_pos = line.find( '\t' );
- if ( tab_pos != std::string::npos ) {
- const std::string url = line.substr( 0, tab_pos );
- const std::string title = line.substr( tab_pos + 1 );
- html += "<li><a href=\"";
- html += url;
- html += "\">[Link] ";
- html += title;
- html += "</a></li>";
- }
- }
- }
- html += "</ul></body></html>";
- return html;
-}
-
-void append_to_history( const std::string& url, // NOLINT(bugprone-easily-swappable-parameters)
- const std::string& title )
-{
- if ( url.starts_with( "ricochet://" ) ) {
- return;
- }
- std::ofstream file( get_config_path( "history.txt" ), std::ios::app );
- if ( !file.is_open() ) {
- return;
- }
-
- std::string safe_title = title;
- if ( safe_title.empty() ) {
- safe_title = url;
- }
- for ( char& c : safe_title ) {
- if ( c == '\n' || c == '\r' || c == '\t' ) {
- c = ' ';
- }
- }
- file << url << '\t' << safe_title << '\n';
-}
-
-std::string generate_history_html()
-{
- std::ifstream file( get_config_path( "history.txt" ) );
- std::string html = "<html><head><title>History</title></head><body><h1>Browsing History</h1><ul>";
- if ( !file.is_open() ) {
- html += "<li>No history found. Start browsing!</li>";
- } else {
- std::vector<std::string> lines;
- std::string line;
- while ( std::getline( file, line ) ) {
- lines.push_back( std::move( line ) );
- }
-
- std::size_t count = 0;
- for ( auto it = lines.rbegin(); it != lines.rend() && count < 100; ++it, ++count ) {
- const std::size_t tab_pos = it->find( '\t' );
- if ( tab_pos != std::string::npos ) {
- const std::string url = it->substr( 0, tab_pos );
- const std::string title = it->substr( tab_pos + 1 );
- html += "<li><a href=\"";
- html += url;
- html += "\">[Link] ";
- html += title;
- html += "</a></li>";
- }
- }
- }
- html += "</ul></body></html>";
- return html;
-}
-
-std::string generate_help_html()
-{
- return R"(<html>
- <head><title>Ricochet Help Manual</title></head>
- <body>
- <h1>Ricochet User Manual</h1>
- <h2>1. Navigation & Browsing</h2>
- <ul>
- <li><b>j / k</b> : Scroll down / up</li>
- <li><b>H / L</b> : History backward / forward</li>
- <li><b>/</b> : Open prompt to enter a new URL</li>
- </ul>
- <h2>2. Page Interaction</h2>
- <ul>
- <li><b>f</b> : Toggle Link Hints (type the two letters to follow a link instantly)</li>
- <li><b>n</b> : Find in page (search for text)</li>
- <li><b>N</b> : Jump to the next search match</li>
- <li><b>i</b> : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)</li>
- </ul>
- <h2>3. Bookmarks & History</h2>
- <ul>
- <li><b>b</b> : Bookmark the current page</li>
- <li><b>B</b> : View all saved bookmarks</li>
- <li><b>h</b> : View browsing history</li>
- </ul>
- <h2>4. System</h2>
- <ul>
- <li><b>?</b> : Show this help manual</li>
- <li><b>q</b> : Quit Ricochet</li>
- </ul>
- </body>
- </html>)";
-}
-
-PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp )
-{
- std::string fname = "download.dat";
- const std::size_t slash = req.url.rfind( '/' );
- if ( slash != std::string::npos && slash + 1 < req.url.length() ) {
- fname = req.url.substr( slash + 1 );
- const std::size_t q_pos = fname.find( '?' );
- if ( q_pos != std::string::npos ) {
- fname = fname.substr( 0, q_pos );
- }
- }
- if ( fname.empty() ) {
- fname = "download.dat";
- }
-
- std::ofstream out( fname, std::ios::binary );
- out.write( resp.body.data(), static_cast<std::streamsize>( resp.body.size() ) );
- out.close();
-
- return { .lines = { "\033[1;32m[ Download Complete ]\033[0m",
- "",
- "Saved File : " + fname,
- "File Type : " + resp.content_type,
- "File Size : " + std::to_string( resp.body.size() ) + " bytes",
- "",
- "Press 'H' to return to the previous page." },
- .links = {},
- .inputs = {},
- .title = "Download Manager" };
-}
-
-std::vector<std::string> split_into_lines( const std::string& text )
-{
- std::vector<std::string> lines;
- std::size_t start = 0;
- while ( start < text.length() ) {
- const std::size_t end = text.find( '\n', start );
- if ( end == std::string::npos ) {
- lines.push_back( text.substr( start ) );
- break;
- }
- lines.push_back( text.substr( start, end - start ) );
- start = end + 1;
- }
- return lines;
-}
-
-PageData load_page( const HttpRequest& req )
-{
- std::string html_body;
- if ( req.url == "ricochet://bookmarks" ) {
- html_body = generate_bookmarks_html();
- } else if ( req.url == "ricochet://history" ) {
- html_body = generate_history_html();
- } else if ( req.url == "ricochet://help" ) {
- html_body = generate_help_html();
- } else {
- const net::HttpClient client;
- auto response_result = client.fetch( req.url, req.method, req.body );
- if ( !response_result.has_value() ) {
- return { .lines
- = { "\033[1;31m[!] Failed to load:\033[0m " + req.url, "", "Reason: " + response_result.error() },
- .links = {},
- .inputs = {},
- .title = "Error" };
- }
-
- const auto& resp = response_result.value();
- std::string c_type = resp.content_type;
- for ( char& c : c_type ) {
- if ( c >= 'A' && c <= 'Z' ) {
- c = static_cast<char>( c + 32 );
- }
- }
-
- const bool is_text
- = c_type.starts_with( "text/" ) || c_type.contains( "xml" ) || c_type.contains( "json" ) || c_type.empty();
-
- if ( !is_text ) {
- return process_binary_download( req, resp );
- }
- html_body = response_result.value().body;
- }
-
- const parser::HtmlLexer lexer;
- const parser::TreeBuilder builder;
- const render::Renderer renderer;
-
- const auto dom_root = builder.build( lexer.tokenize( html_body ) );
- const render::RenderResult result = renderer.render( dom_root );
- const std::string title = extract_title( dom_root );
-
- return {
- .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title };
-}
-
-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<std::string>& 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<unsigned char>( 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<unsigned char>( 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<char>( 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<std::string> 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<unsigned char>( 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<render::InputField>& inputs,
- const std::string& current_url,
- std::vector<HttpRequest>& history,
- std::size_t& history_idx )
-{
- std::string body;
- for ( const auto& in : inputs ) {
- if ( in.action == target_input.action && !in.name.empty() && in.type != "submit" && in.type != "button" ) {
- if ( ( in.type == "checkbox" || in.type == "radio" ) && !in.checked ) {
- continue;
- }
- std::string encoded = in.value;
- for ( char& ch : encoded ) {
- if ( ch == ' ' ) {
- ch = '+';
- }
- }
- if ( !body.empty() ) {
- body += "&";
- }
- body += in.name + "=" + encoded;
- }
- }
- const std::string action
- = target_input.action.empty() ? current_url : normalize_url( target_input.action, current_url );
- HttpRequest next_req { .url = action, .method = "GET", .body = "" };
- if ( target_input.method == "post" || target_input.method == "POST" ) {
- next_req.method = "POST";
- next_req.body = body;
- } else {
- const char sep = action.contains( '?' ) ? '&' : '?';
- next_req.url += sep + body;
- }
- history.resize( history_idx + 1 );
- history.push_back( next_req );
- history_idx++;
- return InputAction::Navigate;
-}
-
-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<std::string>& 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<HttpRequest>& 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<HttpRequest>& 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<std::string> wrap_lines( const std::vector<std::string>& original_lines, std::size_t wrap_width )
-{
- std::vector<std::string> 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/src/core/form_handler.cc b/src/core/form_handler.cc
new file mode 100644
index 0000000..2d018c9
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#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<render::InputField>& inputs,
+ const std::string& current_url,
+ std::vector<HttpRequest>& history,
+ std::size_t& history_idx )
+{
+ std::string body;
+ for ( const auto& in : inputs ) {
+ if ( in.action == target_input.action && !in.name.empty() && in.type != "submit" && in.type != "button" ) {
+ if ( ( in.type == "checkbox" || in.type == "radio" ) && !in.checked ) {
+ continue;
+ }
+ std::string encoded = in.value;
+ for ( char& ch : encoded ) {
+ if ( ch == ' ' ) {
+ ch = '+';
+ }
+ }
+ if ( !body.empty() ) {
+ body += "&";
+ }
+ body += in.name + "=" + encoded;
+ }
+ }
+ const std::string action
+ = target_input.action.empty() ? current_url : normalize_url( target_input.action, current_url );
+ HttpRequest next_req { .url = action, .method = "GET", .body = "" };
+ if ( target_input.method == "post" || target_input.method == "POST" ) {
+ next_req.method = "POST";
+ next_req.body = body;
+ } else {
+ const char sep = action.contains( '?' ) ? '&' : '?';
+ next_req.url += sep + body;
+ }
+ history.resize( history_idx + 1 );
+ history.push_back( next_req );
+ history_idx++;
+ return InputAction::Navigate;
+}
+
+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<std::string>& 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<HttpRequest>& 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/src/core/link_hints.cc b/src/core/link_hints.cc
new file mode 100644
index 0000000..77b91b8
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/link_hints.hh"
+#include "ricochet/core/ui_view.hh"
+#include "ricochet/core/utils.hh"
+#include <iostream>
+
+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<std::string> 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/src/core/local_data.cc b/src/core/local_data.cc
new file mode 100644
index 0000000..9115844
--- /dev/null
+++ b/src/core/local_data.cc
@@ -0,0 +1,171 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/local_data.hh"
+#include <cstdlib>
+#include <fstream>
+#include <sys/stat.h>
+#include <vector>
+
+namespace ricochet::core {
+
+std::string get_config_path( const std::string& filename )
+{
+ const char* home = std::getenv( "HOME" ); // NOLINT(concurrency-mt-unsafe)
+ const std::string dir = ( home != nullptr ) ? std::string( home ) + "/.ricochet" : ".ricochet";
+ static_cast<void>( mkdir( dir.c_str(), 0755 ) );
+ return dir + "/" + filename;
+}
+
+void save_bookmark( const std::string& title, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& url )
+{
+ if ( url == "ricochet://bookmarks" ) {
+ return;
+ }
+ std::ofstream file( get_config_path( "bookmarks.txt" ), std::ios::app );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string safe_title = title;
+ if ( safe_title.empty() ) {
+ safe_title = url;
+ }
+ for ( char& c : safe_title ) {
+ if ( c == '\n' || c == '\r' || c == '\t' ) {
+ c = ' ';
+ }
+ }
+ file << url << '\t' << safe_title << '\n';
+}
+
+std::string generate_bookmarks_html()
+{
+ std::ifstream file( get_config_path( "bookmarks.txt" ) );
+ std::string html = "<html><head><title>My Bookmarks</title></head><body><h1>Saved Bookmarks</h1><ul>";
+ if ( !file.is_open() ) {
+ html += "<li>No bookmarks found. Press 'b' on any page to add one!</li>";
+ } else {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t tab_pos = line.find( '\t' );
+ if ( tab_pos != std::string::npos ) {
+ const std::string url = line.substr( 0, tab_pos );
+ const std::string title = line.substr( tab_pos + 1 );
+ html += "<li><a href=\"";
+ html += url;
+ html += "\">[Link] ";
+ html += title;
+ html += "</a></li>";
+ }
+ }
+ }
+ html += "</ul></body></html>";
+ return html;
+}
+
+void append_to_history( const std::string& url, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& title )
+{
+ if ( url.starts_with( "ricochet://" ) ) {
+ return;
+ }
+ std::ofstream file( get_config_path( "history.txt" ), std::ios::app );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string safe_title = title;
+ if ( safe_title.empty() ) {
+ safe_title = url;
+ }
+ for ( char& c : safe_title ) {
+ if ( c == '\n' || c == '\r' || c == '\t' ) {
+ c = ' ';
+ }
+ }
+ file << url << '\t' << safe_title << '\n';
+}
+
+std::string generate_history_html()
+{
+ std::ifstream file( get_config_path( "history.txt" ) );
+ std::string html = "<html><head><title>History</title></head><body><h1>Browsing History</h1><ul>";
+ if ( !file.is_open() ) {
+ html += "<li>No history found. Start browsing!</li>";
+ } else {
+ std::vector<std::string> lines;
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ lines.push_back( std::move( line ) );
+ }
+
+ std::size_t count = 0;
+ for ( auto it = lines.rbegin(); it != lines.rend() && count < 100; ++it, ++count ) {
+ const std::size_t tab_pos = it->find( '\t' );
+ if ( tab_pos != std::string::npos ) {
+ const std::string url = it->substr( 0, tab_pos );
+ const std::string title = it->substr( tab_pos + 1 );
+ html += "<li><a href=\"";
+ html += url;
+ html += "\">[Link] ";
+ html += title;
+ html += "</a></li>";
+ }
+ }
+ }
+ html += "</ul></body></html>";
+ return html;
+}
+
+std::string generate_help_html()
+{
+ return R"(<html>
+ <head><title>Ricochet Help Manual</title></head>
+ <body>
+ <h1>Ricochet User Manual</h1>
+ <h2>1. Navigation & Browsing</h2>
+ <ul>
+ <li><b>j / k</b> : Scroll down / up</li>
+ <li><b>H / L</b> : History backward / forward</li>
+ <li><b>/</b> : Open prompt to enter a new URL</li>
+ </ul>
+ <h2>2. Page Interaction</h2>
+ <ul>
+ <li><b>f</b> : Toggle Link Hints (type the two letters to follow a link instantly)</li>
+ <li><b>n</b> : Find in page (search for text)</li>
+ <li><b>N</b> : Jump to the next search match</li>
+ <li><b>i</b> : Enter form input mode (fill textboxes, click buttons, toggle checkboxes)</li>
+ </ul>
+ <h2>3. Bookmarks & History</h2>
+ <ul>
+ <li><b>b</b> : Bookmark the current page</li>
+ <li><b>B</b> : View all saved bookmarks</li>
+ <li><b>h</b> : View browsing history</li>
+ </ul>
+ <h2>4. System</h2>
+ <ul>
+ <li><b>?</b> : Show this help manual</li>
+ <li><b>q</b> : Quit Ricochet</li>
+ </ul>
+ </body>
+ </html>)";
+}
+
+} // namespace ricochet::core
diff --git a/src/core/page_loader.cc b/src/core/page_loader.cc
new file mode 100644
index 0000000..b9a5da5
--- /dev/null
+++ b/src/core/page_loader.cc
@@ -0,0 +1,78 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/page_loader.hh"
+#include "ricochet/core/local_data.hh"
+#include "ricochet/core/page_processor.hh"
+#include "ricochet/core/utils.hh"
+#include "ricochet/parser/html_lexer.hh"
+#include "ricochet/parser/tree_builder.hh"
+#include "ricochet/render/renderer.hh"
+
+namespace ricochet::core {
+
+PageData load_page( const HttpRequest& req )
+{
+ std::string html_body;
+ if ( req.url == "ricochet://bookmarks" ) {
+ html_body = generate_bookmarks_html();
+ } else if ( req.url == "ricochet://history" ) {
+ html_body = generate_history_html();
+ } else if ( req.url == "ricochet://help" ) {
+ html_body = generate_help_html();
+ } else {
+ const net::HttpClient client;
+ auto response_result = client.fetch( req.url, req.method, req.body );
+ if ( !response_result.has_value() ) {
+ return { .lines
+ = { "\033[1;31m[!] Failed to load:\033[0m " + req.url, "", "Reason: " + response_result.error() },
+ .links = {},
+ .inputs = {},
+ .title = "Error" };
+ }
+
+ const auto& resp = response_result.value();
+ std::string c_type = resp.content_type;
+ for ( char& c : c_type ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ const bool is_text
+ = c_type.starts_with( "text/" ) || c_type.contains( "xml" ) || c_type.contains( "json" ) || c_type.empty();
+
+ if ( !is_text ) {
+ return process_binary_download( req, resp );
+ }
+ html_body = response_result.value().body;
+ }
+
+ const parser::HtmlLexer lexer;
+ const parser::TreeBuilder builder;
+ const render::Renderer renderer;
+
+ const auto dom_root = builder.build( lexer.tokenize( html_body ) );
+ const render::RenderResult result = renderer.render( dom_root );
+ const std::string title = extract_title( dom_root );
+
+ return {
+ .lines = split_into_lines( result.text ), .links = result.links, .inputs = result.inputs, .title = title };
+}
+
+} // namespace ricochet::core
diff --git a/src/core/page_processor.cc b/src/core/page_processor.cc
new file mode 100644
index 0000000..00728a9
--- /dev/null
+++ b/src/core/page_processor.cc
@@ -0,0 +1,97 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/page_processor.hh"
+#include <fstream>
+#include <ranges>
+#include <vector>
+
+namespace ricochet::core {
+
+std::string extract_title( const parser::DomNode& root_node )
+{
+ std::vector<const parser::DomNode*> stack;
+ stack.push_back( &root_node );
+
+ while ( !stack.empty() ) {
+ const parser::DomNode* current = stack.back();
+ stack.pop_back();
+
+ std::string base_tag = current->tag_name;
+ const std::size_t space_pos = base_tag.find( ' ' );
+ if ( space_pos != std::string::npos ) {
+ base_tag = base_tag.substr( 0, space_pos );
+ }
+
+ for ( char& c : base_tag ) {
+ if ( c >= 'A' && c <= 'Z' ) {
+ c = static_cast<char>( c + 32 );
+ }
+ }
+
+ if ( base_tag == "title" ) {
+ std::string full_title;
+ for ( const auto& child : current->children ) {
+ if ( child.tag_name.empty() ) {
+ full_title += child.text_content;
+ }
+ }
+ if ( !full_title.empty() ) {
+ return full_title;
+ }
+ }
+
+ for ( const auto& child : std::ranges::reverse_view( current->children ) ) {
+ stack.push_back( &child );
+ }
+ }
+ return "";
+}
+
+PageData process_binary_download( const HttpRequest& req, const net::HttpResponse& resp )
+{
+ std::string fname = "download.dat";
+ const std::size_t slash = req.url.rfind( '/' );
+ if ( slash != std::string::npos && slash + 1 < req.url.length() ) {
+ fname = req.url.substr( slash + 1 );
+ const std::size_t q_pos = fname.find( '?' );
+ if ( q_pos != std::string::npos ) {
+ fname = fname.substr( 0, q_pos );
+ }
+ }
+ if ( fname.empty() ) {
+ fname = "download.dat";
+ }
+
+ std::ofstream out( fname, std::ios::binary );
+ out.write( resp.body.data(), static_cast<std::streamsize>( resp.body.size() ) );
+ out.close();
+
+ return { .lines = { "\033[1;32m[ Download Complete ]\033[0m",
+ "",
+ "Saved File : " + fname,
+ "File Type : " + resp.content_type,
+ "File Size : " + std::to_string( resp.body.size() ) + " bytes",
+ "",
+ "Press 'H' to return to the previous page." },
+ .links = {},
+ .inputs = {},
+ .title = "Download Manager" };
+}
+
+} // namespace ricochet::core
diff --git a/src/core/search_handler.cc b/src/core/search_handler.cc
new file mode 100644
index 0000000..cab9e9c
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#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/src/core/ui_input.cc b/src/core/ui_input.cc
new file mode 100644
index 0000000..0f57dd1
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/ui_input.hh"
+#include "ricochet/core/browser.hh"
+#include <iostream>
+
+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<unsigned char>( 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<unsigned char>( ch ) ) ) {
+ input += ch;
+ std::cout << ch;
+ std::cout.flush();
+ }
+ }
+ terminal.show_cursor( false );
+ return input;
+}
+
+} // namespace ricochet::core
diff --git a/src/core/ui_view.cc b/src/core/ui_view.cc
new file mode 100644
index 0000000..5164fe0
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/ui_view.hh"
+#include <iostream>
+
+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<std::string>& 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/src/core/utils.cc b/src/core/utils.cc
new file mode 100644
index 0000000..ddc6321
--- /dev/null
+++ b/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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/core/utils.hh"
+
+namespace ricochet::core {
+
+std::vector<std::string> split_into_lines( const std::string& text )
+{
+ std::vector<std::string> lines;
+ std::size_t start = 0;
+ while ( start < text.length() ) {
+ const std::size_t end = text.find( '\n', start );
+ if ( end == std::string::npos ) {
+ lines.push_back( text.substr( start ) );
+ break;
+ }
+ lines.push_back( text.substr( start, end - start ) );
+ start = end + 1;
+ }
+ return lines;
+}
+
+std::string normalize_url( const std::string& target, const std::string& current_url )
+{
+ if ( target.starts_with( "/" ) ) {
+ if ( target.starts_with( "//" ) ) {
+ return "https:" + target;
+ }
+ const std::size_t slash_pos = current_url.find( '/', 8 );
+ const std::string domain
+ = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos );
+ return domain + target;
+ }
+ if ( !target.starts_with( "http" ) ) {
+ const std::size_t last_slash = current_url.rfind( '/' );
+ if ( last_slash > 7 ) {
+ return current_url.substr( 0, last_slash + 1 ) + target;
+ }
+ return current_url + "/" + target;
+ }
+ return target;
+}
+
+std::string strip_ddg_tracker( std::string target )
+{
+ const std::size_t uddg_pos = target.find( "uddg=" );
+ if ( uddg_pos != std::string::npos ) {
+ const std::size_t end_pos = target.find( '&', uddg_pos );
+ const std::string encoded = target.substr( uddg_pos + 5, end_pos - ( uddg_pos + 5 ) );
+ std::string decoded;
+ for ( std::size_t i = 0; i < encoded.length(); ++i ) {
+ if ( encoded[i] == '%' && i + 2 < encoded.length() ) {
+ const std::string hex = encoded.substr( i + 1, 2 );
+ try {
+ decoded += static_cast<char>( std::stoi( hex, nullptr, 16 ) );
+ } catch ( const std::exception& ) {
+ (void)0;
+ }
+ i += 2;
+ } else {
+ decoded += encoded[i];
+ }
+ }
+ return decoded;
+ }
+ return target;
+}
+
+std::vector<std::string> wrap_lines( const std::vector<std::string>& original_lines, std::size_t wrap_width )
+{
+ std::vector<std::string> 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/src/net/config.cc b/src/net/config.cc
new file mode 100644
index 0000000..6f257df
--- /dev/null
+++ b/src/net/config.cc
@@ -0,0 +1,64 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/net/config.hh"
+
+#include <cstdlib>
+#include <fstream>
+#include <sys/stat.h>
+
+namespace ricochet::net {
+
+std::string get_config_path( const std::string& filename )
+{
+ const char* home = std::getenv( "HOME" ); // NOLINT(concurrency-mt-unsafe)
+ const std::string dir = ( home != nullptr ) ? std::string( home ) + "/.ricochet" : ".ricochet";
+ static_cast<void>( mkdir( dir.c_str(), 0755 ) );
+ return dir + "/" + filename;
+}
+
+BrowserConfig& get_browser_config()
+{
+ static BrowserConfig config { .user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ .homepage = "https://wikipedia.org" };
+ static bool loaded = false;
+ if ( !loaded ) {
+ std::ifstream file( get_config_path( "config.txt" ) );
+ if ( file.is_open() ) {
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t eq = line.find( '=' );
+ if ( eq == std::string::npos ) {
+ continue;
+ }
+ const std::string key = line.substr( 0, eq );
+ const std::string val = line.substr( eq + 1 );
+ if ( key == "user_agent" ) {
+ config.user_agent = val;
+ } else if ( key == "homepage" ) {
+ config.homepage = val;
+ }
+ }
+ }
+ loaded = true;
+ }
+ return config;
+}
+
+} // namespace ricochet::net
diff --git a/src/net/cookie_jar.cc b/src/net/cookie_jar.cc
new file mode 100644
index 0000000..1328511
--- /dev/null
+++ b/src/net/cookie_jar.cc
@@ -0,0 +1,124 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/net/cookie_jar.hh"
+#include "ricochet/net/config.hh"
+
+#include <fstream>
+
+namespace ricochet::net {
+
+void load_cookies_from_file( CookieJar& jar )
+{
+ std::ifstream file( get_config_path( "cookies.txt" ) );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ std::string line;
+ while ( std::getline( file, line ) ) {
+ const std::size_t t1 = line.find( '\t' );
+ if ( t1 == std::string::npos ) {
+ continue;
+ }
+ const std::size_t t2 = line.find( '\t', t1 + 1 );
+ if ( t2 != std::string::npos ) {
+ jar[line.substr( 0, t1 )][line.substr( t1 + 1, t2 - t1 - 1 )] = line.substr( t2 + 1 );
+ }
+ }
+}
+
+CookieJar& get_cookie_jar()
+{
+ static CookieJar jar;
+ static bool initialized = false;
+ if ( !initialized ) {
+ load_cookies_from_file( jar );
+ initialized = true;
+ }
+ return jar;
+}
+
+void save_cookies_to_file( const CookieJar& jar )
+{
+ std::ofstream file( get_config_path( "cookies.txt" ), std::ios::trunc );
+ if ( !file.is_open() ) {
+ return;
+ }
+
+ for ( const auto& [domain, cookies] : jar ) {
+ for ( const auto& [k, v] : cookies ) {
+ file << domain << '\t' << k << '\t' << v << '\n';
+ }
+ }
+}
+
+void update_cookies( const std::string& raw_response, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& host )
+{
+ std::size_t pos = 0;
+ bool updated = false;
+ while ( ( pos = raw_response.find( "Set-Cookie: ", pos ) ) != std::string::npos ) {
+ pos += 12;
+ const std::size_t end_pos = raw_response.find( '\r', pos );
+ if ( end_pos == std::string::npos ) {
+ break;
+ }
+
+ const std::string cookie_line = raw_response.substr( pos, end_pos - pos );
+ const std::size_t semi_pos = cookie_line.find( ';' );
+ const std::string cookie_pair
+ = ( semi_pos == std::string::npos ) ? cookie_line : cookie_line.substr( 0, semi_pos );
+
+ const std::size_t eq_pos = cookie_pair.find( '=' );
+ if ( eq_pos != std::string::npos ) {
+ const std::string key = cookie_pair.substr( 0, eq_pos );
+ const std::string val = cookie_pair.substr( eq_pos + 1 );
+ get_cookie_jar()[host][key] = val;
+ updated = true;
+ }
+ }
+ if ( updated ) {
+ save_cookies_to_file( get_cookie_jar() );
+ }
+}
+
+std::string build_cookie_header( const std::string& host )
+{
+ const auto& jar = get_cookie_jar();
+ auto it = jar.find( host );
+ if ( it == jar.end() || it->second.empty() ) {
+ return "";
+ }
+
+ std::string header = "Cookie: ";
+ bool first = true;
+ for ( const auto& [k, v] : it->second ) {
+ if ( !first ) {
+ header += "; ";
+ }
+ header += k;
+ header += "=";
+ header += v;
+ first = false;
+ }
+ header += "\r\n";
+ return header;
+}
+
+} // namespace ricochet::net
diff --git a/src/net/http_client.cc b/src/net/http_client.cc
index 5f5b449..4a10c02 100644
--- a/src/net/http_client.cc
+++ b/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 <cstddef>
-#include <cstdlib>
#include <format>
-#include <fstream>
#include <string>
#include <string_view>
-#include <sys/stat.h>
-#include <unordered_map>
namespace ricochet::net {
namespace {
-struct ParsedUrl
-{
- std::string host {};
- std::string path {};
-};
-
-std::expected<ParsedUrl, std::string> parse_url( std::string_view url )
-{
- std::string_view prefix = "http://";
- if ( url.starts_with( "https://" ) ) {
- prefix = "https://";
- } else if ( url.starts_with( "http://" ) ) {
- prefix = "http://";
- } else {
- return std::unexpected( "Only 'http://' or 'https://' URLs are supported." );
- }
-
- url.remove_prefix( prefix.length() );
-
- const auto sep_pos = url.find_first_of( "/?#" );
-
- ParsedUrl result;
- if ( sep_pos == std::string_view::npos ) {
- result.host = std::string( url );
- result.path = "/";
- } else {
- result.host = std::string( url.substr( 0, sep_pos ) );
- 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 ) ) != std::string::npos ) {
- auto end_pos = html.find( "-->", start_pos );
- if ( end_pos != std::string::npos ) {
- html.erase( start_pos, end_pos - start_pos + 3 );
- } else {
- html.erase( start_pos );
- break;
- }
- }
- return html;
-}
-
-std::string remove_tag_blocks( std::string html, const std::string& tag_name ) // NOLINT
-{
- const std::string open_tag = "<" + tag_name;
- const std::string close_tag = "</" + tag_name + ">";
- std::size_t start_pos = 0;
-
- while ( ( start_pos = html.find( open_tag, start_pos ) ) != std::string::npos ) {
- const std::size_t end_pos = html.find( close_tag, start_pos );
- if ( end_pos != std::string::npos ) {
- html.erase( start_pos, end_pos - start_pos + close_tag.length() );
- } else {
- html.erase( start_pos );
- break;
- }
- }
- return html;
-}
-
-std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host )
-{
- auto loc_pos = raw_response.find( "Location: " );
- if ( loc_pos == std::string::npos ) {
- loc_pos = raw_response.find( "location: " );
- }
- if ( loc_pos != std::string::npos ) {
- loc_pos += 10;
- auto end_pos = raw_response.find( '\r', loc_pos );
- if ( end_pos == std::string::npos ) {
- end_pos = raw_response.find( '\n', loc_pos );
- }
- if ( end_pos != std::string::npos ) {
- std::string next_url = raw_response.substr( loc_pos, end_pos - loc_pos );
- while ( !next_url.empty() && std::isspace( static_cast<unsigned char>( next_url.front() ) ) ) {
- next_url.erase( 0, 1 );
- }
- if ( next_url.starts_with( "/" ) ) {
- next_url = ( is_https ? "https://" : "http://" ) + 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<void>( 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<std::string, std::unordered_map<std::string, std::string>>;
-
-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<char>( c + 32 );
+ }
+ }
+
+ if ( ct_lower.starts_with( "text/" ) || ct_lower.contains( "xml" ) ) {
+ response.body = remove_html_comments( response.body );
+ response.body = remove_tag_blocks( response.body, "script" );
+ response.body = remove_tag_blocks( response.body, "style" );
+ }
+}
+
} // namespace
-std::expected<HttpResponse, std::string> HttpClient::fetch( std::string_view url, // NOLINT
- std::string_view method,
- std::string_view body ) const
+std::expected<HttpResponse, std::string>
+HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static)
+ std::string_view url, // NOLINT(bugprone-easily-swappable-parameters)
+ std::string_view method, // NOLINT(bugprone-easily-swappable-parameters)
+ std::string_view body ) const // NOLINT(bugprone-easily-swappable-parameters)
{
std::string current_url( url );
std::string current_method( method );
@@ -403,35 +187,8 @@ std::expected<HttpResponse, std::string> 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<HttpResponse, std::string> 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<char>( c + 32 );
- }
- }
-
- if ( ct_lower.starts_with( "text/" ) || ct_lower.contains( "xml" ) ) {
- response.body = remove_html_comments( response.body );
- response.body = remove_tag_blocks( response.body, "script" );
- response.body = remove_tag_blocks( response.body, "style" );
- }
+ sanitize_response_body( response, raw_response );
return response;
}
diff --git a/src/net/http_utils.cc b/src/net/http_utils.cc
new file mode 100644
index 0000000..98f6dae
--- /dev/null
+++ b/src/net/http_utils.cc
@@ -0,0 +1,93 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/net/http_utils.hh"
+
+#include <string>
+#include <string_view>
+
+namespace ricochet::net {
+
+std::string dechunk( std::string_view body )
+{
+ std::string result;
+ std::size_t pos = 0;
+ while ( pos < body.size() ) {
+ auto line_end = body.find( "\r\n", pos );
+ if ( line_end == std::string_view::npos ) {
+ break;
+ }
+
+ const std::string len_str( body.substr( pos, line_end - pos ) );
+ unsigned int chunk_size = 0;
+ try {
+ chunk_size = std::stoul( len_str, nullptr, 16 );
+ } catch ( ... ) {
+ break;
+ }
+
+ if ( chunk_size == 0 ) {
+ break;
+ }
+
+ pos = line_end + 2;
+ if ( pos + chunk_size > body.size() ) {
+ break;
+ }
+ result.append( body.substr( pos, chunk_size ) );
+
+ pos += chunk_size + 2;
+ }
+ return result;
+}
+
+std::string remove_html_comments( std::string html )
+{
+ std::size_t start_pos = 0;
+ while ( ( start_pos = html.find( "<!--", start_pos ) ) != std::string::npos ) {
+ auto end_pos = html.find( "-->", start_pos );
+ if ( end_pos != std::string::npos ) {
+ html.erase( start_pos, end_pos - start_pos + 3 );
+ } else {
+ html.erase( start_pos );
+ break;
+ }
+ }
+ return html;
+}
+
+std::string remove_tag_blocks( std::string html, // NOLINT(bugprone-easily-swappable-parameters)
+ const std::string& tag_name )
+{
+ const std::string open_tag = "<" + tag_name;
+ const std::string close_tag = "</" + tag_name + ">";
+ std::size_t start_pos = 0;
+
+ while ( ( start_pos = html.find( open_tag, start_pos ) ) != std::string::npos ) {
+ const std::size_t end_pos = html.find( close_tag, start_pos );
+ if ( end_pos != std::string::npos ) {
+ html.erase( start_pos, end_pos - start_pos + close_tag.length() );
+ } else {
+ html.erase( start_pos );
+ break;
+ }
+ }
+ return html;
+}
+
+} // namespace ricochet::net
diff --git a/src/net/url_parser.cc b/src/net/url_parser.cc
new file mode 100644
index 0000000..95e86ef
--- /dev/null
+++ b/src/net/url_parser.cc
@@ -0,0 +1,87 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/net/url_parser.hh"
+
+#include <cctype>
+
+namespace ricochet::net {
+
+std::expected<ParsedUrl, std::string> parse_url( std::string_view url )
+{
+ std::string_view prefix = "http://";
+ if ( url.starts_with( "https://" ) ) {
+ prefix = "https://";
+ } else if ( url.starts_with( "http://" ) ) {
+ prefix = "http://";
+ } else {
+ return std::unexpected( "Only 'http://' or 'https://' URLs are supported." );
+ }
+
+ url.remove_prefix( prefix.length() );
+
+ const auto sep_pos = url.find_first_of( "/?#" );
+
+ ParsedUrl result;
+ if ( sep_pos == std::string_view::npos ) {
+ result.host = std::string( url );
+ result.path = "/";
+ } else {
+ result.host = std::string( url.substr( 0, sep_pos ) );
+ std::string_view path_view = url.substr( sep_pos );
+ const auto hash_pos = path_view.find( '#' );
+ if ( hash_pos != std::string_view::npos ) {
+ path_view = path_view.substr( 0, hash_pos );
+ }
+ result.path = std::string( path_view );
+ if ( result.path.empty() || result.path[0] != '/' ) {
+ result.path.insert( 0, "/" );
+ }
+ }
+ return result;
+}
+
+std::string extract_redirect_url( const std::string& raw_response, bool is_https, const std::string& host )
+{
+ auto loc_pos = raw_response.find( "Location: " );
+ if ( loc_pos == std::string::npos ) {
+ loc_pos = raw_response.find( "location: " );
+ }
+ if ( loc_pos != std::string::npos ) {
+ loc_pos += 10;
+ auto end_pos = raw_response.find( '\r', loc_pos );
+ if ( end_pos == std::string::npos ) {
+ end_pos = raw_response.find( '\n', loc_pos );
+ }
+ if ( end_pos != std::string::npos ) {
+ std::string next_url = raw_response.substr( loc_pos, end_pos - loc_pos );
+ while ( !next_url.empty() && std::isspace( static_cast<unsigned char>( next_url.front() ) ) ) {
+ next_url.erase( 0, 1 );
+ }
+ if ( next_url.starts_with( "//" ) ) {
+ next_url = ( is_https ? "https:" : "http:" ) + next_url;
+ } else if ( next_url.starts_with( "/" ) ) {
+ next_url = ( is_https ? "https://" : "http://" ) + host + next_url;
+ }
+ return next_url;
+ }
+ }
+ return "";
+}
+
+} // namespace ricochet::net
diff --git a/src/render/input_renderer.cc b/src/render/input_renderer.cc
new file mode 100644
index 0000000..076fa2a
--- /dev/null
+++ b/src/render/input_renderer.cc
@@ -0,0 +1,82 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/render/input_renderer.hh"
+#include "ricochet/render/internal.hh"
+#include "ricochet/render/utils.hh"
+
+namespace ricochet::render {
+
+namespace {
+
+std::string build_input_hint( const std::string& tag_name, bool checked )
+{
+ std::string placeholder = extract_attr( tag_name, "placeholder" );
+ const std::string type = extract_attr( tag_name, "type" );
+ std::string val = extract_attr( tag_name, "value" );
+
+ if ( !placeholder.empty() ) {
+ return placeholder;
+ }
+ if ( type == "submit" || type == "button" ) {
+ return val.empty() ? "Submit" : val;
+ }
+ if ( type == "checkbox" ) {
+ return checked ? "[X] " + val : "[ ] " + val;
+ }
+ if ( type == "radio" ) {
+ return checked ? "(X) " + val : "( ) " + val;
+ }
+ if ( type == "hidden" ) {
+ return "hidden";
+ }
+ if ( !val.empty() ) {
+ return val;
+ }
+ return "text";
+}
+
+} // namespace
+
+void render_input_node( const parser::DomNode& node,
+ RenderResult& result,
+ const FormContext& ctx,
+ std::string& output )
+{
+ std::string type = extract_attr( node.tag_name, "type" );
+ const std::size_t space_pos = node.tag_name.find( ' ' );
+ const std::string base_tag
+ = ( space_pos == std::string::npos ) ? node.tag_name : node.tag_name.substr( 0, space_pos );
+ if ( base_tag == "button" && type.empty() ) {
+ type = "submit";
+ }
+ const std::string default_val = extract_attr( node.tag_name, "value" );
+ const bool is_checked = node.tag_name.contains( "checked" );
+ result.inputs.push_back(
+ { extract_attr( node.tag_name, "name" ), ctx.action, ctx.method, default_val, type, is_checked } );
+
+ std::string hint = build_input_hint( node.tag_name, is_checked );
+ if ( base_tag == "button" ) {
+ hint = "Submit";
+ }
+ if ( hint != "hidden" ) {
+ output += " \033[7;33m[I" + std::to_string( result.inputs.size() ) + ":" + hint + "]\033[0m ";
+ }
+}
+
+} // namespace ricochet::render
diff --git a/src/render/renderer.cc b/src/render/renderer.cc
index 53eca1b..7fb665f 100644
--- a/src/render/renderer.cc
+++ b/src/render/renderer.cc
@@ -17,92 +17,15 @@
*/
#include "ricochet/render/renderer.hh"
-#include <algorithm>
-#include <array>
-#include <cstdint>
-#include <stdexcept>
+#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<std::pair<std::string_view, std::string_view>, 25> entities
- = { { { "&nbsp;", " " }, { "&lt;", "<" }, { "&gt;", ">" }, { "&amp;", "&" }, { "&quot;", "\"" },
- { "&apos;", "'" }, { "&copy;", "©" }, { "&reg;", "®" }, { "&trade;", "™" }, { "&euro;", "€" },
- { "&pound;", "£" }, { "&yen;", "¥" }, { "&cent;", "¢" }, { "&sect;", "§" }, { "&para;", "¶" },
- { "&hellip;", "…" }, { "&dagger;", "†" }, { "&Dagger;", "‡" }, { "&bull;", "•" }, { "&prime;", "′" },
- { "&Prime;", "″" }, { "&lsaquo;", "‹" }, { "&rsaquo;", "›" }, { "&oline;", "‾" }, { "&frasl;", "⁄" } } };
-
- for ( const auto& [entity, replacement] : entities ) {
- std::size_t pos = 0;
- while ( ( pos = text.find( entity, pos ) ) != std::string::npos ) {
- text.replace( pos, entity.length(), replacement );
- pos += replacement.length();
- }
- }
-
- std::size_t pos = 0;
- while ( ( pos = text.find( "&#", pos ) ) != std::string::npos ) {
- const std::size_t end = text.find( ';', pos );
- if ( end != std::string::npos && end - pos < 10 ) {
- const std::string code = text.substr( pos + 2, end - pos - 2 );
- try {
- std::uint32_t cp = 0;
- if ( !code.empty() && ( code[0] == 'x' || code[0] == 'X' ) ) {
- cp = std::stoul( code.substr( 1 ), nullptr, 16 );
- } else {
- cp = std::stoul( code );
- }
- if ( cp < 128 ) {
- text.replace( pos, end - pos + 1, 1, static_cast<char>( cp ) );
- continue;
- }
- } catch ( const std::invalid_argument& ) {
- (void)0; // 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<unsigned char>( str[i] );
- if ( c == 0x1B ) {
- in_ansi = true;
- } else if ( in_ansi && c == 'm' ) {
- in_ansi = false;
- } else if ( !in_ansi ) {
- if ( ( c & 0x80 ) == 0 || ( c & 0xE0 ) == 0xC0 ) {
- len += 1;
- } // 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<std::size_t>& widths )
-{
- std::size_t col_idx = 0;
- for ( const auto& cell : row.children ) {
- const std::size_t space_pos = cell.tag_name.find( ' ' );
- const std::string tag
- = ( space_pos == std::string::npos ) ? cell.tag_name : cell.tag_name.substr( 0, space_pos );
- if ( tag == "td" || tag == "th" ) {
- const std::size_t len = get_node_text_len( cell ) + 2;
- if ( col_idx >= widths.size() ) {
- widths.push_back( len );
- } else if ( len > widths[col_idx] ) {
- widths[col_idx] = len;
- }
- col_idx++;
- }
- }
-}
-
-std::vector<std::size_t> calc_table_widths( const parser::DomNode& table_node )
-{
- std::vector<std::size_t> widths;
- for ( const auto& child : table_node.children ) {
- const std::size_t space_pos = child.tag_name.find( ' ' );
- const std::string tag
- = ( space_pos == std::string::npos ) ? child.tag_name : child.tag_name.substr( 0, space_pos );
- if ( tag == "tr" ) {
- process_table_row_widths( child, widths );
- } else if ( tag == "tbody" || tag == "thead" ) {
- for ( const auto& row : child.children ) {
- if ( row.tag_name.starts_with( "tr" ) ) {
- process_table_row_widths( row, widths );
- }
- }
- }
- }
- for ( auto& w : widths ) {
- w = std::min<std::size_t>( w, 30 );
- }
- return widths;
-}
-
-void render_table_row( const parser::DomNode& row, // NOLINT(misc-no-recursion)
- RenderResult& result,
- const FormContext& ctx,
- const std::vector<std::size_t>& widths )
-{
- std::string& output = result.text;
- output += "\033[90m|\033[0m ";
- std::size_t col_idx = 0;
- for ( const auto& cell : row.children ) {
- const std::size_t cell_space = cell.tag_name.find( ' ' );
- const std::string c_tag
- = ( cell_space == std::string::npos ) ? cell.tag_name : cell.tag_name.substr( 0, cell_space );
- if ( c_tag != "td" && c_tag != "th" ) {
- continue;
- }
-
- const std::size_t text_start = output.size();
- if ( c_tag == "th" ) {
- output += "\033[1;36m";
- }
-
- for ( const auto& cell_child : cell.children ) {
- render_node( cell_child, result, ctx );
- }
-
- if ( c_tag == "th" ) {
- output += "\033[0m";
- }
-
- const std::string added = output.substr( text_start );
- const std::size_t vis_len = get_visible_length( added );
- const std::size_t tw = ( col_idx < widths.size() ) ? widths[col_idx] : 10;
-
- if ( vis_len < tw ) {
- output.append( tw - vis_len, ' ' );
- }
- output += " \033[90m|\033[0m ";
- col_idx++;
- }
- output += "\n";
-}
-
-void render_table( const parser::DomNode& node, // NOLINT(misc-no-recursion)
- RenderResult& result,
- const FormContext& ctx )
-{
- std::string& output = result.text;
- const std::vector<std::size_t> widths = calc_table_widths( node );
-
- output += "\n\033[90m+" + std::string( 78, '-' ) + "+\033[0m\n";
-
- for ( const auto& child : node.children ) {
- const std::size_t child_space = child.tag_name.find( ' ' );
- const std::string child_tag
- = ( child_space == std::string::npos ) ? child.tag_name : child.tag_name.substr( 0, child_space );
- if ( child_tag == "tr" ) {
- render_table_row( child, result, ctx, widths );
- } else if ( child_tag == "tbody" || child_tag == "thead" ) {
- for ( const auto& row : child.children ) {
- if ( row.tag_name.starts_with( "tr" ) ) {
- render_table_row( row, result, ctx, widths );
- }
- }
- }
- }
- output += "\033[90m+" + std::string( 78, '-' ) + "+\033[0m\n";
-}
+} // namespace
-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/src/render/table_renderer.cc b/src/render/table_renderer.cc
new file mode 100644
index 0000000..37e6bfb
--- /dev/null
+++ b/src/render/table_renderer.cc
@@ -0,0 +1,147 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/render/table_renderer.hh"
+#include "ricochet/render/internal.hh"
+#include "ricochet/render/utils.hh"
+#include <algorithm>
+
+namespace ricochet::render {
+
+namespace {
+
+std::size_t get_node_text_len( const parser::DomNode& node ) // NOLINT(misc-no-recursion)
+{
+ std::size_t len = get_visible_length( decode_entities( node.text_content ) );
+ for ( const auto& child : node.children ) {
+ len += get_node_text_len( child );
+ }
+ return len;
+}
+
+void process_table_row_widths( const parser::DomNode& row, std::vector<std::size_t>& widths )
+{
+ std::size_t col_idx = 0;
+ for ( const auto& cell : row.children ) {
+ const std::size_t space_pos = cell.tag_name.find( ' ' );
+ const std::string tag
+ = ( space_pos == std::string::npos ) ? cell.tag_name : cell.tag_name.substr( 0, space_pos );
+ if ( tag == "td" || tag == "th" ) {
+ const std::size_t len = get_node_text_len( cell ) + 2;
+ if ( col_idx >= widths.size() ) {
+ widths.push_back( len );
+ } else if ( len > widths[col_idx] ) {
+ widths[col_idx] = len;
+ }
+ col_idx++;
+ }
+ }
+}
+
+std::vector<std::size_t> calc_table_widths( const parser::DomNode& table_node )
+{
+ std::vector<std::size_t> widths;
+ for ( const auto& child : table_node.children ) {
+ const std::size_t space_pos = child.tag_name.find( ' ' );
+ const std::string tag
+ = ( space_pos == std::string::npos ) ? child.tag_name : child.tag_name.substr( 0, space_pos );
+ if ( tag == "tr" ) {
+ process_table_row_widths( child, widths );
+ } else if ( tag == "tbody" || tag == "thead" ) {
+ for ( const auto& row : child.children ) {
+ if ( row.tag_name.starts_with( "tr" ) ) {
+ process_table_row_widths( row, widths );
+ }
+ }
+ }
+ }
+ for ( auto& w : widths ) {
+ w = std::min<std::size_t>( w, 30 );
+ }
+ return widths;
+}
+
+void render_table_row( const parser::DomNode& row,
+ RenderResult& result,
+ const FormContext& ctx,
+ const std::vector<std::size_t>& widths )
+{
+ std::string& output = result.text;
+ output += "\033[90m|\033[0m ";
+ std::size_t col_idx = 0;
+ for ( const auto& cell : row.children ) {
+ const std::size_t cell_space = cell.tag_name.find( ' ' );
+ const std::string c_tag
+ = ( cell_space == std::string::npos ) ? cell.tag_name : cell.tag_name.substr( 0, cell_space );
+ if ( c_tag != "td" && c_tag != "th" ) {
+ continue;
+ }
+
+ const std::size_t text_start = output.size();
+ if ( c_tag == "th" ) {
+ output += "\033[1;36m";
+ }
+
+ for ( const auto& cell_child : cell.children ) {
+ render_node( cell_child, result, ctx );
+ }
+
+ if ( c_tag == "th" ) {
+ output += "\033[0m";
+ }
+
+ const std::string added = output.substr( text_start );
+ const std::size_t vis_len = get_visible_length( added );
+ const std::size_t tw = ( col_idx < widths.size() ) ? widths[col_idx] : 10;
+
+ if ( vis_len < tw ) {
+ output.append( tw - vis_len, ' ' );
+ }
+ output += " \033[90m|\033[0m ";
+ col_idx++;
+ }
+ output += "\n";
+}
+
+} // namespace
+
+void render_table( const parser::DomNode& node, RenderResult& result, const FormContext& ctx )
+{
+ std::string& output = result.text;
+ const std::vector<std::size_t> widths = calc_table_widths( node );
+
+ output += "\n\033[90m+" + std::string( 78, '-' ) + "+\033[0m\n";
+
+ for ( const auto& child : node.children ) {
+ const std::size_t child_space = child.tag_name.find( ' ' );
+ const std::string child_tag
+ = ( child_space == std::string::npos ) ? child.tag_name : child.tag_name.substr( 0, child_space );
+ if ( child_tag == "tr" ) {
+ render_table_row( child, result, ctx, widths );
+ } else if ( child_tag == "tbody" || child_tag == "thead" ) {
+ for ( const auto& row : child.children ) {
+ if ( row.tag_name.starts_with( "tr" ) ) {
+ render_table_row( row, result, ctx, widths );
+ }
+ }
+ }
+ }
+ output += "\033[90m+" + std::string( 78, '-' ) + "+\033[0m\n";
+}
+
+} // namespace ricochet::render
diff --git a/src/render/utils.cc b/src/render/utils.cc
new file mode 100644
index 0000000..9c6610a
--- /dev/null
+++ b/src/render/utils.cc
@@ -0,0 +1,135 @@
+/*
+ * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation.
+ * Copyright (C) 2026 Richard Qin
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "ricochet/render/utils.hh"
+
+#include <array>
+#include <cstdint>
+#include <stdexcept>
+
+namespace ricochet::render {
+
+std::string decode_entities( std::string text )
+{
+ constexpr std::array<std::pair<std::string_view, std::string_view>, 25> entities
+ = { { { "&nbsp;", " " }, { "&lt;", "<" }, { "&gt;", ">" }, { "&amp;", "&" }, { "&quot;", "\"" },
+ { "&apos;", "'" }, { "&copy;", "©" }, { "&reg;", "®" }, { "&trade;", "™" }, { "&euro;", "€" },
+ { "&pound;", "£" }, { "&yen;", "¥" }, { "&cent;", "¢" }, { "&sect;", "§" }, { "&para;", "¶" },
+ { "&hellip;", "…" }, { "&dagger;", "†" }, { "&Dagger;", "‡" }, { "&bull;", "•" }, { "&prime;", "′" },
+ { "&Prime;", "″" }, { "&lsaquo;", "‹" }, { "&rsaquo;", "›" }, { "&oline;", "‾" }, { "&frasl;", "⁄" } } };
+
+ for ( const auto& [entity, replacement] : entities ) {
+ std::size_t pos = 0;
+ while ( ( pos = text.find( entity, pos ) ) != std::string::npos ) {
+ text.replace( pos, entity.length(), replacement );
+ pos += replacement.length();
+ }
+ }
+
+ std::size_t pos = 0;
+ while ( ( pos = text.find( "&#", pos ) ) != std::string::npos ) {
+ const std::size_t end = text.find( ';', pos );
+ if ( end != std::string::npos && end - pos < 10 ) {
+ const std::string code = text.substr( pos + 2, end - pos - 2 );
+ try {
+ std::uint32_t cp = 0;
+ if ( !code.empty() && ( code[0] == 'x' || code[0] == 'X' ) ) {
+ cp = std::stoul( code.substr( 1 ), nullptr, 16 );
+ } else {
+ cp = std::stoul( code );
+ }
+ if ( cp < 128 ) {
+ text.replace( pos, end - pos + 1, 1, static_cast<char>( cp ) );
+ continue;
+ }
+ } catch ( const std::invalid_argument& ) {
+ (void)0;
+ } catch ( const std::out_of_range& ) {
+ (void)0;
+ }
+ }
+ pos++;
+ }
+ return text;
+}
+
+std::string extract_attr( const std::string& tag_name, std::string_view attr_name )
+{
+ std::string match = " " + std::string( attr_name ) + "=";
+ std::size_t pos = tag_name.find( match );
+
+ if ( pos == std::string::npos ) {
+ match = std::string( attr_name ) + "=";
+ if ( !tag_name.starts_with( match ) ) {
+ return "";
+ }
+ pos = 0;
+ } else {
+ pos += 1;
+ }
+
+ pos += attr_name.length() + 1;
+ if ( pos >= tag_name.length() ) {
+ return "";
+ }
+
+ const char quote = tag_name[pos];
+ if ( quote == '"' || quote == '\'' ) {
+ const std::size_t end = tag_name.find( quote, pos + 1 );
+ if ( end != std::string::npos ) {
+ return tag_name.substr( pos + 1, end - pos - 1 );
+ }
+ return "";
+ }
+
+ const std::size_t end = tag_name.find_first_of( " >/", pos );
+ return tag_name.substr( pos, ( end == std::string::npos ) ? std::string::npos : end - pos );
+}
+
+std::size_t get_visible_length( std::string_view str )
+{
+ std::size_t len = 0;
+ bool in_ansi = false;
+ for ( std::size_t i = 0; i < str.length(); ++i ) {
+ const auto c = static_cast<unsigned char>( str[i] );
+ if ( c == 0x1B ) {
+ in_ansi = true;
+ } else if ( in_ansi && c == 'm' ) {
+ in_ansi = false;
+ } else if ( !in_ansi ) {
+ if ( ( c & 0x80 ) == 0 || ( c & 0xE0 ) == 0xC0 ) {
+ len += 1;
+ } else if ( ( c & 0xF0 ) == 0xE0 || ( c & 0xF8 ) == 0xF0 ) {
+ len += 2;
+ }
+ }
+ }
+ return len;
+}
+
+bool is_ignored_tag( std::string_view tag )
+{
+ return tag == "head" || tag == "style" || tag == "script" || tag == "meta" || tag == "title" || tag == "link";
+}
+
+bool is_heading_tag( std::string_view tag )
+{
+ return tag == "h1" || tag == "h2" || tag == "h3" || tag == "h4";
+}
+
+} // namespace ricochet::render