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.
This commit is contained in:
m1ngsama 2025-12-05 15:01:21 +08:00 committed by GitHub
parent 5b54b3e9c8
commit ab2d1932e4
15 changed files with 1973 additions and 58 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
build/
*.o
tut
.DS_Store

View file

@ -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)

44
Makefile Normal file
View file

@ -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

261
README.md
View file

@ -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 建议 clangLinux 建议 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设计

443
src/browser.cpp Normal file
View file

@ -0,0 +1,443 @@
#include "browser.h"
#include <curses.h>
#include <clocale>
#include <algorithm>
#include <sstream>
class Browser::Impl {
public:
HttpClient http_client;
HtmlParser html_parser;
TextRenderer renderer;
InputHandler input_handler;
ParsedDocument current_doc;
std::vector<RenderedLine> rendered_lines;
std::string current_url;
std::vector<std::string> history;
int history_pos = -1;
// 视图状态
int scroll_pos = 0;
int current_link = -1;
std::string status_message;
std::string search_term;
std::vector<int> 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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(i) < scroll_pos || static_cast<int>(i) >= scroll_pos + visible_lines) {
scroll_pos = std::max(0, static_cast<int>(i) - visible_lines / 2);
}
break;
}
}
}
void show_help() {
std::ostringstream help_html;
help_html << "<html><head><title>TUT Browser Help</title></head><body>"
<< "<h1>TUT Browser - Vim-style Terminal Browser</h1>"
<< "<h2>Navigation</h2>"
<< "<p>j/k or ↓/↑: Scroll down/up</p>"
<< "<p>Ctrl-D or Space: Scroll page down</p>"
<< "<p>Ctrl-U or b: Scroll page up</p>"
<< "<p>gg: Go to top</p>"
<< "<p>G: Go to bottom</p>"
<< "<p>[number]G: Go to line number</p>"
<< "<h2>Links</h2>"
<< "<p>Tab: Next link</p>"
<< "<p>Shift-Tab or T: Previous link</p>"
<< "<p>Enter: Follow link</p>"
<< "<p>h: Go back</p>"
<< "<p>l: Go forward</p>"
<< "<h2>Search</h2>"
<< "<p>/: Start search</p>"
<< "<p>n: Next match</p>"
<< "<p>N: Previous match</p>"
<< "<h2>Commands</h2>"
<< "<p>:q or :quit - Quit browser</p>"
<< "<p>:o URL or :open URL - Open URL</p>"
<< "<p>:r or :refresh - Refresh page</p>"
<< "<p>:h or :help - Show this help</p>"
<< "<p>:[number] - Go to line number</p>"
<< "<h2>Other</h2>"
<< "<p>r: Refresh current page</p>"
<< "<p>q: Quit browser</p>"
<< "<p>?: Show help</p>"
<< "<h2>Important Limitations</h2>"
<< "<p><strong>JavaScript/SPA Websites:</strong> 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.</p>"
<< "<p><strong>Works best with:</strong></p>"
<< "<ul>"
<< "<li>Static HTML websites</li>"
<< "<li>Server-side rendered pages</li>"
<< "<li>Documentation sites</li>"
<< "<li>News sites with HTML content</li>"
<< "<li>Blogs with traditional HTML</li>"
<< "</ul>"
<< "<p><strong>Example sites that work well:</strong></p>"
<< "<p>- https://example.com</p>"
<< "<p>- https://en.wikipedia.org</p>"
<< "<p>- Text-based news sites</p>"
<< "<p><strong>For JavaScript-heavy sites:</strong> You may need to find alternative URLs "
<< "that provide the same content in plain HTML format.</p>"
<< "</body></html>";
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<Impl>()) {
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;
}

28
src/browser.h Normal file
View file

@ -0,0 +1,28 @@
#pragma once
#include "http_client.h"
#include "html_parser.h"
#include "text_renderer.h"
#include "input_handler.h"
#include <string>
#include <vector>
#include <memory>
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<Impl> pImpl;
};

294
src/html_parser.cpp Normal file
View file

