mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-24 10:51:46 +00:00
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:
parent
5b54b3e9c8
commit
ab2d1932e4
15 changed files with 1973 additions and 58 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
||||||
build/
|
build/
|
||||||
|
*.o
|
||||||
|
tut
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.15)
|
cmake_minimum_required(VERSION 3.15)
|
||||||
project(NBTCA_TUI LANGUAGES CXX)
|
project(TUT LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
@ -15,15 +15,16 @@ endif()
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
find_package(CURL REQUIRED)
|
find_package(CURL REQUIRED)
|
||||||
|
|
||||||
add_executable(nbtca_tui
|
add_executable(tut
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/ics_fetcher.cpp
|
src/http_client.cpp
|
||||||
src/ics_parser.cpp
|
src/html_parser.cpp
|
||||||
src/tui_view.cpp
|
src/text_renderer.cpp
|
||||||
src/calendar.cpp
|
src/input_handler.cpp
|
||||||
|
src/browser.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(nbtca_tui PRIVATE ${CURSES_INCLUDE_DIR})
|
target_include_directories(tut PRIVATE ${CURSES_INCLUDE_DIR})
|
||||||
target_link_libraries(nbtca_tui PRIVATE ${CURSES_LIBRARIES} CURL::libcurl)
|
target_link_libraries(tut PRIVATE ${CURSES_LIBRARIES} CURL::libcurl)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
44
Makefile
Normal file
44
Makefile
Normal 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
261
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
|
- CMake ≥ 3.15
|
||||||
- C++17 编译器(macOS 上建议 `clang`)
|
- C++17 编译器(macOS 建议 clang,Linux 建议 g++)
|
||||||
- `ncurses`
|
- `ncurses` 或 `ncursesw`(支持宽字符)
|
||||||
- `libcurl`
|
- `libcurl`(支持HTTPS)
|
||||||
|
|
||||||
#### 在 macOS (Homebrew) 安装依赖
|
### macOS (Homebrew) 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install cmake ncurses curl
|
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 .
|
cmake --build .
|
||||||
```
|
```
|
||||||
|
|
||||||
生成的可执行文件为 `nbtca_tui`。
|
生成的可执行文件为 `tut`。
|
||||||
|
|
||||||
### 运行
|
## 运行
|
||||||
|
|
||||||
在 `build` 目录中运行:
|
### 直接启动(显示帮助页面)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./nbtca_tui
|
./tut
|
||||||
```
|
```
|
||||||
|
|
||||||
程序会:
|
### 打开指定URL
|
||||||
|
|
||||||
1. 通过 `libcurl` 请求 `https://ical.nbtca.space/nbtca.ics`
|
```bash
|
||||||
2. 解析所有 VEVENT 事件,提取开始时间、结束时间、标题、地点、描述
|
./tut https://example.com
|
||||||
3. 过滤出从当前时间起未来 30 天内的事件
|
./tut https://news.ycombinator.com
|
||||||
4. 使用 ncurses TUI 滚动展示列表
|
```
|
||||||
|
|
||||||
### TUI 操作说明
|
### 显示使用帮助
|
||||||
|
|
||||||
- `↑` / `↓`:上下移动选中事件
|
```bash
|
||||||
- `q`:退出程序
|
./tut --help
|
||||||
|
```
|
||||||
|
|
||||||
### Developer Guide
|
## 键盘操作
|
||||||
|
|
||||||
For contributors and developers, follow these guidelines:
|
### 导航
|
||||||
|
|
||||||
1. **Clone the Repository:**
|
| 按键 | 功能 |
|
||||||
```bash
|
|------|------|
|
||||||
git clone https://github.com/m1ngsama/TUT.git
|
| `j` / `↓` | 向下滚动一行 |
|
||||||
cd TUT
|
| `k` / `↑` | 向上滚动一行 |
|
||||||
```
|
| `Ctrl-D` / `Space` | 向下翻页 |
|
||||||
2. **Build Environment Setup:** Ensure all [Dependencies](#dependencies) are installed.
|
| `Ctrl-U` / `b` | 向上翻页 |
|
||||||
3. **Local Build:** Follow the [构建](#构建) instructions.
|
| `gg` | 跳转到顶部 |
|
||||||
4. **Code Style:** Adhere to the existing code style in `src/`.
|
| `G` | 跳转到底部 |
|
||||||
5. **Testing:** Currently, there are no automated tests. Please manually verify changes.
|
| `[数字]G` | 跳转到指定行(如 `50G`) |
|
||||||
6. **Contributing:** Submit Pull Requests for new features or bug fixes.
|
| `[数字]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
443
src/browser.cpp
Normal 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
28
src/browser.h
Normal 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
294
src/html_parser.cpp
Normal 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 = {
|
||||||
|
{" ", " "},
|
||||||
|
{"&", "&"},
|
||||||
|
{"<", "<"},
|
||||||
|
{">", ">"},
|
||||||
|
{""", "\""},
|
||||||
|
{"'", "'"},
|
||||||
|
{"'", "'"},
|
||||||
|
{"—", "\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]*?)</" + 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
57
src/html_parser.h
Normal 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
111
src/http_client.cpp
Normal 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
37
src/http_client.h
Normal 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
253
src/input_handler.cpp
Normal 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
67
src/input_handler.h
Normal 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;
|
||||||
|
};
|
||||||
54
src/main.cpp
54
src/main.cpp
|
|
@ -1,22 +1,46 @@
|
||||||
#include "tui_view.h"
|
#include "browser.h"
|
||||||
#include "calendar.h"
|
#include <iostream>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
int main() {
|
void print_usage(const char* prog_name) {
|
||||||
display_splash_screen(); // Display splash screen at startup
|
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 main(int argc, char* argv[]) {
|
||||||
int choice = run_portal_tui();
|
// 解析命令行参数
|
||||||
|
std::string initial_url;
|
||||||
|
|
||||||
switch (choice) {
|
if (argc > 1) {
|
||||||
case 0: { // Calendar
|
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
|
||||||
Calendar calendar;
|
print_usage(argv[0]);
|
||||||
calendar.run();
|
return 0;
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 1: { // Exit
|
|
||||||
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;
|
return 0;
|
||||||
|
|
|
||||||
300
src/text_renderer.cpp
Normal file
300
src/text_renderer.cpp
Normal 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
60
src/text_renderer.h
Normal 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();
|
||||||
Loading…
Reference in a new issue