From 997415e3bad0b9ff37aa9abf930841e039695a27 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 02:48:24 +0800 Subject: feat: implement HTML lexer and integrate tokenization into browser output --- ricochet/src/parser/html_lexer.cc | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 ricochet/src/parser/html_lexer.cc (limited to 'ricochet/src/parser/html_lexer.cc') diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc new file mode 100644 index 0000000..bbf8c57 --- /dev/null +++ b/ricochet/src/parser/html_lexer.cc @@ -0,0 +1,121 @@ +/* + * Ricochet - A minimalist CLI web browser for fast, distraction-free navigation. + * Copyright (C) 2026 Richard Qin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ricochet/parser/html_lexer.hh" + +#include +#include + + +namespace ricochet::parser { + +namespace { +enum class LexerState : std::uint8_t { + Data, // Reading plain text + TagOpen, // Just encountered '<' + TagName, // Reading the name of an opening tag (e.g., "div") + ClosingTagName, // Reading the name of a closing tag (e.g., "/div") + SwallowAttributes // Ignore attributes mode (ignore until encountering '>') +}; + +void emit_text(std::string& buffer, std::vector& tokens) { + if (buffer.empty()) { + return; // 提前返回,减少 if 嵌套层级 + } + + bool all_whitespace = true; + for (const char c : buffer) { + if (!std::isspace(static_cast(c))) { + all_whitespace = false; + break; + } + } + + if (!all_whitespace) { + tokens.emplace_back(TextToken{buffer}); + } + buffer.clear(); +} + +} // namespace + +std::vector HtmlLexer::tokenize( std::string_view html ) const // NOLINT(readability-convert-member-functions-to-static) +{ + std::vector tokens; + + LexerState state = LexerState::Data; + std::string buffer{}; + + for (const char c : html) { + switch (state) { + case LexerState::Data: + if (c == '<') { + emit_text(buffer, tokens); + state = LexerState::TagOpen; + } else { + buffer += c; + } + break; + case LexerState::TagOpen: + if (c == '/') { + state = LexerState::ClosingTagName; + } else if (std::isalpha(static_cast(c))) { + buffer.push_back(c); + state = LexerState::TagName; + } else { + buffer.push_back('<'); + buffer.push_back(c); + state = LexerState::Data; + } + break; + case LexerState::TagName: + if (c == '>') { + tokens.emplace_back(TagOpenToken{buffer}); + buffer.clear(); + state = LexerState::Data; + } else if (std::isspace(static_cast(c))) { + tokens.emplace_back(TagOpenToken{buffer}); + buffer.clear(); + state = LexerState::SwallowAttributes; + } else { + buffer += static_cast(std::tolower(static_cast(c))); + } + break; + case LexerState::ClosingTagName: + if (c == '>') { + tokens.emplace_back(TagCloseToken{buffer}); + buffer.clear(); + state = LexerState::Data; + } else { + buffer += static_cast(std::tolower(static_cast(c))); + } + break; + case LexerState::SwallowAttributes: + if (c == '>') { + state = LexerState::Data; + } + break; + } + } + + emit_text(buffer, tokens); + + return tokens; +} + +} // namespace ricochet::parser \ No newline at end of file -- cgit v1.2.3 From 86f0162f5556e25ce6a5b9b1327ff7ef2800d5c1 Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sat, 11 Apr 2026 03:28:01 +0800 Subject: style: apply consistent code formatting and bracing style across the codebase --- ricochet/include/ricochet/parser/dom_tree.hh | 9 +- ricochet/include/ricochet/parser/html_lexer.hh | 17 ++-- ricochet/include/ricochet/parser/tree_builder.hh | 27 ++--- ricochet/src/core/browser.cc | 50 +++++----- ricochet/src/net/http_client.cc | 13 +-- ricochet/src/parser/html_lexer.cc | 122 ++++++++++++----------- ricochet/src/parser/tree_builder.cc | 87 ++++++++-------- 7 files changed, 162 insertions(+), 163 deletions(-) (limited to 'ricochet/src/parser/html_lexer.cc') diff --git a/ricochet/include/ricochet/parser/dom_tree.hh b/ricochet/include/ricochet/parser/dom_tree.hh index b1e4812..e3b3664 100644 --- a/ricochet/include/ricochet/parser/dom_tree.hh +++ b/ricochet/include/ricochet/parser/dom_tree.hh @@ -26,10 +26,11 @@ namespace ricochet::parser { /** * @brief DOM Node. Pure value semantics, no pointers. */ -struct DomNode { - std::string tag_name{}; // If empty, it's a pure text node - std::string text_content{}; // Only meaningful when tag_name is empty - std::vector children{}; // Recursive child nodes list +struct DomNode +{ + std::string tag_name {}; // If empty, it's a pure text node + std::string text_content {}; // Only meaningful when tag_name is empty + std::vector children {}; // Recursive child nodes list }; } // namespace ricochet::parser \ No newline at end of file diff --git a/ricochet/include/ricochet/parser/html_lexer.hh b/ricochet/include/ricochet/parser/html_lexer.hh index 2cfdda9..d9ce23a 100644 --- a/ricochet/include/ricochet/parser/html_lexer.hh +++ b/ricochet/include/ricochet/parser/html_lexer.hh @@ -25,16 +25,19 @@ namespace ricochet::parser { -struct TextToken { - std::string content{}; +struct TextToken +{ + std::string content {}; }; -struct TagOpenToken { - std::string name{}; +struct TagOpenToken +{ + std::string name {}; }; -struct TagCloseToken { - std::string name{}; +struct TagCloseToken +{ + std::string name {}; }; using HtmlToken = std::variant; @@ -53,7 +56,7 @@ public: /** * @brief Perform lexical analysis to convert HTML source code into a structured sequence of tokens */ - std::vector tokenize(std::string_view html) const; + std::vector tokenize( std::string_view html ) const; private: }; diff --git a/ricochet/include/ricochet/parser/tree_builder.hh b/ricochet/include/ricochet/parser/tree_builder.hh index 4adbc4d..2cb0748 100644 --- a/ricochet/include/ricochet/parser/tree_builder.hh +++ b/ricochet/include/ricochet/parser/tree_builder.hh @@ -25,20 +25,21 @@ namespace ricochet::parser { -class TreeBuilder { +class TreeBuilder +{ public: - TreeBuilder() = default; - ~TreeBuilder() = default; - - TreeBuilder(const TreeBuilder&) = delete; - TreeBuilder& operator=(const TreeBuilder&) = delete; - TreeBuilder(TreeBuilder&&) = delete; - TreeBuilder& operator=(TreeBuilder&&) = delete; - - /** - * @brief Assemble a one-dimensional token sequence into a multi-dimensional DOM tree - */ - DomNode build(const std::vector& tokens) const; + TreeBuilder() = default; + ~TreeBuilder() = default; + + TreeBuilder( const TreeBuilder& ) = delete; + TreeBuilder& operator=( const TreeBuilder& ) = delete; + TreeBuilder( TreeBuilder&& ) = delete; + TreeBuilder& operator=( TreeBuilder&& ) = delete; + + /** + * @brief Assemble a one-dimensional token sequence into a multi-dimensional DOM tree + */ + DomNode build( const std::vector& tokens ) const; }; } // namespace ricochet::parser \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 7acc915..e4c4c60 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -23,18 +23,18 @@ #include namespace { -void print_dom_tree(const ricochet::parser::DomNode& node, std::size_t depth = 0) // NOLINT(misc-no-recursion) +void print_dom_tree( const ricochet::parser::DomNode& node, std::size_t depth = 0 ) // NOLINT(misc-no-recursion) { - const std::string indent(depth * 2, ' '); - - if (node.tag_name.empty()) { - std::cout << indent << "\"" << node.text_content << "\"\n"; - } else { - std::cout << indent << "<" << node.tag_name << ">\n"; - for (const auto& child : node.children) { - print_dom_tree(child, depth + 1); - } + const std::string indent( depth * 2, ' ' ); + + if ( node.tag_name.empty() ) { + std::cout << indent << "\"" << node.text_content << "\"\n"; + } else { + std::cout << indent << "<" << node.tag_name << ">\n"; + for ( const auto& child : node.children ) { + print_dom_tree( child, depth + 1 ); } + } } } // namespace namespace ricochet::core { @@ -43,26 +43,26 @@ int Browser::run( std::string_view initial_url ) { std::cout << "-> Fetching: " << initial_url << "...\n"; - const net::HttpClient client; - auto response_result = client.fetch(initial_url); + const net::HttpClient client; + auto response_result = client.fetch( initial_url ); - if (!response_result.has_value()) { - std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; - return 1; - } + if ( !response_result.has_value() ) { + std::cerr << "[!] Ricochet failed to load page: " << response_result.error() << "\n"; + return 1; + } + + const auto& response = response_result.value(); - const auto& response = response_result.value(); - - const parser::HtmlLexer lexer; - const auto tokens = lexer.tokenize(response.body); + const parser::HtmlLexer lexer; + const auto tokens = lexer.tokenize( response.body ); - const parser::TreeBuilder builder; - const auto dom_root = builder.build(tokens); + const parser::TreeBuilder builder; + const auto dom_root = builder.build( tokens ); - std::cout << "\n[=== Abstract Syntax Tree (DOM) ===]\n"; - print_dom_tree(dom_root); + std::cout << "\n[=== Abstract Syntax Tree (DOM) ===]\n"; + print_dom_tree( dom_root ); - return 0; + return 0; } } // namespace ricochet::core diff --git a/ricochet/src/net/http_client.cc b/ricochet/src/net/http_client.cc index 256c1f2..d45de40 100644 --- a/ricochet/src/net/http_client.cc +++ b/ricochet/src/net/http_client.cc @@ -59,7 +59,8 @@ std::expected parse_url( std::string_view url ) } } // namespace -std::expected HttpClient::fetch( std::string_view url ) const // NOLINT(readability-convert-member-functions-to-static) +std::expected HttpClient::fetch( // NOLINT(readability-convert-member-functions-to-static) + std::string_view url ) const { auto parsed_url = parse_url( url ); if ( !parsed_url.has_value() ) { @@ -75,11 +76,11 @@ std::expected HttpClient::fetch( std::string_view url socket.connect( server_addr ); const std::string request = std::format( "GET {} HTTP/1.1\r\n" - "Host: {}\r\n" - "User-Agent: Ricochet/0.1.0\r\n" - "Connection: close\r\n\r\n", - path, - host ); + "Host: {}\r\n" + "User-Agent: Ricochet/0.1.0\r\n" + "Connection: close\r\n\r\n", + path, + host ); size_t bytes_written = 0; while ( bytes_written < request.size() ) { diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc index bbf8c57..73591b3 100644 --- a/ricochet/src/parser/html_lexer.cc +++ b/ricochet/src/parser/html_lexer.cc @@ -21,99 +21,101 @@ #include #include - namespace ricochet::parser { namespace { -enum class LexerState : std::uint8_t { - Data, // Reading plain text - TagOpen, // Just encountered '<' - TagName, // Reading the name of an opening tag (e.g., "div") - ClosingTagName, // Reading the name of a closing tag (e.g., "/div") - SwallowAttributes // Ignore attributes mode (ignore until encountering '>') +enum class LexerState : std::uint8_t +{ + Data, // Reading plain text + TagOpen, // Just encountered '<' + TagName, // Reading the name of an opening tag (e.g., "div") + ClosingTagName, // Reading the name of a closing tag (e.g., "/div") + SwallowAttributes // Ignore attributes mode (ignore until encountering '>') }; -void emit_text(std::string& buffer, std::vector& tokens) { - if (buffer.empty()) { - return; // 提前返回,减少 if 嵌套层级 - } +void emit_text( std::string& buffer, std::vector& tokens ) +{ + if ( buffer.empty() ) { + return; // 提前返回,减少 if 嵌套层级 + } - bool all_whitespace = true; - for (const char c : buffer) { - if (!std::isspace(static_cast(c))) { - all_whitespace = false; - break; - } - } - - if (!all_whitespace) { - tokens.emplace_back(TextToken{buffer}); + bool all_whitespace = true; + for ( const char c : buffer ) { + if ( !std::isspace( static_cast( c ) ) ) { + all_whitespace = false; + break; } - buffer.clear(); + } + + if ( !all_whitespace ) { + tokens.emplace_back( TextToken { buffer } ); + } + buffer.clear(); } } // namespace -std::vector HtmlLexer::tokenize( std::string_view html ) const // NOLINT(readability-convert-member-functions-to-static) +std::vector HtmlLexer::tokenize( // NOLINT(readability-convert-member-functions-to-static) + std::string_view html ) const { std::vector tokens; LexerState state = LexerState::Data; - std::string buffer{}; + std::string buffer {}; - for (const char c : html) { - switch (state) { - case LexerState::Data: - if (c == '<') { - emit_text(buffer, tokens); - state = LexerState::TagOpen; + for ( const char c : html ) { + switch ( state ) { + case LexerState::Data: + if ( c == '<' ) { + emit_text( buffer, tokens ); + state = LexerState::TagOpen; } else { - buffer += c; + buffer += c; } break; - case LexerState::TagOpen: - if (c == '/') { - state = LexerState::ClosingTagName; - } else if (std::isalpha(static_cast(c))) { - buffer.push_back(c); - state = LexerState::TagName; + case LexerState::TagOpen: + if ( c == '/' ) { + state = LexerState::ClosingTagName; + } else if ( std::isalpha( static_cast( c ) ) ) { + buffer.push_back( c ); + state = LexerState::TagName; } else { - buffer.push_back('<'); - buffer.push_back(c); - state = LexerState::Data; + buffer.push_back( '<' ); + buffer.push_back( c ); + state = LexerState::Data; } break; - case LexerState::TagName: - if (c == '>') { - tokens.emplace_back(TagOpenToken{buffer}); - buffer.clear(); - state = LexerState::Data; - } else if (std::isspace(static_cast(c))) { - tokens.emplace_back(TagOpenToken{buffer}); - buffer.clear(); - state = LexerState::SwallowAttributes; + case LexerState::TagName: + if ( c == '>' ) { + tokens.emplace_back( TagOpenToken { buffer } ); + buffer.clear(); + state = LexerState::Data; + } else if ( std::isspace( static_cast( c ) ) ) { + tokens.emplace_back( TagOpenToken { buffer } ); + buffer.clear(); + state = LexerState::SwallowAttributes; } else { - buffer += static_cast(std::tolower(static_cast(c))); + buffer += static_cast( std::tolower( static_cast( c ) ) ); } break; - case LexerState::ClosingTagName: - if (c == '>') { - tokens.emplace_back(TagCloseToken{buffer}); - buffer.clear(); - state = LexerState::Data; + case LexerState::ClosingTagName: + if ( c == '>' ) { + tokens.emplace_back( TagCloseToken { buffer } ); + buffer.clear(); + state = LexerState::Data; } else { - buffer += static_cast(std::tolower(static_cast(c))); + buffer += static_cast( std::tolower( static_cast( c ) ) ); } break; - case LexerState::SwallowAttributes: - if (c == '>') { - state = LexerState::Data; + case LexerState::SwallowAttributes: + if ( c == '>' ) { + state = LexerState::Data; } break; } } - emit_text(buffer, tokens); + emit_text( buffer, tokens ); return tokens; } diff --git a/ricochet/src/parser/tree_builder.cc b/ricochet/src/parser/tree_builder.cc index 51a8a51..4f7f87e 100644 --- a/ricochet/src/parser/tree_builder.cc +++ b/ricochet/src/parser/tree_builder.cc @@ -24,67 +24,58 @@ namespace ricochet::parser { namespace { -struct TokenVisitor { - std::vector* stack; +struct TokenVisitor +{ + std::vector* stack; - void operator()(const TextToken& token) const { - stack->back().children.emplace_back( - DomNode{ - .tag_name = "", - .text_content = token.content, - .children = {} - } - ); - } + void operator()( const TextToken& token ) const + { + stack->back().children.emplace_back( + DomNode { .tag_name = "", .text_content = token.content, .children = {} } ); + } + + void operator()( const TagOpenToken& token ) const + { + DomNode new_node { .tag_name = token.name, .text_content = "", .children = {} }; - void operator()(const TagOpenToken& token) const { - DomNode new_node{ - .tag_name = token.name, - .text_content = "", - .children = {} - }; + if ( token.name == "meta" || token.name == "link" || token.name == "img" || token.name == "br" + || token.name == "hr" || token.name == "input" || token.name == "base" || token.name == "col" ) { - if (token.name == "meta" || token.name == "link" || token.name == "img" || - token.name == "br" || token.name == "hr" || token.name == "input" || - token.name == "base" || token.name == "col") { - - stack->back().children.push_back(std::move(new_node)); - } else { - stack->push_back(std::move(new_node)); - } + stack->back().children.push_back( std::move( new_node ) ); + } else { + stack->push_back( std::move( new_node ) ); } + } - void operator()(const TagCloseToken& /*token*/) const { - if (stack->size() > 1) { - DomNode completed_node = std::move(stack->back()); - stack->pop_back(); - stack->back().children.push_back(std::move(completed_node)); - } + void operator()( const TagCloseToken& /*token*/ ) const + { + if ( stack->size() > 1 ) { + DomNode completed_node = std::move( stack->back() ); + stack->pop_back(); + stack->back().children.push_back( std::move( completed_node ) ); } + } }; } // namespace -DomNode TreeBuilder::build(const std::vector& tokens) const // NOLINT(readability-convert-member-functions-to-static) +DomNode TreeBuilder::build( // NOLINT(readability-convert-member-functions-to-static) + const std::vector& tokens ) const { - std::vector stack; - stack.emplace_back(DomNode{ - .tag_name = "Document", - .text_content = "", - .children = {} - }); + std::vector stack; + stack.emplace_back( DomNode { .tag_name = "Document", .text_content = "", .children = {} } ); - TokenVisitor visitor{&stack}; - for (const auto& token : tokens) { - std::visit(visitor, token); - } + TokenVisitor visitor { &stack }; + for ( const auto& token : tokens ) { + std::visit( visitor, token ); + } - while (stack.size() > 1) { - DomNode completed_node = std::move(stack.back()); - stack.pop_back(); - stack.back().children.push_back(std::move(completed_node)); - } + while ( stack.size() > 1 ) { + DomNode completed_node = std::move( stack.back() ); + stack.pop_back(); + stack.back().children.push_back( std::move( completed_node ) ); + } - return std::move(stack.front()); + return std::move( stack.front() ); } } // namespace ricochet::parser \ No newline at end of file -- cgit v1.2.3 From e81d870756bccbb2b5da52673d52a6bfd93f7b3d Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Sun, 12 Apr 2026 04:22:01 +0800 Subject: feat: implement link extraction and interactive navigation via numbered tags in the browser --- ricochet/include/ricochet/render/renderer.hh | 8 ++- ricochet/src/core/browser.cc | 104 ++++++++++++++++++++++++--- ricochet/src/parser/html_lexer.cc | 6 +- ricochet/src/parser/tree_builder.cc | 8 ++- ricochet/src/render/renderer.cc | 69 +++++++++++++----- 5 files changed, 160 insertions(+), 35 deletions(-) (limited to 'ricochet/src/parser/html_lexer.cc') diff --git a/ricochet/include/ricochet/render/renderer.hh b/ricochet/include/ricochet/render/renderer.hh index f26a788..2a9acae 100644 --- a/ricochet/include/ricochet/render/renderer.hh +++ b/ricochet/include/ricochet/render/renderer.hh @@ -22,6 +22,12 @@ namespace ricochet::render { +struct RenderResult +{ + std::string text {}; + std::vector links {}; +}; + class Renderer { public: @@ -37,7 +43,7 @@ public: * @brief Traverse the DOM tree and output the formatted result to the terminal using ANSI escape codes * @param node The root node of the DOM tree */ - std::string render( const parser::DomNode& node ) const; + RenderResult render( const parser::DomNode& node ) const; }; } // namespace ricochet::render \ No newline at end of file diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index 4225986..8bd921c 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -23,17 +23,24 @@ #include "ricochet/render/renderer.hh" #include "ricochet/tui/terminal.hh" #include +#include #include namespace ricochet::core { +struct PageData +{ + std::vector lines; + std::vector links; +}; + namespace { -std::vector load_page( std::string_view url ) +PageData load_page( std::string_view url ) { const net::HttpClient client; auto response_result = client.fetch( url ); if ( !response_result.has_value() ) { - return { "[!] Failed to load: " + std::string( url ) }; + return { .lines = { "[!] Failed to load: " + std::string( url ) }, .links = {} }; } const parser::HtmlLexer lexer; @@ -41,20 +48,20 @@ std::vector load_page( std::string_view url ) const render::Renderer renderer; const auto dom_root = builder.build( lexer.tokenize( response_result.value().body ) ); - const std::string text = renderer.render( dom_root ); + const render::RenderResult result = renderer.render( dom_root ); std::vector lines; std::size_t start = 0; - while ( start < text.length() ) { - const std::size_t end = text.find( '\n', start ); + while ( start < result.text.length() ) { + const std::size_t end = result.text.find( '\n', start ); if ( end == std::string::npos ) { - lines.push_back( text.substr( start ) ); + lines.push_back( result.text.substr( start ) ); break; } - lines.push_back( text.substr( start, end - start ) ); + lines.push_back( result.text.substr( start, end - start ) ); start = end + 1; } - return lines; + return { .lines = lines, .links = result.links }; } void draw_view( const tui::Terminal& terminal, @@ -73,7 +80,7 @@ void draw_view( const tui::Terminal& terminal, std::cout << "~\r\n"; } } - std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [q] Quit \033[0m"; + std::cout << "\033[7m URL: " << url << " | [j/k] Scroll [/] Jump [f] Follow [q] Quit \033[0m"; std::cout.flush(); } @@ -115,6 +122,79 @@ std::string get_url_input( const tui::Terminal& terminal ) return input; } +std::string get_link_input( const tui::Terminal& terminal ) +{ + auto [width, height] = terminal.get_size(); + terminal.move_cursor( height, 1 ); + std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; + std::cout.flush(); + + std::string num_input; + terminal.show_cursor( true ); + while ( true ) { + const char ch = terminal.read_key(); + if ( ch == '\r' || ch == '\n' ) { + break; + } + if ( ch == 27 ) { + num_input.clear(); + break; + } + if ( ch == 127 || ch == 8 ) { + if ( !num_input.empty() ) { + num_input.pop_back(); + std::cout << "\b \b"; + std::cout.flush(); + } + } else if ( std::isdigit( static_cast( ch ) ) ) { + num_input += ch; + std::cout << ch; + std::cout.flush(); + } + } + terminal.show_cursor( false ); + return num_input; +} + +void handle_follow_link( const tui::Terminal& terminal, + const std::vector& links, + std::string& current_url, + bool& navigate ) +{ + const std::string num_input = get_link_input( terminal ); + if ( !num_input.empty() ) { + try { + const std::size_t index = std::stoul( num_input ); + if ( index > 0 && index <= links.size() ) { + std::string target = links[index - 1]; + + if ( target.starts_with( "/" ) ) { + if ( target.starts_with( "//" ) ) { + target = "https:" + target; // "//example.com" + } else { + const std::size_t slash_pos = current_url.find( '/', 8 ); // 跳过 https:// + const std::string domain + = ( slash_pos == std::string::npos ) ? current_url : current_url.substr( 0, slash_pos ); + target = domain + target; // "/about" -> "https://gnu.org/about" + } + } else if ( !target.starts_with( "http" ) ) { + const std::size_t last_slash = current_url.rfind( '/' ); + if ( last_slash > 7 ) { + target = current_url.substr( 0, last_slash + 1 ) + target; + } else { + target = current_url + "/" + target; + } + } + + current_url = target; + navigate = true; + } + } catch ( const std::exception& e ) { + (void)e; // NOLINT + } + } +} + } // namespace int Browser::run( std::string_view initial_url ) @@ -126,7 +206,9 @@ int Browser::run( std::string_view initial_url ) terminal.clear_screen(); while ( !current_url.empty() ) { - auto lines = load_page( current_url ); + auto page_data = load_page( current_url ); + const auto& lines = page_data.lines; + const auto& links = page_data.links; std::size_t scroll_y = 0; bool navigate = false; @@ -151,6 +233,8 @@ int Browser::run( std::string_view initial_url ) current_url = std::move( next_url ); navigate = true; } + } else if ( c == 'f' ) { + handle_follow_link( terminal, links, current_url, navigate ); } } } diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc index 73591b3..2aa05b1 100644 --- a/ricochet/src/parser/html_lexer.cc +++ b/ricochet/src/parser/html_lexer.cc @@ -90,12 +90,8 @@ std::vector HtmlLexer::tokenize( // NOLINT(readability-convert-member tokens.emplace_back( TagOpenToken { buffer } ); buffer.clear(); state = LexerState::Data; - } else if ( std::isspace( static_cast( c ) ) ) { - tokens.emplace_back( TagOpenToken { buffer } ); - buffer.clear(); - state = LexerState::SwallowAttributes; } else { - buffer += static_cast( std::tolower( static_cast( c ) ) ); + buffer += c; } break; case LexerState::ClosingTagName: diff --git a/ricochet/src/parser/tree_builder.cc b/ricochet/src/parser/tree_builder.cc index 4f7f87e..64f841b 100644 --- a/ricochet/src/parser/tree_builder.cc +++ b/ricochet/src/parser/tree_builder.cc @@ -36,10 +36,14 @@ struct TokenVisitor void operator()( const TagOpenToken& token ) const { + const std::size_t space_pos = token.name.find( ' ' ); + const std::string base_name + = ( space_pos == std::string::npos ) ? token.name : token.name.substr( 0, space_pos ); + DomNode new_node { .tag_name = token.name, .text_content = "", .children = {} }; - if ( token.name == "meta" || token.name == "link" || token.name == "img" || token.name == "br" - || token.name == "hr" || token.name == "input" || token.name == "base" || token.name == "col" ) { + if ( base_name == "meta" || base_name == "link" || base_name == "img" || base_name == "br" || base_name == "hr" + || base_name == "input" || base_name == "base" || base_name == "col" ) { stack->back().children.push_back( std::move( new_node ) ); } else { diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index 2115ee1..1af40f1 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -17,7 +17,7 @@ */ #include "ricochet/render/renderer.hh" -#include +#include namespace ricochet::render { @@ -42,51 +42,86 @@ std::string decode_entities( std::string text ) // NOLINT(readability-convert-me return text; } -void render_node( const parser::DomNode& node, std::string& output ) // NOLINT(misc-no-recursion) +std::string extract_href( const std::string& tag_name ) { - if ( node.tag_name == "head" || node.tag_name == "style" || node.tag_name == "script" || node.tag_name == "meta" - || node.tag_name == "title" || node.tag_name == "link" ) { + std::size_t pos = tag_name.find( "href=\"" ); + if ( pos != std::string::npos ) { + pos += 6; + const std::size_t end = tag_name.find( '\"', pos ); + if ( end != std::string::npos ) { + return tag_name.substr( pos, end - pos ); + } + } + pos = tag_name.find( "href='" ); + if ( pos != std::string::npos ) { + pos += 6; + const std::size_t end = tag_name.find( '\'', pos ); + if ( end != std::string::npos ) { + return tag_name.substr( pos, end - pos ); + } + } + return ""; +} + +void render_node( const parser::DomNode& node, // NOLINT(misc-no-recursion) + std::string& output, + std::vector& links ) +{ + const std::size_t space_pos = node.tag_name.find( ' ' ); + const std::string base_tag + = ( space_pos == std::string::npos ) ? node.tag_name : node.tag_name.substr( 0, space_pos ); + + if ( base_tag == "head" || base_tag == "style" || base_tag == "script" || base_tag == "meta" + || base_tag == "title" || base_tag == "link" ) { return; } - if ( node.tag_name.empty() ) { + if ( base_tag.empty() ) { output += decode_entities( node.text_content ); return; } - const bool is_header - = ( node.tag_name == "h1" || node.tag_name == "h2" || node.tag_name == "h3" || node.tag_name == "h4" ); + const bool is_header = ( base_tag == "h1" || base_tag == "h2" || base_tag == "h3" || base_tag == "h4" ); + const bool is_link = ( base_tag == "a" ); if ( is_header ) { output += "\n\n\033[1;31m"; - } else if ( node.tag_name == "a" ) { - std::cout << "\033[4;34m"; - } else if ( node.tag_name == "p" || node.tag_name == "div" || is_header ) { + } else if ( is_link ) { + output += "\033[4;34m"; // 【核心修复】:必须是 output += ,绝对不能用 std::cout + } else if ( base_tag == "p" || base_tag == "div" || is_header ) { if ( !output.empty() && output.back() != '\n' ) { output += "\n"; } } for ( const auto& child : node.children ) { - render_node( child, output ); + render_node( child, output, links ); } - if ( is_header || node.tag_name == "a" ) { + + if ( is_header ) { + output += "\033[0m"; + } else if ( is_link ) { output += "\033[0m"; + const std::string href = extract_href( node.tag_name ); + if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) { + links.push_back( href ); + output += " \033[7m[" + std::to_string( links.size() ) + "]\033[0m"; // 打印数字标签! + } } - if ( node.tag_name == "h1" || node.tag_name == "p" || node.tag_name == "div" ) { + if ( base_tag == "h1" || base_tag == "p" || base_tag == "div" ) { output += "\n"; } } } // namespace -std::string Renderer::render( // NOLINT(readability-convert-member-functions-to-static) +RenderResult Renderer::render( // NOLINT(readability-convert-member-functions-to-static) const parser::DomNode& node ) const { - std::string output; - render_node( node, output ); - return output; + RenderResult result; + render_node( node, result.text, result.links ); + return result; } } // namespace ricochet::render \ No newline at end of file -- cgit v1.2.3 From 08fdcf8e8a83d433db7e6f5d08442dd27a729a0f Mon Sep 17 00:00:00 2001 From: Richard Qin Date: Mon, 13 Apr 2026 03:52:07 +0800 Subject: feat: replace numeric link selection with two-character link hints --- ricochet/src/core/browser.cc | 160 ++++++++++++++++++++++++++------------ ricochet/src/parser/html_lexer.cc | 2 +- ricochet/src/render/renderer.cc | 2 +- 3 files changed, 111 insertions(+), 53 deletions(-) (limited to 'ricochet/src/parser/html_lexer.cc') diff --git a/ricochet/src/core/browser.cc b/ricochet/src/core/browser.cc index f73f5b1..779bdb2 100644 --- a/ricochet/src/core/browser.cc +++ b/ricochet/src/core/browser.cc @@ -243,7 +243,7 @@ PageData load_page( const HttpRequest& req ) std::string build_footer( std::string_view url, std::size_t width ) { - const std::string_view keys = " | [j/k]Scroll [n/N]Find [/]Nav [f]Link [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; + const std::string_view keys = " | [j/k]Scroll [n/N]Find [/]Nav [f]Hint [i]In [r]Ref [H/L]Hist [b/B]Mark [q]Quit "; const std::string_view prefix = " URL: "; std::string footer; @@ -378,39 +378,39 @@ std::string get_url_input( const tui::Terminal& terminal ) return format_url( std::move( input ) ); } -std::string get_link_input( const tui::Terminal& terminal ) -{ - auto [width, height] = terminal.get_size(); - terminal.move_cursor( height, 1 ); - std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; - std::cout.flush(); - - std::string num_input; - terminal.show_cursor( true ); - while ( true ) { - const char ch = terminal.read_key(); - if ( ch == '\r' || ch == '\n' ) { - break; - } - if ( ch == 27 ) { - num_input.clear(); - break; - } - if ( ch == 127 || ch == 8 ) { - if ( !num_input.empty() ) { - num_input.pop_back(); - std::cout << "\b \b"; - std::cout.flush(); - } - } else if ( std::isdigit( static_cast( ch ) ) ) { - num_input += ch; - std::cout << ch; - std::cout.flush(); - } - } - terminal.show_cursor( false ); - return num_input; -} +// std::string get_link_input( const tui::Terminal& terminal ) +// { +// auto [width, height] = terminal.get_size(); +// terminal.move_cursor( height, 1 ); +// std::cout << "\x1b[2K\033[7m Follow Link [#]: \033[0m "; +// std::cout.flush(); + +// std::string num_input; +// terminal.show_cursor( true ); +// while ( true ) { +// const char ch = terminal.read_key(); +// if ( ch == '\r' || ch == '\n' ) { +// break; +// } +// if ( ch == 27 ) { +// num_input.clear(); +// break; +// } +// if ( ch == 127 || ch == 8 ) { +// if ( !num_input.empty() ) { +// num_input.pop_back(); +// std::cout << "\b \b"; +// std::cout.flush(); +// } +// } else if ( std::isdigit( static_cast( ch ) ) ) { +// num_input += ch; +// std::cout << ch; +// std::cout.flush(); +// } +// } +// terminal.show_cursor( false ); +// return num_input; +// } std::string normalize_url( const std::string& target, const std::string& current_url ) { @@ -458,28 +458,86 @@ std::string strip_ddg_tracker( std::string target ) return target; } +std::string index_to_hint( std::size_t index ) +{ + const std::string alphabet = "asdfghjklqwertyuiopzxcvbnm"; + std::string hint; + hint += alphabet[( index / alphabet.length() ) % alphabet.length()]; + hint += alphabet[index % alphabet.length()]; + return hint; +} + +void toggle_link_hints( PageData& page_data, bool show ) +{ + const std::string hl_start = "\033[0;1;35;7m "; + const std::string hl_end = " \033[0m"; + + std::vector formatted_hints; + if ( show ) { + formatted_hints.reserve( page_data.links.size() ); + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + std::string h = hl_start; + h += index_to_hint( i ); + h += hl_end; + formatted_hints.push_back( std::move( h ) ); + } + } + + for ( auto& line : page_data.lines ) { + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + const std::string marker = "{L:" + std::to_string( i + 1 ) + "}"; + const std::size_t pos = line.find( marker ); + if ( pos != std::string::npos ) { + if ( show ) { + line.replace( pos, marker.length(), formatted_hints[i] ); + } else { + const std::size_t end_pos = line.find( hl_end, pos ); + if ( end_pos != std::string::npos ) { + line.replace( pos, end_pos + hl_end.length() - pos, marker ); + } + } + } + } + } +} + void handle_follow_link( const tui::Terminal& terminal, - const std::vector& links, + PageData& page_data, std::string& current_url, + std::size_t& scroll_y, // NOLINT(bugprone-easily-swappable-parameters) bool& navigate ) { - const std::string num_input = get_link_input( terminal ); - if ( !num_input.empty() ) { - try { - const std::size_t index = std::stoul( num_input ); - if ( index > 0 && index <= links.size() ) { - std::string target = links[index - 1]; + toggle_link_hints( page_data, true ); - target = normalize_url( target, current_url ); - target = strip_ddg_tracker( target ); + draw_view( terminal, page_data.lines, scroll_y, current_url, page_data.title ); - current_url = target; - navigate = true; - } - } catch ( const std::exception& e ) { - (void)e; // NOLINT + auto [w, h] = terminal.get_size(); + terminal.move_cursor( h, 1 ); + std::cout << "\x1b[2K\033[1;35;7m Follow Hint (2 keys): \033[0m "; + std::cout.flush(); + + std::string input; + input += terminal.read_key(); + std::cout << input[0]; + std::cout.flush(); + input += terminal.read_key(); + + bool found = false; + for ( std::size_t i = 0; i < page_data.links.size(); ++i ) { + if ( index_to_hint( i ) == input ) { + std::string target = page_data.links[i]; + target = normalize_url( target, current_url ); + target = strip_ddg_tracker( target ); + current_url = target; + navigate = true; + found = true; + break; } } + + if ( !found ) { + toggle_link_hints( page_data, false ); + } } enum class InputAction : std::uint8_t @@ -730,7 +788,7 @@ InputAction handle_form_input( const tui::Terminal& terminal, InputAction process_key( char c, const tui::Terminal& terminal, PageData& page_data, - std::string current_url, + std::string& current_url, std::vector& history, std::size_t& history_idx, // NOLINT(bugprone-easily-swappable-parameters) std::size_t& scroll_y ) // NOLINT(bugprone-easily-swappable-parameters) @@ -792,7 +850,7 @@ InputAction process_key( char c, if ( c == 'f' ) { const std::string old_url = current_url; bool nav = false; - handle_follow_link( terminal, page_data.links, current_url, nav ); + handle_follow_link( terminal, page_data, current_url, scroll_y, nav ); if ( nav && current_url != old_url ) { history.resize( history_idx + 1 ); history.push_back( HttpRequest { .url = current_url, .method = "GET", .body = "" } ); @@ -852,7 +910,7 @@ int Browser::run( std::string_view initial_url ) while ( true ) { const HttpRequest current_req = history[history_idx]; - const std::string current_url = current_req.url; + std::string current_url = current_req.url; terminal.clear_screen(); std::cout << "-> Fetching: " << current_req.url << "...\r\n"; std::cout.flush(); diff --git a/ricochet/src/parser/html_lexer.cc b/ricochet/src/parser/html_lexer.cc index 2aa05b1..4d70288 100644 --- a/ricochet/src/parser/html_lexer.cc +++ b/ricochet/src/parser/html_lexer.cc @@ -36,7 +36,7 @@ enum class LexerState : std::uint8_t void emit_text( std::string& buffer, std::vector& tokens ) { if ( buffer.empty() ) { - return; // 提前返回,减少 if 嵌套层级 + return; } bool all_whitespace = true; diff --git a/ricochet/src/render/renderer.cc b/ricochet/src/render/renderer.cc index fda21c3..53eca1b 100644 --- a/ricochet/src/render/renderer.cc +++ b/ricochet/src/render/renderer.cc @@ -109,7 +109,7 @@ void render_link_end( const parser::DomNode& node, RenderResult& result ) const std::string href = extract_attr( node.tag_name, "href" ); if ( !href.empty() && !href.starts_with( "javascript:" ) && !href.starts_with( "#" ) ) { result.links.push_back( decode_entities( href ) ); - result.text += " \033[7m[" + std::to_string( result.links.size() ) + "]\033[0m"; + result.text += "\033[8m{L:" + std::to_string( result.links.size() ) + "}\033[0m"; } } -- cgit v1.2.3