@ -0,0 +1,294 @@
#include "html_parser.h"
#include <regex>
#include <algorithm>
#include <cctype>
#include <sstream>
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<std::pair<std::string, std::string>> entities = {
{"&nbsp;", " "},
{"&amp;", "&"},
{"&lt;", "<"},
{"&gt;", ">"},
{"&quot;", "\""},
{"&apos;", "'"},
{"&#39;", "'"},
{"&mdash;", "\u2014"},
{"&ndash;", "\u2013"},
{"&hellip;", "..."},
{"&ldquo;", "\u201C"},
{"&rdquo;", "\u201D"},
{"&lsquo;", "\u2018"},
{"&rsquo;", "\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]*?)</" + tag + ">",
std::regex::icase);
std::smatch match;
if (std::regex_search(html, match, tag_regex)) {
return match[1].str();
}
return "";
}
// 提取所有匹配的标签
std::vector<std::string> extract_all_tags(const std::string& html, const std::string& tag) {
std::vector<std::string> results;
std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">",
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<Link> extract_links(const std::string& html, const std::string& base_url) {
std::vector<Link> links;
std::regex link_regex(R"(<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)</a>)",
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("<script[^>]*>[\\s\\S]*?</script>", std::regex::icase),
"");
// 移除style标签
result = std::regex_replace(result,
std::regex("<style[^>]*>[\\s\\S]*?</style>", std::regex::icase),
"");
return result;
}
};
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
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;
}

57
src/html_parser.h Normal file
View file

@ -0,0 +1,57 @@
#pragma once
#include <string>
#include <vector>
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<ContentElement> elements;
std::vector<Link> 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<Impl> pImpl;
};

111
src/http_client.cpp Normal file
View file

@ -0,0 +1,111 @@
#include "http_client.h"
#include <curl/curl.h>
#include <stdexcept>
// 回调函数用于接收数据
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<char*>(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<Impl>()) {}
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<int>(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;
}

37
src/http_client.h Normal file
View file

@ -0,0 +1,37 @@
#pragma once
#include <string>
#include <memory>
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<Impl> pImpl;
};

253
src/input_handler.cpp Normal file
View file

@ -0,0 +1,253 @@
#include "input_handler.h"
#include <curses.h>
#include <cctype>
#include <sstream>
class InputHandler::Impl {
public:
InputMode mode = InputMode::NORMAL;
std::string buffer;
std::string count_buffer;
std::function<void(const std::string&)> 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<char>(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<char>(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<char>(ch);
}
return result;
}
};
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
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<void(const std::string&)> callback) {
pImpl->status_callback = callback;
}

67
src/input_handler.h Normal file
View file

@ -0,0 +1,67 @@
#pragma once
#include <string>
#include <functional>
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<void(const std::string&)> callback);
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};

View file

@ -1,22 +1,46 @@
#include "tui_view.h"
#include "calendar.h"
#include "browser.h"
#include <iostream>
#include <cstring>
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;

300
src/text_renderer.cpp Normal file
View file

@ -0,0 +1,300 @@
#include "text_renderer.h"
#include <sstream>
#include <algorithm>
#include <clocale>
class TextRenderer::Impl {
public:
RenderConfig config;
// 自动换行处理
std::vector<std::string> wrap_text(const std::string& text, int width) {
std::vector<std::string> 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<size_t>(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<size_t>(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<Impl>()) {
pImpl->config = RenderConfig();
}
TextRenderer::~TextRenderer() = default;
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
std::vector<RenderedLine> 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);
}
}

60
src/text_renderer.h Normal file
View file

@ -0,0 +1,60 @@
#pragma once
#include "html_parser.h"
#include <string>
#include <vector>
#include <curses.h>
// 渲染后的行信息
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<RenderedLine> render(const ParsedDocument& doc, int screen_width);
// 设置渲染配置
void set_config(const RenderConfig& config);
// 获取当前配置
RenderConfig get_config() const;
private:
class Impl;
std::unique_ptr<Impl> 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();