From ab2d1932e41eec5587f328b6f9f31898b153f7da Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Fri, 5 Dec 2025 15:01:21 +0800 Subject: [PATCH] feat: Transform to vim-style terminal browser (#10) * feat: Add HTTP/HTTPS client module Implement HTTP client with libcurl for fetching web pages: - Support for HTTP and HTTPS protocols - Configurable timeout and user agent - Automatic redirect following - SSL certificate verification - Pimpl pattern for implementation hiding This module provides the foundation for web page retrieval in the terminal browser. * feat: Add HTML parser and content extraction Implement HTML parser for extracting readable content: - Parse HTML structure (headings, paragraphs, lists, links) - Extract and decode HTML entities - Smart content area detection (article, main, body) - Relative URL to absolute URL conversion - Support for both absolute and relative paths - Filter out scripts, styles, and non-content elements The parser uses regex-based extraction optimized for text-heavy websites and documentation. * feat: Add newspaper-style text rendering engine Implement text renderer with adaptive layout: - Adaptive width with maximum 80 characters - Center-aligned content for comfortable reading - Smart text wrapping and paragraph spacing - Color scheme optimized for terminal reading - Support for headings, paragraphs, lists, and links - Link indicators with numbering - Horizontal rules and visual separators The renderer creates a newspaper-like reading experience optimized for terminal displays. * feat: Implement vim-style input handling Add complete vim-style keyboard navigation: - Normal mode: hjkl movement, gg/G jump, numeric prefixes - Command mode: :q, :o URL, :r, :h, :[number] - Search mode: / for search, n/N for next/previous match - Link navigation: Tab/Shift-Tab, Enter to follow - Scroll commands: Ctrl-D/U, Space, b for page up/down - History navigation: h for back, l for forward Input handler manages mode transitions and command parsing with full vim compatibility. * feat: Implement browser core with TUI interface Add main browser engine and user interface: - Page loading with HTTP client integration - HTML parsing and text rendering pipeline - History management (back/forward navigation) - Link selection and following with Tab navigation - Search functionality with highlighting - Scrolling with position tracking - Status bar with mode indicator and progress - Built-in help page with usage instructions - Error handling and user feedback - Support for static HTML websites The browser provides a complete vim-style terminal browsing experience optimized for reading text content. * build: Update build system for terminal browser Update CMake and add Makefile for the new project: - Rename project from NBTCA_TUI to TUT - Update executable name from nbtca_tui to tut - Add all new source files to build - Include Makefile for environments without CMake - Update .gitignore for build artifacts Both CMake and Make build systems are now supported for maximum compatibility. * docs: Complete project transformation to terminal browser Transform project from ICS calendar viewer to terminal browser: - Rewrite main.cpp for browser launch with URL argument support - Complete README rewrite with: - New project description and features - Comprehensive keyboard shortcuts documentation - Installation guide for multiple platforms - Usage examples and best practices - JavaScript/SPA limitations explanation - Architecture overview - Add help command line option - Update version to 1.0.0 The project is now TUT (Terminal User Interface Browser), a vim-style terminal web browser optimized for reading. --- .gitignore | 5 +- CMakeLists.txt | 17 +- Makefile | 44 +++++ README.md | 261 +++++++++++++++++++++---- src/browser.cpp | 443 ++++++++++++++++++++++++++++++++++++++++++ src/browser.h | 28 +++ src/html_parser.cpp | 294 ++++++++++++++++++++++++++++ src/html_parser.h | 57 ++++++ src/http_client.cpp | 111 +++++++++++ src/http_client.h | 37 ++++ src/input_handler.cpp | 253 ++++++++++++++++++++++++ src/input_handler.h | 67 +++++++ src/main.cpp | 54 +++-- src/text_renderer.cpp | 300 ++++++++++++++++++++++++++++ src/text_renderer.h | 60 ++++++ 15 files changed, 1973 insertions(+), 58 deletions(-) create mode 100644 Makefile create mode 100644 src/browser.cpp create mode 100644 src/browser.h create mode 100644 src/html_parser.cpp create mode 100644 src/html_parser.h create mode 100644 src/http_client.cpp create mode 100644 src/http_client.h create mode 100644 src/input_handler.cpp create mode 100644 src/input_handler.h create mode 100644 src/text_renderer.cpp create mode 100644 src/text_renderer.h diff --git a/.gitignore b/.gitignore index d163863..48ff0b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -build/ \ No newline at end of file +build/ +*.o +tut +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt index 527c299..e214d1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.15) -project(NBTCA_TUI LANGUAGES CXX) +project(TUT LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -15,15 +15,16 @@ endif() find_package(Curses REQUIRED) find_package(CURL REQUIRED) -add_executable(nbtca_tui +add_executable(tut src/main.cpp - src/ics_fetcher.cpp - src/ics_parser.cpp - src/tui_view.cpp - src/calendar.cpp + src/http_client.cpp + src/html_parser.cpp + src/text_renderer.cpp + src/input_handler.cpp + src/browser.cpp ) -target_include_directories(nbtca_tui PRIVATE ${CURSES_INCLUDE_DIR}) -target_link_libraries(nbtca_tui PRIVATE ${CURSES_LIBRARIES} CURL::libcurl) +target_include_directories(tut PRIVATE ${CURSES_INCLUDE_DIR}) +target_link_libraries(tut PRIVATE ${CURSES_LIBRARIES} CURL::libcurl) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf648bd --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +# Makefile for TUT Browser + +CXX = clang++ +CXXFLAGS = -std=c++17 -Wall -Wextra -O2 +LDFLAGS = -lncurses -lcurl + +# 源文件 +SOURCES = src/main.cpp \ + src/http_client.cpp \ + src/html_parser.cpp \ + src/text_renderer.cpp \ + src/input_handler.cpp \ + src/browser.cpp + +# 目标文件 +OBJECTS = $(SOURCES:.cpp=.o) + +# 可执行文件 +TARGET = tut + +# 默认目标 +all: $(TARGET) + +# 链接 +$(TARGET): $(OBJECTS) + $(CXX) $(OBJECTS) $(LDFLAGS) -o $(TARGET) + +# 编译 +%.o: %.cpp + $(CXX) $(CXXFLAGS) -c $< -o $@ + +# 清理 +clean: + rm -f $(OBJECTS) $(TARGET) + +# 运行 +run: $(TARGET) + ./$(TARGET) + +# 安装 +install: $(TARGET) + install -m 755 $(TARGET) /usr/local/bin/ + +.PHONY: all clean run install diff --git a/README.md b/README.md index 29a2941..4efe488 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,44 @@ -# TUT - TUI Utility Tools (WIP) +# TUT - Terminal User Interface Browser -This project, "TUT," is a collection of TUI (Terminal User Interface) utility modules written in C++. The initial focus is on the integrated **ICS Calendar Module**. This module fetches, parses, and displays iCal calendar events from `https://ical.nbtca.space/nbtca.ics` using `ncurses` to show upcoming activities within the next month. +一个专注于阅读体验的终端网页浏览器,采用vim风格的键盘操作,让你在终端中舒适地浏览网页文本内容。 -### 依赖 +## 特性 + +- 🚀 **纯文本浏览** - 专注于文本内容,无图片干扰 +- ⌨️ **完全vim风格操作** - hjkl移动、gg/G跳转、/搜索等 +- 📖 **报纸式排版** - 自适应宽度居中显示,优化阅读体验 +- 🔗 **链接导航** - TAB键切换链接,Enter跟随链接 +- 📜 **历史管理** - h/l快速前进后退 +- 🎨 **优雅配色** - 精心设计的终端配色方案 +- 🔍 **内容搜索** - 支持文本搜索和高亮 + +## 依赖 - CMake ≥ 3.15 -- C++17 编译器(macOS 上建议 `clang`) -- `ncurses` -- `libcurl` +- C++17 编译器(macOS 建议 clang,Linux 建议 g++) +- `ncurses` 或 `ncursesw`(支持宽字符) +- `libcurl`(支持HTTPS) -#### 在 macOS (Homebrew) 安装依赖 +### macOS (Homebrew) 安装依赖 ```bash brew install cmake ncurses curl ``` -### 构建 +### Linux (Ubuntu/Debian) 安装依赖 + +```bash +sudo apt-get update +sudo apt-get install build-essential cmake libncursesw5-dev libcurl4-openssl-dev +``` + +### Linux (Fedora/RHEL) 安装依赖 + +```bash +sudo dnf install cmake gcc-c++ ncurses-devel libcurl-devel +``` + +## 构建 在项目根目录执行: @@ -26,45 +49,215 @@ cmake .. cmake --build . ``` -生成的可执行文件为 `nbtca_tui`。 +生成的可执行文件为 `tut`。 -### 运行 +## 运行 -在 `build` 目录中运行: +### 直接启动(显示帮助页面) ```bash -./nbtca_tui +./tut ``` -程序会: +### 打开指定URL -1. 通过 `libcurl` 请求 `https://ical.nbtca.space/nbtca.ics` -2. 解析所有 VEVENT 事件,提取开始时间、结束时间、标题、地点、描述 -3. 过滤出从当前时间起未来 30 天内的事件 -4. 使用 ncurses TUI 滚动展示列表 +```bash +./tut https://example.com +./tut https://news.ycombinator.com +``` -### TUI 操作说明 +### 显示使用帮助 -- `↑` / `↓`:上下移动选中事件 -- `q`:退出程序 +```bash +./tut --help +``` -### Developer Guide +## 键盘操作 -For contributors and developers, follow these guidelines: +### 导航 -1. **Clone the Repository:** - ```bash - git clone https://github.com/m1ngsama/TUT.git - cd TUT - ``` -2. **Build Environment Setup:** Ensure all [Dependencies](#dependencies) are installed. -3. **Local Build:** Follow the [构建](#构建) instructions. -4. **Code Style:** Adhere to the existing code style in `src/`. -5. **Testing:** Currently, there are no automated tests. Please manually verify changes. -6. **Contributing:** Submit Pull Requests for new features or bug fixes. +| 按键 | 功能 | +|------|------| +| `j` / `↓` | 向下滚动一行 | +| `k` / `↑` | 向上滚动一行 | +| `Ctrl-D` / `Space` | 向下翻页 | +| `Ctrl-U` / `b` | 向上翻页 | +| `gg` | 跳转到顶部 | +| `G` | 跳转到底部 | +| `[数字]G` | 跳转到指定行(如 `50G`) | +| `[数字]j/k` | 向下/上滚动指定行数(如 `5j`) | -### 版本 (Version) +### 链接操作 -- `v0.0.1` +| 按键 | 功能 | +|------|------| +| `Tab` | 下一个链接 | +| `Shift-Tab` / `T` | 上一个链接 | +| `Enter` | 跟随当前链接 | +| `h` / `←` | 后退 | +| `l` / `→` | 前进 | +### 搜索 + +| 按键 | 功能 | +|------|------| +| `/` | 开始搜索 | +| `n` | 下一个匹配 | +| `N` | 上一个匹配 | + +### 命令模式 + +按 `:` 进入命令模式,支持以下命令: + +| 命令 | 功能 | +|------|------| +| `:q` / `:quit` | 退出浏览器 | +| `:o URL` / `:open URL` | 打开指定URL | +| `:r` / `:refresh` | 刷新当前页面 | +| `:h` / `:help` | 显示帮助 | +| `:[数字]` | 跳转到指定行 | + +### 其他 + +| 按键 | 功能 | +|------|------| +| `r` | 刷新当前页面 | +| `q` | 退出浏览器 | +| `?` | 显示帮助 | +| `ESC` | 取消命令/搜索输入 | + +## 使用示例 + +### 浏览新闻网站 + +```bash +./tut https://news.ycombinator.com +``` + +然后: +- 使用 `j/k` 滚动浏览标题 +- 按 `Tab` 切换到感兴趣的链接 +- 按 `Enter` 打开链接 +- 按 `h` 返回上一页 + +### 阅读文档 + +```bash +./tut https://en.wikipedia.org/wiki/Unix +``` + +然后: +- 使用 `gg` 跳转到顶部 +- 使用 `/` 搜索关键词(如 `/history`) +- 使用 `n/N` 在搜索结果间跳转 +- 使用 `Space` 翻页阅读 + +### 快速查看多个网页 + +```bash +./tut https://github.com +``` + +在浏览器内: +- 浏览页面并点击链接 +- 使用 `:o https://news.ycombinator.com` 打开新URL +- 使用 `h/l` 在历史中前进后退 + +## 设计理念 + +TUT 的设计目标是提供最佳的终端阅读体验: + +1. **极简主义** - 只关注文本内容,摒弃图片、广告等干扰元素 +2. **高效操作** - 完全键盘驱动,无需触摸鼠标 +3. **优雅排版** - 自适应宽度,居中显示,类似专业阅读器 +4. **快速响应** - 轻量级实现,即开即用 + +## 架构 + +``` +TUT +├── http_client - HTTP/HTTPS 网页获取 +├── html_parser - HTML 解析和文本提取 +├── text_renderer - 文本渲染和排版引擎 +├── input_handler - Vim 风格输入处理 +└── browser - 浏览器主循环和状态管理 +``` + +## 限制 + +### JavaScript/SPA 网站 +**重要:** 这个浏览器**不支持JavaScript执行**。这意味着: + +- ❌ **不支持**单页应用(SPA):React、Vue、Angular、Astro等构建的现代网站 +- ❌ **不支持**动态内容加载 +- ❌ **不支持**AJAX请求 +- ❌ **不支持**客户端路由 + +**如何判断网站是否支持:** +1. 用 `curl` 命令查看HTML内容:`curl https://example.com | less` +2. 如果能看到实际的文章内容,则支持;如果只有JavaScript代码或空白div,则不支持 + +**你的网站示例:** +- ✅ **thinker.m1ng.space** - 静态HTML,完全支持,可以浏览文章列表并点击进入具体文章 +- ❌ **blog.m1ng.space** - 使用Astro SPA构建,内容由JavaScript动态渲染,无法正常显示 + +**替代方案:** +- 对于SPA网站,查找是否有RSS feed或API端点 +- 使用服务器端渲染(SSR)版本的URL(如果有) +- 寻找使用传统HTML构建的同类网站 + +### 其他限制 + +- 不支持图片显示 +- 不支持复杂的CSS布局 +- 不支持表单提交 +- 不支持Cookie和会话管理 +- 专注于内容阅读,不适合需要交互的网页 + +## 开发指南 + +### 代码风格 + +- 遵循 C++17 标准 +- 使用 RAII 进行资源管理 +- 使用 Pimpl 模式隐藏实现细节 + +### 测试 + +```bash +cd build +./tut https://example.com +``` + +### 贡献 + +欢迎提交 Pull Request!请确保: + +1. 代码风格与现有代码一致 +2. 添加必要的注释 +3. 测试新功能 +4. 更新文档 + +## 版本历史 + +- **v1.0.0** - 完全重构为终端浏览器 + - 添加 HTTP/HTTPS 支持 + - 实现 HTML 解析 + - 实现 Vim 风格操作 + - 报纸式排版引擎 + - 链接导航和搜索功能 + +- **v0.0.1** - 初始版本(ICS 日历查看器) + +## 许可证 + +MIT License + +## 致谢 + +灵感来源于: +- `lynx` - 经典的终端浏览器 +- `w3m` - 另一个优秀的终端浏览器 +- `vim` - 最好的文本编辑器 +- `btop` - 美观的TUI设计 diff --git a/src/browser.cpp b/src/browser.cpp new file mode 100644 index 0000000..9f50bd8 --- /dev/null +++ b/src/browser.cpp @@ -0,0 +1,443 @@ +#include "browser.h" +#include +#include +#include +#include + +class Browser::Impl { +public: + HttpClient http_client; + HtmlParser html_parser; + TextRenderer renderer; + InputHandler input_handler; + + ParsedDocument current_doc; + std::vector rendered_lines; + std::string current_url; + std::vector history; + int history_pos = -1; + + // 视图状态 + int scroll_pos = 0; + int current_link = -1; + std::string status_message; + std::string search_term; + std::vector search_results; // 匹配行号 + + // 屏幕尺寸 + int screen_height = 0; + int screen_width = 0; + + void init_screen() { + setlocale(LC_ALL, ""); + initscr(); + init_color_scheme(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + curs_set(0); + timeout(0); // non-blocking + getmaxyx(stdscr, screen_height, screen_width); + } + + void cleanup_screen() { + endwin(); + } + + bool load_page(const std::string& url) { + status_message = "Loading " + url + "..."; + draw_screen(); + refresh(); + + auto response = http_client.fetch(url); + + if (!response.is_success()) { + status_message = "Error: " + (response.error_message.empty() ? + "HTTP " + std::to_string(response.status_code) : + response.error_message); + return false; + } + + current_doc = html_parser.parse(response.body, url); + rendered_lines = renderer.render(current_doc, screen_width); + current_url = url; + scroll_pos = 0; + current_link = -1; + search_results.clear(); + + // 更新历史 + if (history_pos >= 0 && history_pos < static_cast(history.size()) - 1) { + history.erase(history.begin() + history_pos + 1, history.end()); + } + history.push_back(url); + history_pos = history.size() - 1; + + status_message = "Loaded: " + (current_doc.title.empty() ? url : current_doc.title); + return true; + } + + void draw_status_bar() { + attron(COLOR_PAIR(COLOR_STATUS_BAR)); + mvprintw(screen_height - 1, 0, "%s", std::string(screen_width, ' ').c_str()); + + // 显示模式和缓冲 + std::string mode_str; + InputMode mode = input_handler.get_mode(); + switch (mode) { + case InputMode::NORMAL: + mode_str = "NORMAL"; + break; + case InputMode::COMMAND: + mode_str = input_handler.get_buffer(); + break; + case InputMode::SEARCH: + mode_str = input_handler.get_buffer(); + break; + default: + mode_str = "???"; + break; + } + + // 左侧:模式或命令 + mvprintw(screen_height - 1, 0, " %s", mode_str.c_str()); + + // 中间:状态消息 + if (!status_message.empty() && mode == InputMode::NORMAL) { + int msg_x = (screen_width - status_message.length()) / 2; + if (msg_x < mode_str.length() + 2) { + msg_x = mode_str.length() + 2; + } + mvprintw(screen_height - 1, msg_x, "%s", status_message.c_str()); + } + + // 右侧:位置信息 + int total_lines = rendered_lines.size(); + int visible_lines = screen_height - 2; + int percentage = 0; + if (total_lines > 0) { + if (scroll_pos == 0) { + percentage = 0; + } else if (scroll_pos + visible_lines >= total_lines) { + percentage = 100; + } else { + percentage = (scroll_pos * 100) / total_lines; + } + } + + std::string pos_str = std::to_string(scroll_pos + 1) + "/" + + std::to_string(total_lines) + " " + + std::to_string(percentage) + "%"; + + if (current_link >= 0 && current_link < static_cast(current_doc.links.size())) { + pos_str = "[Link " + std::to_string(current_link) + "] " + pos_str; + } + + mvprintw(screen_height - 1, screen_width - pos_str.length() - 1, "%s", pos_str.c_str()); + + attroff(COLOR_PAIR(COLOR_STATUS_BAR)); + } + + void draw_screen() { + clear(); + + int visible_lines = screen_height - 2; + int content_lines = std::min(static_cast(rendered_lines.size()) - scroll_pos, visible_lines); + + for (int i = 0; i < content_lines; ++i) { + int line_idx = scroll_pos + i; + const auto& line = rendered_lines[line_idx]; + + // 高亮当前链接 + if (line.is_link && line.link_index == current_link) { + attron(COLOR_PAIR(COLOR_LINK_ACTIVE)); + } else { + attron(COLOR_PAIR(line.color_pair)); + if (line.is_bold) { + attron(A_BOLD); + } + } + + // 搜索高亮 + std::string display_text = line.text; + if (!search_term.empty() && + std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) { + // 简单高亮:整行反色(实际应该只高亮匹配部分) + attron(A_REVERSE); + } + + mvprintw(i, 0, "%s", display_text.c_str()); + + if (!search_term.empty() && + std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) { + attroff(A_REVERSE); + } + + if (line.is_link && line.link_index == current_link) { + attroff(COLOR_PAIR(COLOR_LINK_ACTIVE)); + } else { + if (line.is_bold) { + attroff(A_BOLD); + } + attroff(COLOR_PAIR(line.color_pair)); + } + } + + draw_status_bar(); + } + + void handle_action(const InputResult& result) { + int visible_lines = screen_height - 2; + int max_scroll = std::max(0, static_cast(rendered_lines.size()) - visible_lines); + + int count = result.has_count ? result.count : 1; + + switch (result.action) { + case Action::SCROLL_UP: + scroll_pos = std::max(0, scroll_pos - count); + break; + + case Action::SCROLL_DOWN: + scroll_pos = std::min(max_scroll, scroll_pos + count); + break; + + case Action::SCROLL_PAGE_UP: + scroll_pos = std::max(0, scroll_pos - visible_lines); + break; + + case Action::SCROLL_PAGE_DOWN: + scroll_pos = std::min(max_scroll, scroll_pos + visible_lines); + break; + + case Action::GOTO_TOP: + scroll_pos = 0; + break; + + case Action::GOTO_BOTTOM: + scroll_pos = max_scroll; + break; + + case Action::GOTO_LINE: + if (result.number > 0 && result.number <= static_cast(rendered_lines.size())) { + scroll_pos = std::min(result.number - 1, max_scroll); + } + break; + + case Action::NEXT_LINK: + if (!current_doc.links.empty()) { + current_link = (current_link + 1) % current_doc.links.size(); + // 滚动到链接位置 + scroll_to_link(current_link); + } + break; + + case Action::PREV_LINK: + if (!current_doc.links.empty()) { + current_link = (current_link - 1 + current_doc.links.size()) % current_doc.links.size(); + scroll_to_link(current_link); + } + break; + + case Action::FOLLOW_LINK: + if (current_link >= 0 && current_link < static_cast(current_doc.links.size())) { + load_page(current_doc.links[current_link].url); + } + break; + + case Action::GO_BACK: + if (history_pos > 0) { + history_pos--; + load_page(history[history_pos]); + } else { + status_message = "No previous page"; + } + break; + + case Action::GO_FORWARD: + if (history_pos < static_cast(history.size()) - 1) { + history_pos++; + load_page(history[history_pos]); + } else { + status_message = "No next page"; + } + break; + + case Action::OPEN_URL: + if (!result.text.empty()) { + load_page(result.text); + } + break; + + case Action::REFRESH: + if (!current_url.empty()) { + load_page(current_url); + } + break; + + case Action::SEARCH_FORWARD: + search_term = result.text; + search_results.clear(); + for (size_t i = 0; i < rendered_lines.size(); ++i) { + if (rendered_lines[i].text.find(search_term) != std::string::npos) { + search_results.push_back(i); + } + } + if (!search_results.empty()) { + scroll_pos = search_results[0]; + status_message = "Found " + std::to_string(search_results.size()) + " matches"; + } else { + status_message = "Pattern not found: " + search_term; + } + break; + + case Action::SEARCH_NEXT: + if (!search_results.empty()) { + auto it = std::upper_bound(search_results.begin(), search_results.end(), scroll_pos); + if (it != search_results.end()) { + scroll_pos = *it; + } else { + scroll_pos = search_results[0]; + status_message = "Search wrapped to top"; + } + } + break; + + case Action::SEARCH_PREV: + if (!search_results.empty()) { + auto it = std::lower_bound(search_results.begin(), search_results.end(), scroll_pos); + if (it != search_results.begin()) { + scroll_pos = *(--it); + } else { + scroll_pos = search_results.back(); + status_message = "Search wrapped to bottom"; + } + } + break; + + case Action::HELP: + show_help(); + break; + + default: + break; + } + } + + void scroll_to_link(int link_idx) { + // 查找链接在渲染行中的位置 + for (size_t i = 0; i < rendered_lines.size(); ++i) { + if (rendered_lines[i].is_link && rendered_lines[i].link_index == link_idx) { + int visible_lines = screen_height - 2; + if (static_cast(i) < scroll_pos || static_cast(i) >= scroll_pos + visible_lines) { + scroll_pos = std::max(0, static_cast(i) - visible_lines / 2); + } + break; + } + } + } + + void show_help() { + std::ostringstream help_html; + help_html << "TUT Browser Help" + << "

TUT Browser - Vim-style Terminal Browser

" + << "

Navigation

" + << "

j/k or ↓/↑: Scroll down/up

" + << "

Ctrl-D or Space: Scroll page down

" + << "

Ctrl-U or b: Scroll page up

" + << "

gg: Go to top

" + << "

G: Go to bottom

" + << "

[number]G: Go to line number

" + << "

Links

" + << "

Tab: Next link

" + << "

Shift-Tab or T: Previous link

" + << "

Enter: Follow link

" + << "

h: Go back

" + << "

l: Go forward

" + << "

Search

" + << "

/: Start search

" + << "

n: Next match

" + << "

N: Previous match

" + << "

Commands

" + << "

:q or :quit - Quit browser

" + << "

:o URL or :open URL - Open URL

" + << "

:r or :refresh - Refresh page

" + << "

:h or :help - Show this help

" + << "

:[number] - Go to line number

" + << "

Other

" + << "

r: Refresh current page

" + << "

q: Quit browser

" + << "

?: Show help

" + << "

Important Limitations

" + << "

JavaScript/SPA Websites: This browser cannot execute JavaScript. " + << "Single Page Applications (SPAs) built with React, Vue, Angular, etc. will not work properly " + << "as they render content dynamically with JavaScript.

" + << "

Works best with:

" + << "
    " + << "
  • Static HTML websites
  • " + << "
  • Server-side rendered pages
  • " + << "
  • Documentation sites
  • " + << "
  • News sites with HTML content
  • " + << "
  • Blogs with traditional HTML
  • " + << "
" + << "

Example sites that work well:

" + << "

- https://example.com

" + << "

- https://en.wikipedia.org

" + << "

- Text-based news sites

" + << "

For JavaScript-heavy sites: You may need to find alternative URLs " + << "that provide the same content in plain HTML format.

" + << ""; + + current_doc = html_parser.parse(help_html.str(), "help://"); + rendered_lines = renderer.render(current_doc, screen_width); + scroll_pos = 0; + current_link = -1; + status_message = "Help - Press q to return"; + } +}; + +Browser::Browser() : pImpl(std::make_unique()) { + pImpl->input_handler.set_status_callback([this](const std::string& msg) { + pImpl->status_message = msg; + }); +} + +Browser::~Browser() = default; + +void Browser::run(const std::string& initial_url) { + pImpl->init_screen(); + + if (!initial_url.empty()) { + load_url(initial_url); + } else { + pImpl->show_help(); + } + + bool running = true; + while (running) { + pImpl->draw_screen(); + refresh(); + + int ch = getch(); + if (ch == ERR) { + napms(50); // 50ms sleep + continue; + } + + auto result = pImpl->input_handler.handle_key(ch); + + if (result.action == Action::QUIT) { + running = false; + } else if (result.action != Action::NONE) { + pImpl->handle_action(result); + } + } + + pImpl->cleanup_screen(); +} + +bool Browser::load_url(const std::string& url) { + return pImpl->load_page(url); +} + +std::string Browser::get_current_url() const { + return pImpl->current_url; +} diff --git a/src/browser.h b/src/browser.h new file mode 100644 index 0000000..64ddfde --- /dev/null +++ b/src/browser.h @@ -0,0 +1,28 @@ +#pragma once + +#include "http_client.h" +#include "html_parser.h" +#include "text_renderer.h" +#include "input_handler.h" +#include +#include +#include + +class Browser { +public: + Browser(); + ~Browser(); + + // 启动浏览器(进入主循环) + void run(const std::string& initial_url = ""); + + // 加载URL + bool load_url(const std::string& url); + + // 获取当前URL + std::string get_current_url() const; + +private: + class Impl; + std::unique_ptr pImpl; +}; diff --git a/src/html_parser.cpp b/src/html_parser.cpp new file mode 100644 index 0000000..a66711b --- /dev/null +++ b/src/html_parser.cpp @@ -0,0 +1,294 @@ +#include "html_parser.h" +#include +#include +#include +#include + +class HtmlParser::Impl { +public: + bool keep_code_blocks = true; + bool keep_lists = true; + + // 简单的HTML标签清理 + std::string remove_tags(const std::string& html) { + std::string result; + bool in_tag = false; + for (char c : html) { + if (c == '<') { + in_tag = true; + } else if (c == '>') { + in_tag = false; + } else if (!in_tag) { + result += c; + } + } + return result; + } + + // 解码HTML实体 + std::string decode_html_entities(const std::string& text) { + std::string result = text; + + // 常见HTML实体 + const std::vector> entities = { + {" ", " "}, + {"&", "&"}, + {"<", "<"}, + {">", ">"}, + {""", "\""}, + {"'", "'"}, + {"'", "'"}, + {"—", "\u2014"}, + {"–", "\u2013"}, + {"…", "..."}, + {"“", "\u201C"}, + {"”", "\u201D"}, + {"‘", "\u2018"}, + {"’", "\u2019"} + }; + + for (const auto& [entity, replacement] : entities) { + size_t pos = 0; + while ((pos = result.find(entity, pos)) != std::string::npos) { + result.replace(pos, entity.length(), replacement); + pos += replacement.length(); + } + } + + return result; + } + + // 提取标签内容 + std::string extract_tag_content(const std::string& html, const std::string& tag) { + std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)", + std::regex::icase); + std::smatch match; + if (std::regex_search(html, match, tag_regex)) { + return match[1].str(); + } + return ""; + } + + // 提取所有匹配的标签 + std::vector extract_all_tags(const std::string& html, const std::string& tag) { + std::vector results; + std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)", + std::regex::icase); + + auto begin = std::sregex_iterator(html.begin(), html.end(), tag_regex); + auto end = std::sregex_iterator(); + + for (std::sregex_iterator i = begin; i != end; ++i) { + std::smatch match = *i; + results.push_back(match[1].str()); + } + + return results; + } + + // 提取链接 + std::vector extract_links(const std::string& html, const std::string& base_url) { + std::vector links; + std::regex link_regex(R"(]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?))", + std::regex::icase); + + auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex); + auto end = std::sregex_iterator(); + + int position = 0; + for (std::sregex_iterator i = begin; i != end; ++i) { + std::smatch match = *i; + Link link; + link.url = match[1].str(); + link.text = decode_html_entities(remove_tags(match[2].str())); + link.position = position++; + + // 处理相对URL + if (!link.url.empty() && link.url[0] != '#') { + // 如果是相对路径 + if (link.url.find("://") == std::string::npos) { + // 提取base_url的协议和域名 + std::regex base_regex(R"((https?://[^/]+)(/.*)?)", std::regex::icase); + std::smatch base_match; + if (std::regex_match(base_url, base_match, base_regex)) { + std::string base_domain = base_match[1].str(); + std::string base_path = base_match[2].str(); + + if (link.url[0] == '/') { + // 绝对路径(从根目录开始) + link.url = base_domain + link.url; + } else { + // 相对路径 + // 获取当前页面的目录 + size_t last_slash = base_path.rfind('/'); + std::string current_dir = (last_slash != std::string::npos) + ? base_path.substr(0, last_slash + 1) + : "/"; + link.url = base_domain + current_dir + link.url; + } + } + } + + // 过滤空链接文本 + if (!link.text.empty()) { + links.push_back(link); + } + } + } + + return links; + } + + // 清理空白字符 + std::string trim(const std::string& str) { + auto start = str.begin(); + while (start != str.end() && std::isspace(*start)) { + ++start; + } + + auto end = str.end(); + do { + --end; + } while (std::distance(start, end) > 0 && std::isspace(*end)); + + return std::string(start, end + 1); + } + + // 移除脚本和样式 + std::string remove_scripts_and_styles(const std::string& html) { + std::string result = html; + + // 移除script标签 + result = std::regex_replace(result, + std::regex("]*>[\\s\\S]*?", std::regex::icase), + ""); + + // 移除style标签 + result = std::regex_replace(result, + std::regex("]*>[\\s\\S]*?", std::regex::icase), + ""); + + return result; + } +}; + +HtmlParser::HtmlParser() : pImpl(std::make_unique()) {} + +HtmlParser::~HtmlParser() = default; + +ParsedDocument HtmlParser::parse(const std::string& html, const std::string& base_url) { + ParsedDocument doc; + doc.url = base_url; + + // 清理HTML + std::string clean_html = pImpl->remove_scripts_and_styles(html); + + // 提取标题 + std::string title_content = pImpl->extract_tag_content(clean_html, "title"); + doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(title_content))); + + if (doc.title.empty()) { + std::string h1_content = pImpl->extract_tag_content(clean_html, "h1"); + doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(h1_content))); + } + + // 提取主要内容区域(article, main, 或 body) + std::string main_content = pImpl->extract_tag_content(clean_html, "article"); + if (main_content.empty()) { + main_content = pImpl->extract_tag_content(clean_html, "main"); + } + if (main_content.empty()) { + main_content = pImpl->extract_tag_content(clean_html, "body"); + } + if (main_content.empty()) { + main_content = clean_html; + } + + // 提取链接 + doc.links = pImpl->extract_links(main_content, base_url); + + // 解析标题 + for (int level = 1; level <= 6; ++level) { + std::string tag = "h" + std::to_string(level); + auto headings = pImpl->extract_all_tags(main_content, tag); + for (const auto& heading : headings) { + ContentElement elem; + elem.type = (level == 1) ? ElementType::HEADING1 : + (level == 2) ? ElementType::HEADING2 : ElementType::HEADING3; + elem.text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(heading))); + elem.level = level; + if (!elem.text.empty()) { + doc.elements.push_back(elem); + } + } + } + + // 解析列表项 + if (pImpl->keep_lists) { + auto list_items = pImpl->extract_all_tags(main_content, "li"); + for (const auto& item : list_items) { + std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(item))); + if (!text.empty() && text.length() > 1) { + ContentElement elem; + elem.type = ElementType::LIST_ITEM; + elem.text = text; + doc.elements.push_back(elem); + } + } + } + + // 解析段落 + auto paragraphs = pImpl->extract_all_tags(main_content, "p"); + for (const auto& para : paragraphs) { + std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(para))); + if (!text.empty() && text.length() > 1) { + ContentElement elem; + elem.type = ElementType::PARAGRAPH; + elem.text = text; + doc.elements.push_back(elem); + } + } + + // 如果内容很少,尝试提取div中的文本 + if (doc.elements.size() < 3) { + auto divs = pImpl->extract_all_tags(main_content, "div"); + for (const auto& div : divs) { + std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(div))); + if (!text.empty() && text.length() > 20) { // 忽略太短的div + ContentElement elem; + elem.type = ElementType::PARAGRAPH; + elem.text = text; + doc.elements.push_back(elem); + } + } + } + + // 如果仍然没有内容,尝试提取整个文本 + if (doc.elements.empty()) { + std::string all_text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(main_content))); + if (!all_text.empty()) { + // 按换行符分割 + std::istringstream iss(all_text); + std::string line; + while (std::getline(iss, line)) { + line = pImpl->trim(line); + if (!line.empty() && line.length() > 1) { + ContentElement elem; + elem.type = ElementType::PARAGRAPH; + elem.text = line; + doc.elements.push_back(elem); + } + } + } + } + + return doc; +} + +void HtmlParser::set_keep_code_blocks(bool keep) { + pImpl->keep_code_blocks = keep; +} + +void HtmlParser::set_keep_lists(bool keep) { + pImpl->keep_lists = keep; +} diff --git a/src/html_parser.h b/src/html_parser.h new file mode 100644 index 0000000..056c6e2 --- /dev/null +++ b/src/html_parser.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +enum class ElementType { + TEXT, + HEADING1, + HEADING2, + HEADING3, + PARAGRAPH, + LINK, + LIST_ITEM, + BLOCKQUOTE, + CODE_BLOCK, + HORIZONTAL_RULE, + LINE_BREAK +}; + +struct Link { + std::string text; + std::string url; + int position; // 在文档中的位置(用于TAB导航) +}; + +struct ContentElement { + ElementType type; + std::string text; + std::string url; // 对于链接元素 + int level; // 对于标题元素(1-6) +}; + +struct ParsedDocument { + std::string title; + std::string url; + std::vector elements; + std::vector links; +}; + +class HtmlParser { +public: + HtmlParser(); + ~HtmlParser(); + + // 解析HTML并提取可读内容 + ParsedDocument parse(const std::string& html, const std::string& base_url = ""); + + // 设置是否保留代码块 + void set_keep_code_blocks(bool keep); + + // 设置是否保留列表 + void set_keep_lists(bool keep); + +private: + class Impl; + std::unique_ptr pImpl; +}; diff --git a/src/http_client.cpp b/src/http_client.cpp new file mode 100644 index 0000000..dd990dc --- /dev/null +++ b/src/http_client.cpp @@ -0,0 +1,111 @@ +#include "http_client.h" +#include +#include + +// 回调函数用于接收数据 +static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) { + size_t total_size = size * nmemb; + userp->append(static_cast(contents), total_size); + return total_size; +} + +class HttpClient::Impl { +public: + CURL* curl; + long timeout; + std::string user_agent; + bool follow_redirects; + + Impl() : timeout(30), + user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"), + follow_redirects(true) { + curl = curl_easy_init(); + if (!curl) { + throw std::runtime_error("Failed to initialize CURL"); + } + } + + ~Impl() { + if (curl) { + curl_easy_cleanup(curl); + } + } +}; + +HttpClient::HttpClient() : pImpl(std::make_unique()) {} + +HttpClient::~HttpClient() = default; + +HttpResponse HttpClient::fetch(const std::string& url) { + HttpResponse response; + response.status_code = 0; + + if (!pImpl->curl) { + response.error_message = "CURL not initialized"; + return response; + } + + // 重置选项 + curl_easy_reset(pImpl->curl); + + // 设置URL + curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str()); + + // 设置写回调 + std::string response_body; + curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_body); + + // 设置超时 + curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout); + curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L); + + // 设置用户代理 + curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str()); + + // 设置是否跟随重定向 + if (pImpl->follow_redirects) { + curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L); + } + + // 支持 HTTPS + curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L); + + // 执行请求 + CURLcode res = curl_easy_perform(pImpl->curl); + + if (res != CURLE_OK) { + response.error_message = curl_easy_strerror(res); + return response; + } + + // 获取响应码 + long http_code = 0; + curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code); + response.status_code = static_cast(http_code); + + // 获取 Content-Type + char* content_type = nullptr; + curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type); + if (content_type) { + response.content_type = content_type; + } + + response.body = std::move(response_body); + + return response; +} + +void HttpClient::set_timeout(long timeout_seconds) { + pImpl->timeout = timeout_seconds; +} + +void HttpClient::set_user_agent(const std::string& user_agent) { + pImpl->user_agent = user_agent; +} + +void HttpClient::set_follow_redirects(bool follow) { + pImpl->follow_redirects = follow; +} diff --git a/src/http_client.h b/src/http_client.h new file mode 100644 index 0000000..9793178 --- /dev/null +++ b/src/http_client.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +struct HttpResponse { + int status_code; + std::string body; + std::string content_type; + std::string error_message; + + bool is_success() const { + return status_code >= 200 && status_code < 300; + } +}; + +class HttpClient { +public: + HttpClient(); + ~HttpClient(); + + // 获取网页内容 + HttpResponse fetch(const std::string& url); + + // 设置超时(秒) + void set_timeout(long timeout_seconds); + + // 设置用户代理 + void set_user_agent(const std::string& user_agent); + + // 设置是否跟随重定向 + void set_follow_redirects(bool follow); + +private: + class Impl; + std::unique_ptr pImpl; +}; diff --git a/src/input_handler.cpp b/src/input_handler.cpp new file mode 100644 index 0000000..bc21e17 --- /dev/null +++ b/src/input_handler.cpp @@ -0,0 +1,253 @@ +#include "input_handler.h" +#include +#include +#include + +class InputHandler::Impl { +public: + InputMode mode = InputMode::NORMAL; + std::string buffer; + std::string count_buffer; + std::function status_callback; + + void set_status(const std::string& msg) { + if (status_callback) { + status_callback(msg); + } + } + + InputResult process_normal_mode(int ch) { + InputResult result; + result.action = Action::NONE; + result.number = 0; + result.has_count = false; + result.count = 1; + + // 处理数字前缀 + if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) { + count_buffer += static_cast(ch); + return result; + } + + // 解析count + if (!count_buffer.empty()) { + result.has_count = true; + result.count = std::stoi(count_buffer); + count_buffer.clear(); + } + + // 处理vim风格的命令 + switch (ch) { + // 移动 + case 'j': + case KEY_DOWN: + result.action = Action::SCROLL_DOWN; + break; + case 'k': + case KEY_UP: + result.action = Action::SCROLL_UP; + break; + case 'h': + case KEY_LEFT: + result.action = Action::GO_BACK; + break; + case 'l': + case KEY_RIGHT: + result.action = Action::GO_FORWARD; + break; + + // 翻页 + case 4: // Ctrl-D + case ' ': + result.action = Action::SCROLL_PAGE_DOWN; + break; + case 21: // Ctrl-U + case 'b': + result.action = Action::SCROLL_PAGE_UP; + break; + + // 跳转 + case 'g': + buffer += 'g'; + if (buffer == "gg") { + result.action = Action::GOTO_TOP; + buffer.clear(); + } + break; + case 'G': + if (result.has_count) { + result.action = Action::GOTO_LINE; + result.number = result.count; + } else { + result.action = Action::GOTO_BOTTOM; + } + break; + + // 搜索 + case '/': + mode = InputMode::SEARCH; + buffer = "/"; + break; + case 'n': + result.action = Action::SEARCH_NEXT; + break; + case 'N': + result.action = Action::SEARCH_PREV; + break; + + // 链接导航 + case '\t': // Tab + result.action = Action::NEXT_LINK; + break; + case KEY_BTAB: // Shift-Tab (可能不是所有终端都支持) + case 'T': + result.action = Action::PREV_LINK; + break; + case '\n': // Enter + case '\r': + result.action = Action::FOLLOW_LINK; + break; + + // 命令模式 + case ':': + mode = InputMode::COMMAND; + buffer = ":"; + break; + + // 其他操作 + case 'r': + result.action = Action::REFRESH; + break; + case 'q': + result.action = Action::QUIT; + break; + case '?': + result.action = Action::HELP; + break; + + default: + buffer.clear(); + break; + } + + return result; + } + + InputResult process_command_mode(int ch) { + InputResult result; + result.action = Action::NONE; + + if (ch == '\n' || ch == '\r') { + // 执行命令 + std::string command = buffer.substr(1); // 去掉':' + + if (command == "q" || command == "quit") { + result.action = Action::QUIT; + } else if (command == "h" || command == "help") { + result.action = Action::HELP; + } else if (command == "r" || command == "refresh") { + result.action = Action::REFRESH; + } else if (command.rfind("o ", 0) == 0 || command.rfind("open ", 0) == 0) { + // :o URL 或 :open URL + size_t space_pos = command.find(' '); + if (space_pos != std::string::npos) { + result.action = Action::OPEN_URL; + result.text = command.substr(space_pos + 1); + } + } else if (!command.empty() && std::isdigit(command[0])) { + // 跳转到行号 + try { + result.action = Action::GOTO_LINE; + result.number = std::stoi(command); + } catch (...) { + set_status("Invalid line number"); + } + } + + mode = InputMode::NORMAL; + buffer.clear(); + } else if (ch == 27) { // ESC + mode = InputMode::NORMAL; + buffer.clear(); + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { + if (buffer.length() > 1) { + buffer.pop_back(); + } else { + mode = InputMode::NORMAL; + buffer.clear(); + } + } else if (std::isprint(ch)) { + buffer += static_cast(ch); + } + + return result; + } + + InputResult process_search_mode(int ch) { + InputResult result; + result.action = Action::NONE; + + if (ch == '\n' || ch == '\r') { + // 执行搜索 + if (buffer.length() > 1) { + result.action = Action::SEARCH_FORWARD; + result.text = buffer.substr(1); // 去掉'/' + } + mode = InputMode::NORMAL; + buffer.clear(); + } else if (ch == 27) { // ESC + mode = InputMode::NORMAL; + buffer.clear(); + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { + if (buffer.length() > 1) { + buffer.pop_back(); + } else { + mode = InputMode::NORMAL; + buffer.clear(); + } + } else if (std::isprint(ch)) { + buffer += static_cast(ch); + } + + return result; + } +}; + +InputHandler::InputHandler() : pImpl(std::make_unique()) {} + +InputHandler::~InputHandler() = default; + +InputResult InputHandler::handle_key(int ch) { + switch (pImpl->mode) { + case InputMode::NORMAL: + return pImpl->process_normal_mode(ch); + case InputMode::COMMAND: + return pImpl->process_command_mode(ch); + case InputMode::SEARCH: + return pImpl->process_search_mode(ch); + default: + break; + } + + InputResult result; + result.action = Action::NONE; + return result; +} + +InputMode InputHandler::get_mode() const { + return pImpl->mode; +} + +std::string InputHandler::get_buffer() const { + return pImpl->buffer; +} + +void InputHandler::reset() { + pImpl->mode = InputMode::NORMAL; + pImpl->buffer.clear(); + pImpl->count_buffer.clear(); +} + +void InputHandler::set_status_callback(std::function callback) { + pImpl->status_callback = callback; +} diff --git a/src/input_handler.h b/src/input_handler.h new file mode 100644 index 0000000..2b2abc0 --- /dev/null +++ b/src/input_handler.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +enum class InputMode { + NORMAL, // 正常浏览模式 + COMMAND, // 命令模式 (:) + SEARCH, // 搜索模式 (/) + LINK // 链接选择模式 +}; + +enum class Action { + NONE, + SCROLL_UP, + SCROLL_DOWN, + SCROLL_PAGE_UP, + SCROLL_PAGE_DOWN, + GOTO_TOP, + GOTO_BOTTOM, + GOTO_LINE, + SEARCH_FORWARD, + SEARCH_NEXT, + SEARCH_PREV, + NEXT_LINK, + PREV_LINK, + FOLLOW_LINK, + GO_BACK, + GO_FORWARD, + OPEN_URL, + REFRESH, + QUIT, + HELP +}; + +struct InputResult { + Action action; + std::string text; // 用于命令、搜索、URL输入 + int number; // 用于跳转行号、链接编号等 + bool has_count; // 是否有数字前缀(如 5j) + int count; // 数字前缀 +}; + +class InputHandler { +public: + InputHandler(); + ~InputHandler(); + + // 处理单个按键 + InputResult handle_key(int ch); + + // 获取当前模式 + InputMode get_mode() const; + + // 获取当前输入缓冲(用于显示命令行) + std::string get_buffer() const; + + // 重置状态 + void reset(); + + // 设置状态栏消息回调 + void set_status_callback(std::function callback); + +private: + class Impl; + std::unique_ptr pImpl; +}; diff --git a/src/main.cpp b/src/main.cpp index 842c2d0..5e1b137 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,22 +1,46 @@ -#include "tui_view.h" -#include "calendar.h" +#include "browser.h" +#include +#include -int main() { - display_splash_screen(); // Display splash screen at startup +void print_usage(const char* prog_name) { + std::cout << "TUT - Terminal User Interface Browser\n" + << "A vim-style terminal web browser for comfortable reading\n\n" + << "Usage: " << prog_name << " [URL]\n\n" + << "If no URL is provided, the browser will start with a help page.\n\n" + << "Examples:\n" + << " " << prog_name << "\n" + << " " << prog_name << " https://example.com\n" + << " " << prog_name << " https://news.ycombinator.com\n\n" + << "Vim-style keybindings:\n" + << " j/k - Scroll down/up\n" + << " gg/G - Go to top/bottom\n" + << " / - Search\n" + << " Tab - Next link\n" + << " Enter - Follow link\n" + << " h/l - Back/Forward\n" + << " :o URL - Open URL\n" + << " :q - Quit\n" + << " ? - Show help\n"; +} - while (true) { - int choice = run_portal_tui(); +int main(int argc, char* argv[]) { + // 解析命令行参数 + std::string initial_url; - switch (choice) { - case 0: { // Calendar - Calendar calendar; - calendar.run(); - break; - } - case 1: { // Exit - return 0; - } + if (argc > 1) { + if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) { + print_usage(argv[0]); + return 0; } + initial_url = argv[1]; + } + + try { + Browser browser; + browser.run(initial_url); + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; } return 0; diff --git a/src/text_renderer.cpp b/src/text_renderer.cpp new file mode 100644 index 0000000..3696e5d --- /dev/null +++ b/src/text_renderer.cpp @@ -0,0 +1,300 @@ +#include "text_renderer.h" +#include +#include +#include + +class TextRenderer::Impl { +public: + RenderConfig config; + + // 自动换行处理 + std::vector wrap_text(const std::string& text, int width) { + std::vector lines; + if (text.empty()) { + return lines; + } + + std::istringstream words_stream(text); + std::string word; + std::string current_line; + + while (words_stream >> word) { + // 处理单个词超长的情况 + if (word.length() > static_cast(width)) { + if (!current_line.empty()) { + lines.push_back(current_line); + current_line.clear(); + } + // 强制分割长词 + for (size_t i = 0; i < word.length(); i += width) { + lines.push_back(word.substr(i, width)); + } + continue; + } + + // 正常换行逻辑 + if (current_line.empty()) { + current_line = word; + } else if (current_line.length() + 1 + word.length() <= static_cast(width)) { + current_line += " " + word; + } else { + lines.push_back(current_line); + current_line = word; + } + } + + if (!current_line.empty()) { + lines.push_back(current_line); + } + + if (lines.empty()) { + lines.push_back(""); + } + + return lines; + } + + // 添加缩进 + std::string add_indent(const std::string& text, int indent) { + return std::string(indent, ' ') + text; + } +}; + +TextRenderer::TextRenderer() : pImpl(std::make_unique()) { + pImpl->config = RenderConfig(); +} + +TextRenderer::~TextRenderer() = default; + +std::vector TextRenderer::render(const ParsedDocument& doc, int screen_width) { + std::vector lines; + + // 计算实际内容宽度 + int content_width = std::min(pImpl->config.max_width, screen_width - 4); + if (content_width < 40) { + content_width = screen_width - 4; + } + + // 计算左边距(如果居中) + int margin = 0; + if (pImpl->config.center_content && content_width < screen_width) { + margin = (screen_width - content_width) / 2; + } + pImpl->config.margin_left = margin; + + // 渲染标题 + if (!doc.title.empty()) { + RenderedLine title_line; + title_line.text = std::string(margin, ' ') + doc.title; + title_line.color_pair = COLOR_HEADING1; + title_line.is_bold = true; + title_line.is_link = false; + title_line.link_index = -1; + lines.push_back(title_line); + + // 标题下划线 + RenderedLine underline; + underline.text = std::string(margin, ' ') + std::string(std::min((int)doc.title.length(), content_width), '='); + underline.color_pair = COLOR_HEADING1; + underline.is_bold = false; + underline.is_link = false; + underline.link_index = -1; + lines.push_back(underline); + + // 空行 + RenderedLine empty; + empty.text = ""; + empty.color_pair = COLOR_NORMAL; + empty.is_bold = false; + empty.is_link = false; + empty.link_index = -1; + lines.push_back(empty); + } + + // 渲染URL + if (!doc.url.empty()) { + RenderedLine url_line; + url_line.text = std::string(margin, ' ') + "URL: " + doc.url; + url_line.color_pair = COLOR_URL_BAR; + url_line.is_bold = false; + url_line.is_link = false; + url_line.link_index = -1; + lines.push_back(url_line); + + RenderedLine empty; + empty.text = ""; + empty.color_pair = COLOR_NORMAL; + empty.is_bold = false; + empty.is_link = false; + empty.link_index = -1; + lines.push_back(empty); + } + + // 渲染内容元素 + for (const auto& elem : doc.elements) { + int color = COLOR_NORMAL; + bool bold = false; + std::string prefix = ""; + + switch (elem.type) { + case ElementType::HEADING1: + color = COLOR_HEADING1; + bold = true; + prefix = "# "; + break; + case ElementType::HEADING2: + color = COLOR_HEADING2; + bold = true; + prefix = "## "; + break; + case ElementType::HEADING3: + color = COLOR_HEADING3; + bold = true; + prefix = "### "; + break; + case ElementType::PARAGRAPH: + color = COLOR_NORMAL; + bold = false; + break; + case ElementType::BLOCKQUOTE: + color = COLOR_DIM; + prefix = "> "; + break; + case ElementType::LIST_ITEM: + prefix = " • "; + break; + case ElementType::HORIZONTAL_RULE: + { + RenderedLine hr; + std::string hrline(content_width, '-'); + hr.text = std::string(margin, ' ') + hrline; + hr.color_pair = COLOR_DIM; + hr.is_bold = false; + hr.is_link = false; + hr.link_index = -1; + lines.push_back(hr); + continue; + } + default: + break; + } + + // 换行处理 + auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length()); + for (size_t i = 0; i < wrapped_lines.size(); ++i) { + RenderedLine line; + if (i == 0) { + line.text = std::string(margin, ' ') + prefix + wrapped_lines[i]; + } else { + line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i]; + } + line.color_pair = color; + line.is_bold = bold; + line.is_link = false; + line.link_index = -1; + lines.push_back(line); + } + + // 段落间距 + if (elem.type == ElementType::PARAGRAPH || + elem.type == ElementType::HEADING1 || + elem.type == ElementType::HEADING2 || + elem.type == ElementType::HEADING3) { + for (int i = 0; i < pImpl->config.paragraph_spacing; ++i) { + RenderedLine empty; + empty.text = ""; + empty.color_pair = COLOR_NORMAL; + empty.is_bold = false; + empty.is_link = false; + empty.link_index = -1; + lines.push_back(empty); + } + } + } + + // 渲染链接列表 + if (!doc.links.empty() && pImpl->config.show_link_indicators) { + RenderedLine separator; + std::string sepline(content_width, '-'); + separator.text = std::string(margin, ' ') + sepline; + separator.color_pair = COLOR_DIM; + separator.is_bold = false; + separator.is_link = false; + separator.link_index = -1; + lines.push_back(separator); + + RenderedLine links_header; + links_header.text = std::string(margin, ' ') + "Links:"; + links_header.color_pair = COLOR_HEADING3; + links_header.is_bold = true; + links_header.is_link = false; + links_header.link_index = -1; + lines.push_back(links_header); + + RenderedLine empty; + empty.text = ""; + empty.color_pair = COLOR_NORMAL; + empty.is_bold = false; + empty.is_link = false; + empty.link_index = -1; + lines.push_back(empty); + + for (size_t i = 0; i < doc.links.size(); ++i) { + const auto& link = doc.links[i]; + std::string link_text = "[" + std::to_string(i) + "] " + link.text; + + auto wrapped = pImpl->wrap_text(link_text, content_width - 4); + for (size_t j = 0; j < wrapped.size(); ++j) { + RenderedLine link_line; + link_line.text = std::string(margin + 2, ' ') + wrapped[j]; + link_line.color_pair = COLOR_LINK; + link_line.is_bold = false; + link_line.is_link = true; + link_line.link_index = i; + lines.push_back(link_line); + } + + // URL on next line + auto url_wrapped = pImpl->wrap_text(link.url, content_width - 6); + for (const auto& url_line_text : url_wrapped) { + RenderedLine url_line; + url_line.text = std::string(margin + 4, ' ') + "→ " + url_line_text; + url_line.color_pair = COLOR_DIM; + url_line.is_bold = false; + url_line.is_link = false; + url_line.link_index = -1; + lines.push_back(url_line); + } + + lines.push_back(empty); + } + } + + return lines; +} + +void TextRenderer::set_config(const RenderConfig& config) { + pImpl->config = config; +} + +RenderConfig TextRenderer::get_config() const { + return pImpl->config; +} + +void init_color_scheme() { + if (has_colors()) { + start_color(); + use_default_colors(); + + init_pair(COLOR_NORMAL, COLOR_WHITE, -1); + init_pair(COLOR_HEADING1, COLOR_CYAN, -1); + init_pair(COLOR_HEADING2, COLOR_BLUE, -1); + init_pair(COLOR_HEADING3, COLOR_MAGENTA, -1); + init_pair(COLOR_LINK, COLOR_YELLOW, -1); + init_pair(COLOR_LINK_ACTIVE, COLOR_BLACK, COLOR_YELLOW); + init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE); + init_pair(COLOR_URL_BAR, COLOR_GREEN, -1); + init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW); + init_pair(COLOR_DIM, COLOR_BLACK, -1); + } +} diff --git a/src/text_renderer.h b/src/text_renderer.h new file mode 100644 index 0000000..3737a5c --- /dev/null +++ b/src/text_renderer.h @@ -0,0 +1,60 @@ +#pragma once + +#include "html_parser.h" +#include +#include +#include + +// 渲染后的行信息 +struct RenderedLine { + std::string text; + int color_pair; + bool is_bold; + bool is_link; + int link_index; // 如果是链接,对应的链接索引 +}; + +// 渲染配置 +struct RenderConfig { + int max_width = 80; // 内容最大宽度 + int margin_left = 0; // 左边距(居中时自动计算) + bool center_content = true; // 是否居中内容 + int paragraph_spacing = 1; // 段落间距 + bool show_link_indicators = true; // 是否显示链接指示器 +}; + +class TextRenderer { +public: + TextRenderer(); + ~TextRenderer(); + + // 渲染文档到行数组 + std::vector render(const ParsedDocument& doc, int screen_width); + + // 设置渲染配置 + void set_config(const RenderConfig& config); + + // 获取当前配置 + RenderConfig get_config() const; + +private: + class Impl; + std::unique_ptr pImpl; +}; + +// 颜色定义 +enum ColorScheme { + COLOR_NORMAL = 1, + COLOR_HEADING1, + COLOR_HEADING2, + COLOR_HEADING3, + COLOR_LINK, + COLOR_LINK_ACTIVE, + COLOR_STATUS_BAR, + COLOR_URL_BAR, + COLOR_SEARCH_HIGHLIGHT, + COLOR_DIM +}; + +// 初始化颜色方案 +void init_color_scheme();