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