mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-24 10:51:46 +00:00
Compare commits
7 commits
v2025.12.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| feefbfcf90 | |||
| 430e70d7b6 | |||
| 8ba659c8d2 | |||
| 815c479a90 | |||
| 860c8aaf56 | |||
| ea71b0ca02 | |||
| 354133b500 |
23 changed files with 1598 additions and 1285 deletions
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -1,6 +1,21 @@
|
|||
# Build artifacts
|
||||
build/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Executables
|
||||
tut
|
||||
nbtca_tui
|
||||
|
||||
# CMake artifacts
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
install_manifest.txt
|
||||
|
||||
# Editor/IDE
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
cmake_minimum_required(VERSION 3.15)
|
||||
project(TUT LANGUAGES CXX)
|
||||
project(TUT VERSION 1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# 优先使用带宽字符支持的 ncursesw
|
||||
# Prefer wide character support (ncursesw)
|
||||
set(CURSES_NEED_WIDE TRUE)
|
||||
|
||||
# macOS: Homebrew ncurses 路径
|
||||
# macOS: Use Homebrew ncurses if available
|
||||
if(APPLE)
|
||||
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/ncurses" ${CMAKE_PREFIX_PATH})
|
||||
endif()
|
||||
|
|
@ -15,6 +15,7 @@ endif()
|
|||
find_package(Curses REQUIRED)
|
||||
find_package(CURL REQUIRED)
|
||||
|
||||
# Executable
|
||||
add_executable(tut
|
||||
src/main.cpp
|
||||
src/http_client.cpp
|
||||
|
|
@ -27,4 +28,14 @@ add_executable(tut
|
|||
target_include_directories(tut PRIVATE ${CURSES_INCLUDE_DIR})
|
||||
target_link_libraries(tut PRIVATE ${CURSES_LIBRARIES} CURL::libcurl)
|
||||
|
||||
# Compiler warnings
|
||||
target_compile_options(tut PRIVATE
|
||||
-Wall -Wextra -Wpedantic
|
||||
$<$<CONFIG:RELEASE>:-O2>
|
||||
$<$<CONFIG:DEBUG>:-g -O0>
|
||||
)
|
||||
|
||||
# Installation
|
||||
install(TARGETS tut DESTINATION bin)
|
||||
|
||||
|
||||
|
|
|
|||
76
LINK_NAVIGATION.md
Normal file
76
LINK_NAVIGATION.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Quick Link Navigation Guide
|
||||
|
||||
The browser now supports vim-style quick navigation to links!
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Visual Link Numbers
|
||||
All links are displayed inline in the text with numbers like `[0]`, `[1]`, `[2]`, etc.
|
||||
- Links are shown with yellow color and underline
|
||||
- Active link (selected with Tab) has yellow background
|
||||
|
||||
### 2. Quick Navigation Methods
|
||||
|
||||
#### Method 1: Number + Enter
|
||||
Type a number and press Enter to jump to that link:
|
||||
```
|
||||
3<Enter> - Jump to link [3]
|
||||
10<Enter> - Jump to link [10]
|
||||
```
|
||||
|
||||
#### Method 2: 'f' command (follow)
|
||||
Press `f` followed by a number to immediately open that link:
|
||||
```
|
||||
f3 - Open link [3] directly
|
||||
f10 - Open link [10] directly
|
||||
```
|
||||
|
||||
Or type the number first:
|
||||
```
|
||||
3f - Open link [3] directly
|
||||
10f - Open link [10] directly
|
||||
```
|
||||
|
||||
#### Method 3: Traditional Tab navigation (still works)
|
||||
```
|
||||
Tab - Next link
|
||||
Shift-Tab/T - Previous link
|
||||
Enter - Follow current highlighted link
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Given a page with these links:
|
||||
- "Google[0]"
|
||||
- "GitHub[1]"
|
||||
- "Wikipedia[2]"
|
||||
|
||||
You can:
|
||||
- Press `1<Enter>` to select GitHub link
|
||||
- Press `f2` to immediately open Wikipedia
|
||||
- Press `Tab` twice then `Enter` to open Wikipedia
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Test with a real website
|
||||
./tut https://example.com
|
||||
|
||||
# View help
|
||||
./tut
|
||||
# Press ? for help
|
||||
```
|
||||
|
||||
## Key Bindings Summary
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `[N]<Enter>` | Jump to link N |
|
||||
| `f[N]` or `[N]f` | Open link N directly |
|
||||
| `Tab` | Next link |
|
||||
| `Shift-Tab` / `T` | Previous link |
|
||||
| `Enter` | Follow current link |
|
||||
| `h` | Go back |
|
||||
| `l` | Go forward |
|
||||
|
||||
All standard vim navigation keys (j/k, gg/G, /, n/N) still work as before!
|
||||
54
Makefile
54
Makefile
|
|
@ -1,44 +1,28 @@
|
|||
# Makefile for TUT Browser
|
||||
# Simple Makefile wrapper for CMake build system
|
||||
# Follows Unix convention: simple interface to underlying build
|
||||
|
||||
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)
|
||||
|
||||
# 可执行文件
|
||||
BUILD_DIR = build
|
||||
TARGET = tut
|
||||
|
||||
# 默认目标
|
||||
all: $(TARGET)
|
||||
.PHONY: all clean install test help
|
||||
|
||||
# 链接
|
||||
$(TARGET): $(OBJECTS)
|
||||
$(CXX) $(OBJECTS) $(LDFLAGS) -o $(TARGET)
|
||||
all:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@cd $(BUILD_DIR) && cmake .. && cmake --build .
|
||||
@cp $(BUILD_DIR)/$(TARGET) .
|
||||
|
||||
# 编译
|
||||
%.o: %.cpp
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
# 清理
|
||||
clean:
|
||||
rm -f $(OBJECTS) $(TARGET)
|
||||
@rm -rf $(BUILD_DIR) $(TARGET)
|
||||
|
||||
# 运行
|
||||
run: $(TARGET)
|
||||
./$(TARGET)
|
||||
install: all
|
||||
@install -m 755 $(TARGET) /usr/local/bin/
|
||||
|
||||
# 安装
|
||||
install: $(TARGET)
|
||||
install -m 755 $(TARGET) /usr/local/bin/
|
||||
test: all
|
||||
@./$(TARGET) https://example.com
|
||||
|
||||
.PHONY: all clean run install
|
||||
help:
|
||||
@echo "TUT Browser - Simple make targets"
|
||||
@echo " make - Build the browser"
|
||||
@echo " make clean - Remove build artifacts"
|
||||
@echo " make install - Install to /usr/local/bin"
|
||||
@echo " make test - Quick test run"
|
||||
|
|
|
|||
391
README.md
391
README.md
|
|
@ -1,263 +1,274 @@
|
|||
# TUT - Terminal User Interface Browser
|
||||
TUT(1) - Terminal User Interface Browser
|
||||
========================================
|
||||
|
||||
一个专注于阅读体验的终端网页浏览器,采用vim风格的键盘操作,让你在终端中舒适地浏览网页文本内容。
|
||||
NAME
|
||||
----
|
||||
tut - vim-style terminal web browser
|
||||
|
||||
## 特性
|
||||
SYNOPSIS
|
||||
--------
|
||||
**tut** [*URL*]
|
||||
|
||||
- 🚀 **纯文本浏览** - 专注于文本内容,无图片干扰
|
||||
- ⌨️ **完全vim风格操作** - hjkl移动、gg/G跳转、/搜索等
|
||||
- 📖 **报纸式排版** - 自适应宽度居中显示,优化阅读体验
|
||||
- 🔗 **链接导航** - TAB键切换链接,Enter跟随链接
|
||||
- 📜 **历史管理** - h/l快速前进后退
|
||||
- 🎨 **优雅配色** - 精心设计的终端配色方案
|
||||
- 🔍 **内容搜索** - 支持文本搜索和高亮
|
||||
**tut** **-h** | **--help**
|
||||
|
||||
## 依赖
|
||||
DESCRIPTION
|
||||
-----------
|
||||
**tut** is a text-mode web browser designed for comfortable reading in the
|
||||
terminal. It extracts and displays the textual content of web pages with a
|
||||
clean, centered layout optimized for reading, while providing vim-style
|
||||
keyboard navigation.
|
||||
|
||||
- CMake ≥ 3.15
|
||||
- C++17 编译器(macOS 建议 clang,Linux 建议 g++)
|
||||
- `ncurses` 或 `ncursesw`(支持宽字符)
|
||||
- `libcurl`(支持HTTPS)
|
||||
The browser does not execute JavaScript or display images. It is designed
|
||||
for reading static HTML content, documentation, and text-heavy websites.
|
||||
|
||||
### macOS (Homebrew) 安装依赖
|
||||
OPTIONS
|
||||
-------
|
||||
*URL*
|
||||
Open the specified URL on startup. If omitted, displays the built-in
|
||||
help page.
|
||||
|
||||
```bash
|
||||
brew install cmake ncurses curl
|
||||
```
|
||||
**-h**, **--help**
|
||||
Display usage information and exit.
|
||||
|
||||
### Linux (Ubuntu/Debian) 安装依赖
|
||||
KEYBINDINGS
|
||||
-----------
|
||||
**tut** uses vim-style keybindings throughout.
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install build-essential cmake libncursesw5-dev libcurl4-openssl-dev
|
||||
```
|
||||
### Navigation
|
||||
|
||||
### Linux (Fedora/RHEL) 安装依赖
|
||||
**j**, **Down**
|
||||
Scroll down one line.
|
||||
|
||||
```bash
|
||||
sudo dnf install cmake gcc-c++ ncurses-devel libcurl-devel
|
||||
```
|
||||
**k**, **Up**
|
||||
Scroll up one line.
|
||||
|
||||
## 构建
|
||||
**Ctrl-D**, **Space**
|
||||
Scroll down one page.
|
||||
|
||||
在项目根目录执行:
|
||||
**Ctrl-U**, **b**
|
||||
Scroll up one page.
|
||||
|
||||
```bash
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
```
|
||||
**gg**
|
||||
Jump to top of page.
|
||||
|
||||
生成的可执行文件为 `tut`。
|
||||
**G**
|
||||
Jump to bottom of page.
|
||||
|
||||
## 运行
|
||||
**[***count***]G**
|
||||
Jump to line *count* (e.g., **50G** jumps to line 50).
|
||||
|
||||
### 直接启动(显示帮助页面)
|
||||
**[***count***]j**, **[***count***]k**
|
||||
Scroll down/up *count* lines (e.g., **5j** scrolls down 5 lines).
|
||||
|
||||
```bash
|
||||
./tut
|
||||
```
|
||||
### Link Navigation
|
||||
|
||||
### 打开指定URL
|
||||
**Tab**
|
||||
Move to next link.
|
||||
|
||||
```bash
|
||||
./tut https://example.com
|
||||
./tut https://news.ycombinator.com
|
||||
```
|
||||
**Shift-Tab**, **T**
|
||||
Move to previous link.
|
||||
|
||||
### 显示使用帮助
|
||||
**Enter**
|
||||
Follow current link.
|
||||
|
||||
```bash
|
||||
./tut --help
|
||||
```
|
||||
**h**, **Left**
|
||||
Go back in history.
|
||||
|
||||
## 键盘操作
|
||||
**l**, **Right**
|
||||
Go forward in history.
|
||||
|
||||
### 导航
|
||||
### Search
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| `j` / `↓` | 向下滚动一行 |
|
||||
| `k` / `↑` | 向上滚动一行 |
|
||||
| `Ctrl-D` / `Space` | 向下翻页 |
|
||||
| `Ctrl-U` / `b` | 向上翻页 |
|
||||
| `gg` | 跳转到顶部 |
|
||||
| `G` | 跳转到底部 |
|
||||
| `[数字]G` | 跳转到指定行(如 `50G`) |
|
||||
| `[数字]j/k` | 向下/上滚动指定行数(如 `5j`) |
|
||||
**/**
|
||||
Start search. Enter search term and press **Enter**.
|
||||
|
||||
### 链接操作
|
||||
**n**
|
||||
Jump to next search match.
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| `Tab` | 下一个链接 |
|
||||
| `Shift-Tab` / `T` | 上一个链接 |
|
||||
| `Enter` | 跟随当前链接 |
|
||||
| `h` / `←` | 后退 |
|
||||
| `l` / `→` | 前进 |
|
||||
**N**
|
||||
Jump to previous search match.
|
||||
|
||||
### 搜索
|
||||
### Marks
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| `/` | 开始搜索 |
|
||||
| `n` | 下一个匹配 |
|
||||
| `N` | 上一个匹配 |
|
||||
**m***[a-z]*
|
||||
Set mark at current position (e.g., **ma**, **mb**).
|
||||
|
||||
### 命令模式
|
||||
**'***[a-z]*
|
||||
Jump to mark (e.g., **'a**, **'b**).
|
||||
|
||||
按 `:` 进入命令模式,支持以下命令:
|
||||
### Mouse
|
||||
|
||||
| 命令 | 功能 |
|
||||
|------|------|
|
||||
| `:q` / `:quit` | 退出浏览器 |
|
||||
| `:o URL` / `:open URL` | 打开指定URL |
|
||||
| `:r` / `:refresh` | 刷新当前页面 |
|
||||
| `:h` / `:help` | 显示帮助 |
|
||||
| `:[数字]` | 跳转到指定行 |
|
||||
**Left Click**
|
||||
Click on links to follow them directly.
|
||||
|
||||
### 其他
|
||||
**Scroll Wheel Up/Down**
|
||||
Scroll page up or down.
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| `r` | 刷新当前页面 |
|
||||
| `q` | 退出浏览器 |
|
||||
| `?` | 显示帮助 |
|
||||
| `ESC` | 取消命令/搜索输入 |
|
||||
Works with most modern terminal emulators that support mouse events.
|
||||
|
||||
## 使用示例
|
||||
### Commands
|
||||
|
||||
### 浏览新闻网站
|
||||
Press **:** to enter command mode. Available commands:
|
||||
|
||||
```bash
|
||||
./tut https://news.ycombinator.com
|
||||
```
|
||||
**:q**, **:quit**
|
||||
Quit the browser.
|
||||
|
||||
然后:
|
||||
- 使用 `j/k` 滚动浏览标题
|
||||
- 按 `Tab` 切换到感兴趣的链接
|
||||
- 按 `Enter` 打开链接
|
||||
- 按 `h` 返回上一页
|
||||
**:o** *URL*, **:open** *URL*
|
||||
Open *URL*.
|
||||
|
||||
### 阅读文档
|
||||
**:r**, **:refresh**
|
||||
Reload current page.
|
||||
|
||||
```bash
|
||||
./tut https://en.wikipedia.org/wiki/Unix
|
||||
```
|
||||
**:h**, **:help**
|
||||
Display help page.
|
||||
|
||||
然后:
|
||||
- 使用 `gg` 跳转到顶部
|
||||
- 使用 `/` 搜索关键词(如 `/history`)
|
||||
- 使用 `n/N` 在搜索结果间跳转
|
||||
- 使用 `Space` 翻页阅读
|
||||
**:***number*
|
||||
Jump to line *number*.
|
||||
|
||||
### 快速查看多个网页
|
||||
### Other
|
||||
|
||||
```bash
|
||||
./tut https://github.com
|
||||
```
|
||||
**r**
|
||||
Reload current page.
|
||||
|
||||
在浏览器内:
|
||||
- 浏览页面并点击链接
|
||||
- 使用 `:o https://news.ycombinator.com` 打开新URL
|
||||
- 使用 `h/l` 在历史中前进后退
|
||||
**q**
|
||||
Quit the browser.
|
||||
|
||||
## 设计理念
|
||||
**?**
|
||||
Display help page.
|
||||
|
||||
TUT 的设计目标是提供最佳的终端阅读体验:
|
||||
**ESC**
|
||||
Cancel command or search input.
|
||||
|
||||
1. **极简主义** - 只关注文本内容,摒弃图片、广告等干扰元素
|
||||
2. **高效操作** - 完全键盘驱动,无需触摸鼠标
|
||||
3. **优雅排版** - 自适应宽度,居中显示,类似专业阅读器
|
||||
4. **快速响应** - 轻量级实现,即开即用
|
||||
LIMITATIONS
|
||||
-----------
|
||||
**tut** does not execute JavaScript. Modern single-page applications (SPAs)
|
||||
built with React, Vue, Angular, or similar frameworks will not work correctly,
|
||||
as they require JavaScript to render content.
|
||||
|
||||
## 架构
|
||||
To determine if a site will work with **tut**, use:
|
||||
|
||||
```
|
||||
TUT
|
||||
├── http_client - HTTP/HTTPS 网页获取
|
||||
├── html_parser - HTML 解析和文本提取
|
||||
├── text_renderer - 文本渲染和排版引擎
|
||||
├── input_handler - Vim 风格输入处理
|
||||
└── browser - 浏览器主循环和状态管理
|
||||
```
|
||||
curl https://example.com | less
|
||||
|
||||
## 限制
|
||||
If you can see the actual content in the HTML source, the site will work.
|
||||
If you only see JavaScript code or empty div elements, it will not.
|
||||
|
||||
### JavaScript/SPA 网站
|
||||
**重要:** 这个浏览器**不支持JavaScript执行**。这意味着:
|
||||
Additionally:
|
||||
- No image display
|
||||
- No CSS layout support
|
||||
- No form submission
|
||||
- No cookie or session management
|
||||
- No AJAX or dynamic content loading
|
||||
|
||||
- ❌ **不支持**单页应用(SPA):React、Vue、Angular、Astro等构建的现代网站
|
||||
- ❌ **不支持**动态内容加载
|
||||
- ❌ **不支持**AJAX请求
|
||||
- ❌ **不支持**客户端路由
|
||||
EXAMPLES
|
||||
--------
|
||||
View the built-in help:
|
||||
|
||||
**如何判断网站是否支持:**
|
||||
1. 用 `curl` 命令查看HTML内容:`curl https://example.com | less`
|
||||
2. 如果能看到实际的文章内容,则支持;如果只有JavaScript代码或空白div,则不支持
|
||||
tut
|
||||
|
||||
**你的网站示例:**
|
||||
- ✅ **thinker.m1ng.space** - 静态HTML,完全支持,可以浏览文章列表并点击进入具体文章
|
||||
- ❌ **blog.m1ng.space** - 使用Astro SPA构建,内容由JavaScript动态渲染,无法正常显示
|
||||
Browse Hacker News:
|
||||
|
||||
**替代方案:**
|
||||
- 对于SPA网站,查找是否有RSS feed或API端点
|
||||
- 使用服务器端渲染(SSR)版本的URL(如果有)
|
||||
- 寻找使用传统HTML构建的同类网站
|
||||
tut https://news.ycombinator.com
|
||||
|
||||
### 其他限制
|
||||
Read Wikipedia:
|
||||
|
||||
- 不支持图片显示
|
||||
- 不支持复杂的CSS布局
|
||||
- 不支持表单提交
|
||||
- 不支持Cookie和会话管理
|
||||
- 专注于内容阅读,不适合需要交互的网页
|
||||
tut https://en.wikipedia.org/wiki/Unix_philosophy
|
||||
|
||||
## 开发指南
|
||||
Open a URL, search for "unix", and navigate:
|
||||
|
||||
### 代码风格
|
||||
tut https://example.com
|
||||
/unix<Enter>
|
||||
n
|
||||
|
||||
- 遵循 C++17 标准
|
||||
- 使用 RAII 进行资源管理
|
||||
- 使用 Pimpl 模式隐藏实现细节
|
||||
DEPENDENCIES
|
||||
------------
|
||||
- ncurses or ncursesw (for terminal UI)
|
||||
- libcurl (for HTTPS support)
|
||||
- CMake >= 3.15 (build time)
|
||||
- C++17 compiler (build time)
|
||||
|
||||
### 测试
|
||||
INSTALLATION
|
||||
------------
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
cd build
|
||||
./tut https://example.com
|
||||
```
|
||||
**macOS (Homebrew):**
|
||||
|
||||
### 贡献
|
||||
brew install cmake ncurses curl
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
|
||||
欢迎提交 Pull Request!请确保:
|
||||
**Linux (Debian/Ubuntu):**
|
||||
|
||||
1. 代码风格与现有代码一致
|
||||
2. 添加必要的注释
|
||||
3. 测试新功能
|
||||
4. 更新文档
|
||||
sudo apt-get install cmake libncursesw5-dev libcurl4-openssl-dev
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
|
||||
## 版本历史
|
||||
**Linux (Fedora/RHEL):**
|
||||
|
||||
- **v1.0.0** - 完全重构为终端浏览器
|
||||
- 添加 HTTP/HTTPS 支持
|
||||
- 实现 HTML 解析
|
||||
- 实现 Vim 风格操作
|
||||
- 报纸式排版引擎
|
||||
- 链接导航和搜索功能
|
||||
sudo dnf install cmake gcc-c++ ncurses-devel libcurl-devel
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
|
||||
- **v0.0.1** - 初始版本(ICS 日历查看器)
|
||||
### Using Makefile
|
||||
|
||||
## 许可证
|
||||
make
|
||||
sudo make install
|
||||
|
||||
MIT License
|
||||
FILES
|
||||
-----
|
||||
No configuration files are used. The browser is stateless and does not
|
||||
store history, cookies, or cache.
|
||||
|
||||
## 致谢
|
||||
ENVIRONMENT
|
||||
-----------
|
||||
**tut** respects the following environment variables:
|
||||
|
||||
灵感来源于:
|
||||
- `lynx` - 经典的终端浏览器
|
||||
- `w3m` - 另一个优秀的终端浏览器
|
||||
- `vim` - 最好的文本编辑器
|
||||
- `btop` - 美观的TUI设计
|
||||
**TERM**
|
||||
Terminal type. Must support basic cursor movement and colors.
|
||||
|
||||
**LINES**, **COLUMNS**
|
||||
Terminal size. Automatically detected via ncurses.
|
||||
|
||||
EXIT STATUS
|
||||
-----------
|
||||
**0**
|
||||
Success.
|
||||
|
||||
**1**
|
||||
Error occurred (e.g., invalid URL, network error, ncurses initialization
|
||||
failure).
|
||||
|
||||
PHILOSOPHY
|
||||
----------
|
||||
**tut** follows the Unix philosophy:
|
||||
|
||||
1. Do one thing well: display and navigate text content from the web.
|
||||
2. Work with other programs: output can be piped, URLs can come from stdin.
|
||||
3. Simple and minimal: no configuration files, no persistent state.
|
||||
4. Text-focused: everything is text, processed and displayed cleanly.
|
||||
|
||||
The design emphasizes keyboard efficiency, clean output, and staying out
|
||||
of your way.
|
||||
|
||||
SEE ALSO
|
||||
--------
|
||||
lynx(1), w3m(1), curl(1), vim(1)
|
||||
|
||||
BUGS
|
||||
----
|
||||
Report bugs at: https://github.com/m1ngsama/TUT/issues
|
||||
|
||||
AUTHORS
|
||||
-------
|
||||
m1ngsama <contact@m1ng.space>
|
||||
|
||||
Inspired by lynx, w3m, and vim.
|
||||
|
||||
LICENSE
|
||||
-------
|
||||
MIT License. See LICENSE file for details.
|
||||
|
|
|
|||
237
src/browser.cpp
237
src/browser.cpp
|
|
@ -3,6 +3,7 @@
|
|||
#include <clocale>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
|
||||
class Browser::Impl {
|
||||
public:
|
||||
|
|
@ -17,17 +18,18 @@ public:
|
|||
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; // 匹配行号
|
||||
std::vector<int> search_results;
|
||||
|
||||
// 屏幕尺寸
|
||||
int screen_height = 0;
|
||||
int screen_width = 0;
|
||||
|
||||
// Marks support (vim-style position bookmarks)
|
||||
std::map<char, int> marks;
|
||||
|
||||
void init_screen() {
|
||||
setlocale(LC_ALL, "");
|
||||
initscr();
|
||||
|
|
@ -36,7 +38,12 @@ public:
|
|||
noecho();
|
||||
keypad(stdscr, TRUE);
|
||||
curs_set(0);
|
||||
timeout(0); // non-blocking
|
||||
timeout(0);
|
||||
|
||||
// Enable mouse support
|
||||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL);
|
||||
mouseinterval(0); // No click delay
|
||||
|
||||
getmaxyx(stdscr, screen_height, screen_width);
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +82,53 @@ public:
|
|||
return true;
|
||||
}
|
||||
|
||||
void handle_mouse(MEVENT& event) {
|
||||
int visible_lines = screen_height - 2;
|
||||
|
||||
// Mouse wheel up (scroll up)
|
||||
if (event.bstate & BUTTON4_PRESSED) {
|
||||
scroll_pos = std::max(0, scroll_pos - 3);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mouse wheel down (scroll down)
|
||||
if (event.bstate & BUTTON5_PRESSED) {
|
||||
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - visible_lines);
|
||||
scroll_pos = std::min(max_scroll, scroll_pos + 3);
|
||||
return;
|
||||
}
|
||||
|
||||
// Left click
|
||||
if (event.bstate & BUTTON1_CLICKED) {
|
||||
int clicked_line = event.y;
|
||||
int clicked_col = event.x;
|
||||
|
||||
// Check if clicked on a link
|
||||
if (clicked_line >= 0 && clicked_line < visible_lines) {
|
||||
int doc_line_idx = scroll_pos + clicked_line;
|
||||
if (doc_line_idx < static_cast<int>(rendered_lines.size())) {
|
||||
const auto& line = rendered_lines[doc_line_idx];
|
||||
|
||||
// Check if click is within any link range
|
||||
for (const auto& [start, end] : line.link_ranges) {
|
||||
if (clicked_col >= static_cast<int>(start) && clicked_col < static_cast<int>(end)) {
|
||||
// Clicked on a link!
|
||||
if (line.link_index >= 0 && line.link_index < static_cast<int>(current_doc.links.size())) {
|
||||
load_page(current_doc.links[line.link_index].url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If clicked on a line with a link but not on the link text itself
|
||||
if (line.is_link && line.link_index >= 0) {
|
||||
current_link = line.link_index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void draw_status_bar() {
|
||||
attron(COLOR_PAIR(COLOR_STATUS_BAR));
|
||||
mvprintw(screen_height - 1, 0, "%s", std::string(screen_width, ' ').c_str());
|
||||
|
|
@ -140,34 +194,93 @@ public:
|
|||
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);
|
||||
// Check if this line contains the active link
|
||||
bool has_active_link = (line.is_link && line.link_index == current_link);
|
||||
|
||||
// Check if this line is in search results
|
||||
bool in_search_results = !search_term.empty() &&
|
||||
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end();
|
||||
|
||||
// If line has link ranges, render character by character with proper highlighting
|
||||
if (!line.link_ranges.empty()) {
|
||||
int col = 0;
|
||||
for (size_t char_idx = 0; char_idx < line.text.length(); ++char_idx) {
|
||||
// Check if this character is within any link range
|
||||
bool is_in_link = false;
|
||||
|
||||
for (const auto& [start, end] : line.link_ranges) {
|
||||
if (char_idx >= start && char_idx < end) {
|
||||
is_in_link = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply appropriate color
|
||||
if (is_in_link && has_active_link) {
|
||||
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||
} else if (is_in_link) {
|
||||
attron(COLOR_PAIR(COLOR_LINK));
|
||||
attron(A_UNDERLINE);
|
||||
} else {
|
||||
attron(COLOR_PAIR(line.color_pair));
|
||||
if (line.is_bold) {
|
||||
attron(A_BOLD);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_search_results) {
|
||||
attron(A_REVERSE);
|
||||
}
|
||||
|
||||
mvaddch(i, col, line.text[char_idx]);
|
||||
|
||||
if (in_search_results) {
|
||||
attroff(A_REVERSE);
|
||||
}
|
||||
|
||||
if (is_in_link && has_active_link) {
|
||||
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||
} else if (is_in_link) {
|
||||
attroff(A_UNDERLINE);
|
||||
attroff(COLOR_PAIR(COLOR_LINK));
|
||||
} else {
|
||||
if (line.is_bold) {
|
||||
attroff(A_BOLD);
|
||||
}
|
||||
attroff(COLOR_PAIR(line.color_pair));
|
||||
}
|
||||
|
||||
col++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!search_term.empty() &&
|
||||
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
|
||||
attron(A_REVERSE);
|
||||
}
|
||||
|
||||
mvprintw(i, 0, "%s", line.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);
|
||||
// No inline links, render normally
|
||||
if (has_active_link) {
|
||||
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||
} else {
|
||||
attron(COLOR_PAIR(line.color_pair));
|
||||
if (line.is_bold) {
|
||||
attron(A_BOLD);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_search_results) {
|
||||
attron(A_REVERSE);
|
||||
}
|
||||
|
||||
mvprintw(i, 0, "%s", line.text.c_str());
|
||||
|
||||
if (in_search_results) {
|
||||
attroff(A_REVERSE);
|
||||
}
|
||||
|
||||
if (has_active_link) {
|
||||
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||
} else {
|
||||
if (line.is_bold) {
|
||||
attroff(A_BOLD);
|
||||
}
|
||||
attroff(COLOR_PAIR(line.color_pair));
|
||||
}
|
||||
attroff(COLOR_PAIR(line.color_pair));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +344,26 @@ public:
|
|||
}
|
||||
break;
|
||||
|
||||
case Action::GOTO_LINK:
|
||||
// Jump to specific link by number
|
||||
if (result.number >= 0 && result.number < static_cast<int>(current_doc.links.size())) {
|
||||
current_link = result.number;
|
||||
scroll_to_link(current_link);
|
||||
status_message = "Link " + std::to_string(result.number);
|
||||
} else {
|
||||
status_message = "Invalid link number: " + std::to_string(result.number);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::FOLLOW_LINK_NUM:
|
||||
// Follow specific link by number directly
|
||||
if (result.number >= 0 && result.number < static_cast<int>(current_doc.links.size())) {
|
||||
load_page(current_doc.links[result.number].url);
|
||||
} else {
|
||||
status_message = "Invalid link number: " + std::to_string(result.number);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::GO_BACK:
|
||||
if (history_pos > 0) {
|
||||
history_pos--;
|
||||
|
|
@ -301,6 +434,27 @@ public:
|
|||
}
|
||||
break;
|
||||
|
||||
case Action::SET_MARK:
|
||||
if (!result.text.empty()) {
|
||||
char mark = result.text[0];
|
||||
marks[mark] = scroll_pos;
|
||||
status_message = "Mark '" + std::string(1, mark) + "' set at line " + std::to_string(scroll_pos);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::GOTO_MARK:
|
||||
if (!result.text.empty()) {
|
||||
char mark = result.text[0];
|
||||
auto it = marks.find(mark);
|
||||
if (it != marks.end()) {
|
||||
scroll_pos = std::min(it->second, max_scroll);
|
||||
status_message = "Jumped to mark '" + std::string(1, mark) + "'";
|
||||
} else {
|
||||
status_message = "Mark '" + std::string(1, mark) + "' not set";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::HELP:
|
||||
show_help();
|
||||
break;
|
||||
|
|
@ -311,7 +465,6 @@ public:
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
@ -335,9 +488,12 @@ public:
|
|||
<< "<p>G: Go to bottom</p>"
|
||||
<< "<p>[number]G: Go to line number</p>"
|
||||
<< "<h2>Links</h2>"
|
||||
<< "<p>Links are displayed inline with numbers like [0], [1], etc.</p>"
|
||||
<< "<p>Tab: Next link</p>"
|
||||
<< "<p>Shift-Tab or T: Previous link</p>"
|
||||
<< "<p>Enter: Follow link</p>"
|
||||
<< "<p>Enter: Follow current link</p>"
|
||||
<< "<p>[number]Enter: Jump to link number N</p>"
|
||||
<< "<p>f[number]: Follow link number N directly</p>"
|
||||
<< "<p>h: Go back</p>"
|
||||
<< "<p>l: Go forward</p>"
|
||||
<< "<h2>Search</h2>"
|
||||
|
|
@ -350,10 +506,18 @@ public:
|
|||
<< "<p>:r or :refresh - Refresh page</p>"
|
||||
<< "<p>:h or :help - Show this help</p>"
|
||||
<< "<p>:[number] - Go to line number</p>"
|
||||
<< "<h2>Marks</h2>"
|
||||
<< "<p>m[a-z]: Set mark at letter (e.g., ma, mb)</p>"
|
||||
<< "<p>'[a-z]: Jump to mark (e.g., 'a, 'b)</p>"
|
||||
<< "<h2>Mouse Support</h2>"
|
||||
<< "<p>Click on links to follow them</p>"
|
||||
<< "<p>Scroll wheel to scroll up/down</p>"
|
||||
<< "<p>Works with most terminal emulators</p>"
|
||||
<< "<h2>Other</h2>"
|
||||
<< "<p>r: Refresh current page</p>"
|
||||
<< "<p>q: Quit browser</p>"
|
||||
<< "<p>?: Show help</p>"
|
||||
<< "<p>ESC: Cancel current mode</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 "
|
||||
|
|
@ -406,7 +570,16 @@ void Browser::run(const std::string& initial_url) {
|
|||
|
||||
int ch = getch();
|
||||
if (ch == ERR) {
|
||||
napms(50); // 50ms sleep
|
||||
napms(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle mouse events
|
||||
if (ch == KEY_MOUSE) {
|
||||
MEVENT event;
|
||||
if (getmouse(&event) == OK) {
|
||||
pImpl->handle_mouse(event);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,8 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
#include "calendar.h"
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
#include <algorithm>
|
||||
|
||||
#include "ics_fetcher.h"
|
||||
#include "ics_parser.h"
|
||||
#include "tui_view.h"
|
||||
|
||||
void Calendar::run() {
|
||||
try {
|
||||
// 1. 获取 ICS 文本
|
||||
std::string url = "https://ical.nbtca.space/nbtca.ics";
|
||||
std::string icsData = fetch_ics(url);
|
||||
|
||||
// 2. 解析事件
|
||||
auto allEvents = parse_ics(icsData);
|
||||
|
||||
// 3. 过滤未来一个月的事件(支持简单的每周 RRULE)
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto oneMonthLater = now + std::chrono::hours(24 * 30);
|
||||
|
||||
std::vector<IcsEvent> upcoming;
|
||||
for (const auto &ev : allEvents) {
|
||||
// 简单处理:如果包含 FREQ=WEEKLY,则视为每周重复事件
|
||||
if (ev.rrule.find("FREQ=WEEKLY") != std::string::npos) {
|
||||
// 从基准时间往后每 7 天生成一次,直到超过 oneMonthLater 或 UNTIL
|
||||
auto curStart = ev.start;
|
||||
auto curEnd = ev.end;
|
||||
|
||||
// 如果有 UNTIL,则作为上界
|
||||
auto upper = oneMonthLater;
|
||||
if (ev.until && *ev.until < upper) {
|
||||
upper = *ev.until;
|
||||
}
|
||||
|
||||
// 为避免意外死循环,限制最多展开约 10 年(~520 周)
|
||||
const int maxIterations = 520;
|
||||
int iter = 0;
|
||||
|
||||
while (curStart <= upper && iter < maxIterations) {
|
||||
if (curStart >= now && curStart <= upper) {
|
||||
IcsEvent occ = ev;
|
||||
occ.start = curStart;
|
||||
occ.end = curEnd;
|
||||
upcoming.push_back(std::move(occ));
|
||||
}
|
||||
curStart += std::chrono::hours(24 * 7);
|
||||
curEnd += std::chrono::hours(24 * 7);
|
||||
++iter;
|
||||
}
|
||||
} else {
|
||||
// 非 RRULE 事件:直接按时间窗口筛选
|
||||
if (ev.start >= now && ev.start <= oneMonthLater) {
|
||||
upcoming.push_back(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保展示按时间排序
|
||||
std::sort(upcoming.begin(), upcoming.end(),
|
||||
[](const IcsEvent &a, const IcsEvent &b) { return a.start < b.start; });
|
||||
|
||||
// 4. 启动 TUI 展示(只展示未来一个月的活动)
|
||||
run_tui(upcoming);
|
||||
} catch (const std::exception &ex) {
|
||||
std::cerr << "错误: " << ex.what() << std::endl;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
class Calendar {
|
||||
public:
|
||||
void run();
|
||||
};
|
||||
|
|
@ -3,13 +3,14 @@
|
|||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <functional>
|
||||
|
||||
class HtmlParser::Impl {
|
||||
public:
|
||||
bool keep_code_blocks = true;
|
||||
bool keep_lists = true;
|
||||
|
||||
// 简单的HTML标签清理
|
||||
// Remove HTML tags
|
||||
std::string remove_tags(const std::string& html) {
|
||||
std::string result;
|
||||
bool in_tag = false;
|
||||
|
|
@ -25,12 +26,9 @@ public:
|
|||
return result;
|
||||
}
|
||||
|
||||
// 解码HTML实体
|
||||
// Decode HTML entities (named and numeric)
|
||||
std::string decode_html_entities(const std::string& text) {
|
||||
std::string result = text;
|
||||
|
||||
// 常见HTML实体
|
||||
const std::vector<std::pair<std::string, std::string>> entities = {
|
||||
static const std::vector<std::pair<std::string, std::string>> named_entities = {
|
||||
{" ", " "},
|
||||
{"&", "&"},
|
||||
{"<", "<"},
|
||||
|
|
@ -47,7 +45,10 @@ public:
|
|||
{"’", "\u2019"}
|
||||
};
|
||||
|
||||
for (const auto& [entity, replacement] : entities) {
|
||||
std::string result = text;
|
||||
|
||||
// Replace named entities
|
||||
for (const auto& [entity, replacement] : named_entities) {
|
||||
size_t pos = 0;
|
||||
while ((pos = result.find(entity, pos)) != std::string::npos) {
|
||||
result.replace(pos, entity.length(), replacement);
|
||||
|
|
@ -55,10 +56,51 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
// Replace numeric entities ({ and «)
|
||||
std::regex numeric_entity(R"(&#(\d+);|&#x([0-9a-fA-F]+);)");
|
||||
std::smatch match;
|
||||
std::string::const_iterator search_start(result.cbegin());
|
||||
std::string temp;
|
||||
size_t last_pos = 0;
|
||||
|
||||
while (std::regex_search(search_start, result.cend(), match, numeric_entity)) {
|
||||
size_t match_pos = match.position(0) + (search_start - result.cbegin());
|
||||
temp += result.substr(last_pos, match_pos - last_pos);
|
||||
|
||||
int code_point = 0;
|
||||
if (match[1].length() > 0) {
|
||||
// Decimal entity
|
||||
code_point = std::stoi(match[1].str());
|
||||
} else if (match[2].length() > 0) {
|
||||
// Hex entity
|
||||
code_point = std::stoi(match[2].str(), nullptr, 16);
|
||||
}
|
||||
|
||||
// Convert to UTF-8 (simplified - only handles ASCII and basic Unicode)
|
||||
if (code_point < 128) {
|
||||
temp += static_cast<char>(code_point);
|
||||
} else if (code_point < 0x800) {
|
||||
temp += static_cast<char>(0xC0 | (code_point >> 6));
|
||||
temp += static_cast<char>(0x80 | (code_point & 0x3F));
|
||||
} else if (code_point < 0x10000) {
|
||||
temp += static_cast<char>(0xE0 | (code_point >> 12));
|
||||
temp += static_cast<char>(0x80 | ((code_point >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code_point & 0x3F));
|
||||
}
|
||||
|
||||
last_pos = match_pos + match.length(0);
|
||||
search_start = result.cbegin() + last_pos;
|
||||
}
|
||||
|
||||
if (!temp.empty()) {
|
||||
temp += result.substr(last_pos);
|
||||
result = temp;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 提取标签内容
|
||||
// Extract content between HTML tags
|
||||
std::string extract_tag_content(const std::string& html, const std::string& tag) {
|
||||
std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">",
|
||||
std::regex::icase);
|
||||
|
|
@ -69,7 +111,7 @@ public:
|
|||
return "";
|
||||
}
|
||||
|
||||
// 提取所有匹配的标签
|
||||
// Extract all matching tags
|
||||
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 + ">",
|
||||
|
|
@ -86,7 +128,7 @@ public:
|
|||
return results;
|
||||
}
|
||||
|
||||
// 提取链接
|
||||
// Extract links from HTML
|
||||
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>)",
|
||||
|
|
@ -139,7 +181,71 @@ public:
|
|||
return links;
|
||||
}
|
||||
|
||||
// 清理空白字符
|
||||
// 从HTML中提取文本,同时保留内联链接位置信息
|
||||
std::string extract_text_with_links(const std::string& html,
|
||||
std::vector<Link>& all_links,
|
||||
std::vector<InlineLink>& inline_links) {
|
||||
std::string result;
|
||||
std::regex link_regex(R"(<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)</a>)",
|
||||
std::regex::icase);
|
||||
|
||||
size_t last_pos = 0;
|
||||
auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex);
|
||||
auto end = std::sregex_iterator();
|
||||
|
||||
// 处理所有链接
|
||||
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||
std::smatch match = *i;
|
||||
|
||||
// 添加链接前的文本
|
||||
std::string before_link = html.substr(last_pos, match.position() - last_pos);
|
||||
std::string before_text = decode_html_entities(remove_tags(before_link));
|
||||
result += before_text;
|
||||
|
||||
// 提取链接信息
|
||||
std::string link_url = match[1].str();
|
||||
std::string link_text = decode_html_entities(remove_tags(match[2].str()));
|
||||
|
||||
// 跳过空链接或锚点链接
|
||||
if (link_url.empty() || link_url[0] == '#' || link_text.empty()) {
|
||||
result += link_text;
|
||||
last_pos = match.position() + match.length();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 找到这个链接在全局链接列表中的索引
|
||||
int link_index = -1;
|
||||
for (size_t j = 0; j < all_links.size(); ++j) {
|
||||
if (all_links[j].url == link_url && all_links[j].text == link_text) {
|
||||
link_index = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (link_index != -1) {
|
||||
// 记录内联链接位置
|
||||
InlineLink inline_link;
|
||||
inline_link.text = link_text;
|
||||
inline_link.url = link_url;
|
||||
inline_link.start_pos = result.length();
|
||||
inline_link.end_pos = result.length() + link_text.length();
|
||||
inline_link.link_index = link_index;
|
||||
inline_links.push_back(inline_link);
|
||||
}
|
||||
|
||||
// 添加链接文本
|
||||
result += link_text;
|
||||
last_pos = match.position() + match.length();
|
||||
}
|
||||
|
||||
// 添加最后一段文本
|
||||
std::string remaining = html.substr(last_pos);
|
||||
result += decode_html_entities(remove_tags(remaining));
|
||||
|
||||
return trim(result);
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
std::string trim(const std::string& str) {
|
||||
auto start = str.begin();
|
||||
while (start != str.end() && std::isspace(*start)) {
|
||||
|
|
@ -170,6 +276,133 @@ public:
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract images
|
||||
std::vector<Image> extract_images(const std::string& html) {
|
||||
std::vector<Image> images;
|
||||
std::regex img_regex(R"(<img[^>]*src\s*=\s*["']([^"']*)["'][^>]*>)", std::regex::icase);
|
||||
|
||||
auto begin = std::sregex_iterator(html.begin(), html.end(), img_regex);
|
||||
auto end = std::sregex_iterator();
|
||||
|
||||
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||
std::smatch match = *i;
|
||||
Image img;
|
||||
img.src = match[1].str();
|
||||
img.width = -1;
|
||||
img.height = -1;
|
||||
|
||||
// Extract alt text
|
||||
std::string img_tag = match[0].str();
|
||||
std::regex alt_regex(R"(alt\s*=\s*["']([^"']*)["'])", std::regex::icase);
|
||||
std::smatch alt_match;
|
||||
if (std::regex_search(img_tag, alt_match, alt_regex)) {
|
||||
img.alt = decode_html_entities(alt_match[1].str());
|
||||
}
|
||||
|
||||
// Extract width
|
||||
std::regex width_regex(R"(width\s*=\s*["']?(\d+)["']?)", std::regex::icase);
|
||||
std::smatch width_match;
|
||||
if (std::regex_search(img_tag, width_match, width_regex)) {
|
||||
try {
|
||||
img.width = std::stoi(width_match[1].str());
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
// Extract height
|
||||
std::regex height_regex(R"(height\s*=\s*["']?(\d+)["']?)", std::regex::icase);
|
||||
std::smatch height_match;
|
||||
if (std::regex_search(img_tag, height_match, height_regex)) {
|
||||
try {
|
||||
img.height = std::stoi(height_match[1].str());
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
images.push_back(img);
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
// Extract tables
|
||||
std::vector<Table> extract_tables(const std::string& html, std::vector<Link>& all_links) {
|
||||
std::vector<Table> tables;
|
||||
auto table_contents = extract_all_tags(html, "table");
|
||||
|
||||
for (const auto& table_html : table_contents) {
|
||||
Table table;
|
||||
table.has_header = false;
|
||||
|
||||
// Extract rows
|
||||
auto thead_html = extract_tag_content(table_html, "thead");
|
||||
auto tbody_html = extract_tag_content(table_html, "tbody");
|
||||
|
||||
// If no thead/tbody, just get all rows
|
||||
std::vector<std::string> row_htmls;
|
||||
if (!thead_html.empty() || !tbody_html.empty()) {
|
||||
if (!thead_html.empty()) {
|
||||
auto header_rows = extract_all_tags(thead_html, "tr");
|
||||
row_htmls.insert(row_htmls.end(), header_rows.begin(), header_rows.end());
|
||||
table.has_header = !header_rows.empty();
|
||||
}
|
||||
if (!tbody_html.empty()) {
|
||||
auto body_rows = extract_all_tags(tbody_html, "tr");
|
||||
row_htmls.insert(row_htmls.end(), body_rows.begin(), body_rows.end());
|
||||
}
|
||||
} else {
|
||||
row_htmls = extract_all_tags(table_html, "tr");
|
||||
// Check if first row has <th> tags
|
||||
if (!row_htmls.empty()) {
|
||||
table.has_header = (row_htmls[0].find("<th") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
bool is_first_row = true;
|
||||
for (const auto& row_html : row_htmls) {
|
||||
TableRow row;
|
||||
|
||||
// Extract cells (both th and td)
|
||||
auto th_cells = extract_all_tags(row_html, "th");
|
||||
auto td_cells = extract_all_tags(row_html, "td");
|
||||
|
||||
// Process th cells (headers)
|
||||
for (const auto& cell_html : th_cells) {
|
||||
TableCell cell;
|
||||
std::vector<InlineLink> inline_links;
|
||||
cell.text = extract_text_with_links(cell_html, all_links, inline_links);
|
||||
cell.inline_links = inline_links;
|
||||
cell.is_header = true;
|
||||
cell.colspan = 1;
|
||||
cell.rowspan = 1;
|
||||
row.cells.push_back(cell);
|
||||
}
|
||||
|
||||
// Process td cells (data)
|
||||
for (const auto& cell_html : td_cells) {
|
||||
TableCell cell;
|
||||
std::vector<InlineLink> inline_links;
|
||||
cell.text = extract_text_with_links(cell_html, all_links, inline_links);
|
||||
cell.inline_links = inline_links;
|
||||
cell.is_header = is_first_row && table.has_header && th_cells.empty();
|
||||
cell.colspan = 1;
|
||||
cell.rowspan = 1;
|
||||
row.cells.push_back(cell);
|
||||
}
|
||||
|
||||
if (!row.cells.empty()) {
|
||||
table.rows.push_back(row);
|
||||
}
|
||||
|
||||
is_first_row = false;
|
||||
}
|
||||
|
||||
if (!table.rows.empty()) {
|
||||
tables.push_back(table);
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
};
|
||||
|
||||
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
|
@ -207,44 +440,130 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
|||
// 提取链接
|
||||
doc.links = pImpl->extract_links(main_content, base_url);
|
||||
|
||||
// Extract and add images
|
||||
auto images = pImpl->extract_images(main_content);
|
||||
for (const auto& img : images) {
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::IMAGE;
|
||||
elem.image_data = img;
|
||||
elem.level = 0;
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
|
||||
// Extract and add tables
|
||||
auto tables = pImpl->extract_tables(main_content, doc.links);
|
||||
for (const auto& tbl : tables) {
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::TABLE;
|
||||
elem.table_data = tbl;
|
||||
elem.level = 0;
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
|
||||
// 解析标题
|
||||
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;
|
||||
ElementType type;
|
||||
if (level == 1) type = ElementType::HEADING1;
|
||||
else if (level == 2) type = ElementType::HEADING2;
|
||||
else if (level == 3) type = ElementType::HEADING3;
|
||||
else if (level == 4) type = ElementType::HEADING4;
|
||||
else if (level == 5) type = ElementType::HEADING5;
|
||||
else type = ElementType::HEADING6;
|
||||
|
||||
elem.type = type;
|
||||
elem.text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(heading)));
|
||||
elem.level = level;
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
if (!elem.text.empty()) {
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析列表项
|
||||
// 解析列表项 - with nesting support
|
||||
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);
|
||||
// Extract both <ul> and <ol> lists
|
||||
auto ul_lists = pImpl->extract_all_tags(main_content, "ul");
|
||||
auto ol_lists = pImpl->extract_all_tags(main_content, "ol");
|
||||
|
||||
// Helper to parse a list recursively
|
||||
std::function<void(const std::string&, bool, int)> parse_list;
|
||||
parse_list = [&](const std::string& list_html, bool is_ordered, int nesting) {
|
||||
auto list_items = pImpl->extract_all_tags(list_html, "li");
|
||||
int item_number = 1;
|
||||
|
||||
for (const auto& item_html : list_items) {
|
||||
// Check if this item contains nested lists
|
||||
bool has_nested_ul = item_html.find("<ul") != std::string::npos;
|
||||
bool has_nested_ol = item_html.find("<ol") != std::string::npos;
|
||||
|
||||
// Extract text without nested lists
|
||||
std::string item_text = item_html;
|
||||
if (has_nested_ul || has_nested_ol) {
|
||||
// Remove nested lists from text
|
||||
item_text = std::regex_replace(item_text,
|
||||
std::regex("<ul[^>]*>[\\s\\S]*?</ul>", std::regex::icase), "");
|
||||
item_text = std::regex_replace(item_text,
|
||||
std::regex("<ol[^>]*>[\\s\\S]*?</ol>", std::regex::icase), "");
|
||||
}
|
||||
|
||||
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(item_text)));
|
||||
if (!text.empty() && text.length() > 1) {
|
||||
ContentElement elem;
|
||||
elem.type = is_ordered ? ElementType::ORDERED_LIST_ITEM : ElementType::LIST_ITEM;
|
||||
elem.text = text;
|
||||
elem.level = 0;
|
||||
elem.list_number = item_number++;
|
||||
elem.nesting_level = nesting;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
|
||||
// Parse nested lists
|
||||
if (has_nested_ul) {
|
||||
auto nested_uls = pImpl->extract_all_tags(item_html, "ul");
|
||||
for (const auto& nested_ul : nested_uls) {
|
||||
parse_list(nested_ul, false, nesting + 1);
|
||||
}
|
||||
}
|
||||
if (has_nested_ol) {
|
||||
auto nested_ols = pImpl->extract_all_tags(item_html, "ol");
|
||||
for (const auto& nested_ol : nested_ols) {
|
||||
parse_list(nested_ol, true, nesting + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse unordered lists
|
||||
for (const auto& ul : ul_lists) {
|
||||
parse_list(ul, false, 0);
|
||||
}
|
||||
|
||||
// Parse ordered lists
|
||||
for (const auto& ol : ol_lists) {
|
||||
parse_list(ol, true, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析段落
|
||||
// 解析段落 (保留内联链接)
|
||||
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;
|
||||
ContentElement elem;
|
||||
elem.type = ElementType::PARAGRAPH;
|
||||
elem.text = pImpl->extract_text_with_links(para, doc.links, elem.inline_links);
|
||||
elem.level = 0;
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
if (!elem.text.empty() && elem.text.length() > 1) {
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +577,9 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
|||
ContentElement elem;
|
||||
elem.type = ElementType::PARAGRAPH;
|
||||
elem.text = text;
|
||||
elem.level = 0;
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
|
|
@ -276,6 +598,9 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
|||
ContentElement elem;
|
||||
elem.type = ElementType::PARAGRAPH;
|
||||
elem.text = line;
|
||||
elem.level = 0;
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
doc.elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,26 +9,95 @@ enum class ElementType {
|
|||
HEADING1,
|
||||
HEADING2,
|
||||
HEADING3,
|
||||
HEADING4,
|
||||
HEADING5,
|
||||
HEADING6,
|
||||
PARAGRAPH,
|
||||
LINK,
|
||||
LIST_ITEM,
|
||||
ORDERED_LIST_ITEM,
|
||||
BLOCKQUOTE,
|
||||
CODE_BLOCK,
|
||||
HORIZONTAL_RULE,
|
||||
LINE_BREAK
|
||||
LINE_BREAK,
|
||||
TABLE,
|
||||
IMAGE,
|
||||
FORM,
|
||||
SECTION_START,
|
||||
SECTION_END,
|
||||
NAV_START,
|
||||
NAV_END,
|
||||
HEADER_START,
|
||||
HEADER_END,
|
||||
ASIDE_START,
|
||||
ASIDE_END
|
||||
};
|
||||
|
||||
struct Link {
|
||||
std::string text;
|
||||
std::string url;
|
||||
int position; // 在文档中的位置(用于TAB导航)
|
||||
int position;
|
||||
};
|
||||
|
||||
struct InlineLink {
|
||||
std::string text;
|
||||
std::string url;
|
||||
size_t start_pos; // Position in the text where link starts
|
||||
size_t end_pos; // Position in the text where link ends
|
||||
int link_index; // Index in the document's links array
|
||||
};
|
||||
|
||||
struct TableCell {
|
||||
std::string text;
|
||||
std::vector<InlineLink> inline_links;
|
||||
bool is_header;
|
||||
int colspan;
|
||||
int rowspan;
|
||||
};
|
||||
|
||||
struct TableRow {
|
||||
std::vector<TableCell> cells;
|
||||
};
|
||||
|
||||
struct Table {
|
||||
std::vector<TableRow> rows;
|
||||
bool has_header;
|
||||
};
|
||||
|
||||
struct Image {
|
||||
std::string src;
|
||||
std::string alt;
|
||||
int width; // -1 if not specified
|
||||
int height; // -1 if not specified
|
||||
};
|
||||
|
||||
struct FormField {
|
||||
enum Type { TEXT, PASSWORD, CHECKBOX, RADIO, SUBMIT, BUTTON } type;
|
||||
std::string name;
|
||||
std::string value;
|
||||
std::string placeholder;
|
||||
bool checked;
|
||||
};
|
||||
|
||||
struct Form {
|
||||
std::string action;
|
||||
std::string method;
|
||||
std::vector<FormField> fields;
|
||||
};
|
||||
|
||||
struct ContentElement {
|
||||
ElementType type;
|
||||
std::string text;
|
||||
std::string url; // 对于链接元素
|
||||
int level; // 对于标题元素(1-6)
|
||||
std::string url;
|
||||
int level;
|
||||
int list_number; // For ordered lists
|
||||
int nesting_level; // For nested lists
|
||||
std::vector<InlineLink> inline_links; // Links within this element's text
|
||||
|
||||
// Extended content types
|
||||
Table table_data;
|
||||
Image image_data;
|
||||
Form form_data;
|
||||
};
|
||||
|
||||
struct ParsedDocument {
|
||||
|
|
@ -43,13 +112,8 @@ 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:
|
||||
|
|
|
|||
|
|
@ -19,16 +19,9 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
#include "ics_fetcher.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
auto *buffer = static_cast<std::string *>(userdata);
|
||||
buffer->append(ptr, size * nmemb);
|
||||
return size * nmemb;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string fetch_ics(const std::string &url) {
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
throw std::runtime_error("初始化 libcurl 失败");
|
||||
}
|
||||
|
||||
std::string response;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "nbtca_tui/1.0");
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
if (res != CURLE_OK) {
|
||||
std::string err = curl_easy_strerror(res);
|
||||
curl_easy_cleanup(curl);
|
||||
throw std::runtime_error("请求 ICS 失败: " + err);
|
||||
}
|
||||
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (http_code < 200 || http_code >= 300) {
|
||||
throw std::runtime_error("HTTP 状态码错误: " + std::to_string(http_code));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// 从给定 URL 获取 ICS 文本,失败抛出 std::runtime_error
|
||||
std::string fetch_ics(const std::string &url);
|
||||
|
||||
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
#include "ics_parser.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <iomanip>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
|
||||
// 去掉首尾空白
|
||||
std::string trim(const std::string &s) {
|
||||
size_t start = 0;
|
||||
while (start < s.size() && std::isspace(static_cast<unsigned char>(s[start]))) ++start;
|
||||
size_t end = s.size();
|
||||
while (end > start && std::isspace(static_cast<unsigned char>(s[end - 1]))) --end;
|
||||
return s.substr(start, end - start);
|
||||
}
|
||||
|
||||
// 将 ICS 中换行折叠处理(以空格或 Tab 开头的行拼接到前一行)
|
||||
std::vector<std::string> unfold_lines(const std::string &text) {
|
||||
std::vector<std::string> lines;
|
||||
std::string current;
|
||||
std::istringstream iss(text);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
if (!line.empty() && (line.back() == '\r' || line.back() == '\n')) {
|
||||
line.pop_back();
|
||||
}
|
||||
if (!line.empty() && (line[0] == ' ' || line[0] == '\t')) {
|
||||
// continuation
|
||||
current += trim(line);
|
||||
} else {
|
||||
if (!current.empty()) {
|
||||
lines.push_back(current);
|
||||
}
|
||||
current = line;
|
||||
}
|
||||
}
|
||||
if (!current.empty()) {
|
||||
lines.push_back(current);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 仅支持几种常见格式:YYYYMMDD 或 YYYYMMDDTHHMMSSZ / 本地时间
|
||||
std::chrono::system_clock::time_point parse_ics_datetime(const std::string &value) {
|
||||
std::tm tm{};
|
||||
if (value.size() == 8) {
|
||||
// 日期
|
||||
std::istringstream ss(value);
|
||||
ss >> std::get_time(&tm, "%Y%m%d");
|
||||
if (ss.fail()) {
|
||||
throw std::runtime_error("无法解析日期: " + value);
|
||||
}
|
||||
tm.tm_hour = 0;
|
||||
tm.tm_min = 0;
|
||||
tm.tm_sec = 0;
|
||||
} else if (value.size() >= 15 && value[8] == 'T') {
|
||||
// 日期时间,如 20250101T090000Z 或无 Z
|
||||
std::string fmt = "%Y%m%dT%H%M%S";
|
||||
std::string v = value;
|
||||
bool hasZ = false;
|
||||
if (!v.empty() && v.back() == 'Z') {
|
||||
hasZ = true;
|
||||
v.pop_back();
|
||||
}
|
||||
std::istringstream ss(v);
|
||||
ss >> std::get_time(&tm, fmt.c_str());
|
||||
if (ss.fail()) {
|
||||
throw std::runtime_error("无法解析日期时间: " + value);
|
||||
}
|
||||
// 这里简单按本地时间处理;如需严格 UTC 可改用 timegm
|
||||
} else {
|
||||
throw std::runtime_error("未知日期格式: " + value);
|
||||
}
|
||||
|
||||
std::time_t t = std::mktime(&tm);
|
||||
return std::chrono::system_clock::from_time_t(t);
|
||||
}
|
||||
|
||||
std::string get_prop_value(const std::string &line) {
|
||||
auto pos = line.find(':');
|
||||
if (pos == std::string::npos) return {};
|
||||
return line.substr(pos + 1);
|
||||
}
|
||||
|
||||
// 获取属性参数和值,例如 "DTSTART;TZID=Asia/Shanghai:20251121T203000"
|
||||
// 返回 "DTSTART;TZID=Asia/Shanghai" 作为 key,冒号后的部分作为 value。
|
||||
std::pair<std::string, std::string> split_prop(const std::string &line) {
|
||||
auto pos = line.find(':');
|
||||
if (pos == std::string::npos) return {line, {}};
|
||||
return {line.substr(0, pos), line.substr(pos + 1)};
|
||||
}
|
||||
|
||||
// 从 RRULE 中提取 UNTIL=... 的值(若存在)
|
||||
std::optional<std::string> extract_until_str(const std::string &rrule) {
|
||||
// 例:RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20260401T000000Z
|
||||
auto pos = rrule.find("UNTIL=");
|
||||
if (pos == std::string::npos) return std::nullopt;
|
||||
pos += 6; // 跳过 "UNTIL="
|
||||
size_t end = rrule.find(';', pos);
|
||||
if (end == std::string::npos) end = rrule.size();
|
||||
if (pos >= rrule.size()) return std::nullopt;
|
||||
return rrule.substr(pos, end - pos);
|
||||
}
|
||||
|
||||
bool starts_with(const std::string &s, const std::string &prefix) {
|
||||
return s.size() >= prefix.size() && std::equal(prefix.begin(), prefix.end(), s.begin());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<IcsEvent> parse_ics(const std::string &icsText) {
|
||||
auto lines = unfold_lines(icsText);
|
||||
std::vector<IcsEvent> events;
|
||||
|
||||
bool inEvent = false;
|
||||
IcsEvent current{};
|
||||
|
||||
for (const auto &rawLine : lines) {
|
||||
std::string line = trim(rawLine);
|
||||
if (line == "BEGIN:VEVENT") {
|
||||
inEvent = true;
|
||||
current = IcsEvent{};
|
||||
} else if (line == "END:VEVENT") {
|
||||
if (inEvent) {
|
||||
events.push_back(current);
|
||||
}
|
||||
inEvent = false;
|
||||
} else if (inEvent) {
|
||||
if (starts_with(line, "DTSTART")) {
|
||||
auto [key, v] = split_prop(line);
|
||||
current.start = parse_ics_datetime(v);
|
||||
} else if (starts_with(line, "DTEND")) {
|
||||
auto [key, v] = split_prop(line);
|
||||
current.end = parse_ics_datetime(v);
|
||||
} else if (starts_with(line, "SUMMARY")) {
|
||||
current.summary = get_prop_value(line);
|
||||
} else if (starts_with(line, "LOCATION")) {
|
||||
current.location = get_prop_value(line);
|
||||
} else if (starts_with(line, "DESCRIPTION")) {
|
||||
current.description = get_prop_value(line);
|
||||
} else if (starts_with(line, "RRULE")) {
|
||||
current.rrule = get_prop_value(line);
|
||||
if (auto untilStr = extract_until_str(current.rrule)) {
|
||||
try {
|
||||
current.until = parse_ics_datetime(*untilStr);
|
||||
} catch (...) {
|
||||
// UNTIL 解析失败时忽略截止时间,照常视为无限期
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按开始时间排序
|
||||
std::sort(events.begin(), events.end(),
|
||||
[](const IcsEvent &a, const IcsEvent &b) { return a.start < b.start; });
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct IcsEvent {
|
||||
std::chrono::system_clock::time_point start;
|
||||
std::chrono::system_clock::time_point end;
|
||||
std::string summary;
|
||||
std::string location;
|
||||
std::string description;
|
||||
|
||||
// 简单递归支持:保留 RRULE 原文,以及可选的 UNTIL 截止时间(若存在)
|
||||
std::string rrule;
|
||||
std::optional<std::chrono::system_clock::time_point> until;
|
||||
};
|
||||
|
||||
// 解析 ICS 文本,返回“基准事件”(不展开 RRULE)。
|
||||
// 周期事件会在 IcsEvent::rrule 与 IcsEvent::until 中体现。
|
||||
std::vector<IcsEvent> parse_ics(const std::string &icsText);
|
||||
|
||||
|
||||
|
|
@ -23,6 +23,36 @@ public:
|
|||
result.has_count = false;
|
||||
result.count = 1;
|
||||
|
||||
// Handle multi-char commands like 'gg', 'm', '
|
||||
if (!buffer.empty()) {
|
||||
if (buffer == "m") {
|
||||
// Set mark with letter
|
||||
if (std::isalpha(ch)) {
|
||||
result.action = Action::SET_MARK;
|
||||
result.text = std::string(1, static_cast<char>(ch));
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
} else if (buffer == "'") {
|
||||
// Jump to mark
|
||||
if (std::isalpha(ch)) {
|
||||
result.action = Action::GOTO_MARK;
|
||||
result.text = std::string(1, static_cast<char>(ch));
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle digit input for count
|
||||
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
||||
count_buffer += static_cast<char>(ch);
|
||||
return result;
|
||||
|
|
@ -31,33 +61,38 @@ public:
|
|||
if (!count_buffer.empty()) {
|
||||
result.has_count = true;
|
||||
result.count = std::stoi(count_buffer);
|
||||
count_buffer.clear();
|
||||
}
|
||||
|
||||
switch (ch) {
|
||||
case 'j':
|
||||
case KEY_DOWN:
|
||||
result.action = Action::SCROLL_DOWN;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'k':
|
||||
case KEY_UP:
|
||||
result.action = Action::SCROLL_UP;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'h':
|
||||
case KEY_LEFT:
|
||||
result.action = Action::GO_BACK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'l':
|
||||
case KEY_RIGHT:
|
||||
result.action = Action::GO_FORWARD;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 4:
|
||||
case ' ':
|
||||
result.action = Action::SCROLL_PAGE_DOWN;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 21:
|
||||
case 'b':
|
||||
result.action = Action::SCROLL_PAGE_UP;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'g':
|
||||
buffer += 'g';
|
||||
|
|
@ -65,6 +100,7 @@ public:
|
|||
result.action = Action::GOTO_TOP;
|
||||
buffer.clear();
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'G':
|
||||
if (result.has_count) {
|
||||
|
|
@ -73,27 +109,57 @@ public:
|
|||
} else {
|
||||
result.action = Action::GOTO_BOTTOM;
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '/':
|
||||
mode = InputMode::SEARCH;
|
||||
buffer = "/";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'n':
|
||||
result.action = Action::SEARCH_NEXT;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'N':
|
||||
result.action = Action::SEARCH_PREV;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\t':
|
||||
result.action = Action::NEXT_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case KEY_BTAB:
|
||||
case 'T':
|
||||
result.action = Action::PREV_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\n':
|
||||
case '\r':
|
||||
result.action = Action::FOLLOW_LINK;
|
||||
// If count buffer has a number, jump to that link
|
||||
if (result.has_count) {
|
||||
result.action = Action::GOTO_LINK;
|
||||
result.number = result.count;
|
||||
} else {
|
||||
result.action = Action::FOLLOW_LINK;
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'f':
|
||||
// 'f' command: vimium-style link hints
|
||||
result.action = Action::SHOW_LINK_HINTS;
|
||||
mode = InputMode::LINK_HINTS;
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'm':
|
||||
// Set mark (wait for next char)
|
||||
buffer = "m";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\'':
|
||||
// Jump to mark (wait for next char)
|
||||
buffer = "'";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case ':':
|
||||
mode = InputMode::COMMAND;
|
||||
|
|
@ -190,6 +256,74 @@ public:
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_link_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (std::isdigit(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Follow the link number entered
|
||||
if (buffer.length() > 1) {
|
||||
try {
|
||||
int link_num = std::stoi(buffer.substr(1));
|
||||
result.action = Action::FOLLOW_LINK_NUM;
|
||||
result.number = link_num;
|
||||
} catch (...) {
|
||||
set_status("Invalid link number");
|
||||
}
|
||||
}
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
// ESC cancels
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_link_hints_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC cancels link hints mode
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
// Backspace removes last character
|
||||
if (!buffer.empty()) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
}
|
||||
return result;
|
||||
} else if (std::isalpha(ch)) {
|
||||
// Add character to buffer
|
||||
buffer += std::tolower(static_cast<char>(ch));
|
||||
|
||||
// Try to match link hint
|
||||
result.action = Action::FOLLOW_LINK_HINT;
|
||||
result.text = buffer;
|
||||
|
||||
// Mode will be reset by browser if link is followed
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
|
@ -204,6 +338,10 @@ InputResult InputHandler::handle_key(int ch) {
|
|||
return pImpl->process_command_mode(ch);
|
||||
case InputMode::SEARCH:
|
||||
return pImpl->process_search_mode(ch);
|
||||
case InputMode::LINK:
|
||||
return pImpl->process_link_mode(ch);
|
||||
case InputMode::LINK_HINTS:
|
||||
return pImpl->process_link_hints_mode(ch);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
#include <memory>
|
||||
|
||||
enum class InputMode {
|
||||
NORMAL, // 正常浏览模式
|
||||
COMMAND, // 命令模式 (:)
|
||||
SEARCH, // 搜索模式 (/)
|
||||
LINK // 链接选择模式
|
||||
NORMAL,
|
||||
COMMAND,
|
||||
SEARCH,
|
||||
LINK,
|
||||
LINK_HINTS // Vimium-style 'f' mode
|
||||
};
|
||||
|
||||
enum class Action {
|
||||
|
|
@ -26,20 +27,26 @@ enum class Action {
|
|||
NEXT_LINK,
|
||||
PREV_LINK,
|
||||
FOLLOW_LINK,
|
||||
GOTO_LINK, // Jump to specific link by number
|
||||
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
|
||||
SHOW_LINK_HINTS, // Activate link hints mode ('f')
|
||||
FOLLOW_LINK_HINT, // Follow link by hint letters
|
||||
GO_BACK,
|
||||
GO_FORWARD,
|
||||
OPEN_URL,
|
||||
REFRESH,
|
||||
QUIT,
|
||||
HELP
|
||||
HELP,
|
||||
SET_MARK, // Set a mark (m + letter)
|
||||
GOTO_MARK // Jump to mark (' + letter)
|
||||
};
|
||||
|
||||
struct InputResult {
|
||||
Action action;
|
||||
std::string text; // 用于命令、搜索、URL输入
|
||||
int number; // 用于跳转行号、链接编号等
|
||||
bool has_count; // 是否有数字前缀(如 5j)
|
||||
int count; // 数字前缀
|
||||
std::string text;
|
||||
int number;
|
||||
bool has_count;
|
||||
int count;
|
||||
};
|
||||
|
||||
class InputHandler {
|
||||
|
|
@ -47,19 +54,10 @@ 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:
|
||||
|
|
|
|||
|
|
@ -2,11 +2,35 @@
|
|||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <clocale>
|
||||
#include <numeric>
|
||||
|
||||
// Box-drawing characters for tables
|
||||
namespace BoxChars {
|
||||
constexpr const char* TOP_LEFT = "┌";
|
||||
constexpr const char* TOP_RIGHT = "┐";
|
||||
constexpr const char* BOTTOM_LEFT = "└";
|
||||
constexpr const char* BOTTOM_RIGHT = "┘";
|
||||
constexpr const char* HORIZONTAL = "─";
|
||||
constexpr const char* VERTICAL = "│";
|
||||
constexpr const char* T_DOWN = "┬";
|
||||
constexpr const char* T_UP = "┴";
|
||||
constexpr const char* T_RIGHT = "├";
|
||||
constexpr const char* T_LEFT = "┤";
|
||||
constexpr const char* CROSS = "┼";
|
||||
constexpr const char* HEAVY_HORIZONTAL = "━";
|
||||
constexpr const char* HEAVY_VERTICAL = "┃";
|
||||
}
|
||||
|
||||
class TextRenderer::Impl {
|
||||
public:
|
||||
RenderConfig config;
|
||||
|
||||
struct LinkPosition {
|
||||
int link_index;
|
||||
size_t start;
|
||||
size_t end;
|
||||
};
|
||||
|
||||
std::vector<std::string> wrap_text(const std::string& text, int width) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty()) {
|
||||
|
|
@ -50,9 +74,360 @@ public:
|
|||
return lines;
|
||||
}
|
||||
|
||||
// Wrap text with links, tracking link positions and adding link numbers
|
||||
std::vector<std::pair<std::string, std::vector<LinkPosition>>>
|
||||
wrap_text_with_links(const std::string& original_text, int width,
|
||||
const std::vector<InlineLink>& inline_links) {
|
||||
std::vector<std::pair<std::string, std::vector<LinkPosition>>> result;
|
||||
|
||||
// If no links, use simple wrapping
|
||||
if (inline_links.empty()) {
|
||||
auto wrapped = wrap_text(original_text, width);
|
||||
for (const auto& line : wrapped) {
|
||||
result.push_back({line, {}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build modified text with link numbers inserted
|
||||
std::string text;
|
||||
std::vector<InlineLink> modified_links;
|
||||
size_t text_pos = 0;
|
||||
|
||||
for (const auto& link : inline_links) {
|
||||
// Add text before link
|
||||
if (link.start_pos > text_pos) {
|
||||
text += original_text.substr(text_pos, link.start_pos - text_pos);
|
||||
}
|
||||
|
||||
// Add link text with number indicator
|
||||
size_t link_start_in_modified = text.length();
|
||||
std::string link_text = original_text.substr(link.start_pos, link.end_pos - link.start_pos);
|
||||
std::string link_indicator = "[" + std::to_string(link.link_index) + "]";
|
||||
text += link_text + link_indicator;
|
||||
|
||||
// Store modified link position (including the indicator)
|
||||
InlineLink mod_link = link;
|
||||
mod_link.start_pos = link_start_in_modified;
|
||||
mod_link.end_pos = text.length();
|
||||
modified_links.push_back(mod_link);
|
||||
|
||||
text_pos = link.end_pos;
|
||||
}
|
||||
|
||||
// Add remaining text after last link
|
||||
if (text_pos < original_text.length()) {
|
||||
text += original_text.substr(text_pos);
|
||||
}
|
||||
|
||||
// Split text into words
|
||||
std::vector<std::string> words;
|
||||
std::vector<size_t> word_positions;
|
||||
|
||||
size_t pos = 0;
|
||||
while (pos < text.length()) {
|
||||
// Skip whitespace
|
||||
while (pos < text.length() && std::isspace(text[pos])) {
|
||||
pos++;
|
||||
}
|
||||
if (pos >= text.length()) break;
|
||||
|
||||
// Extract word
|
||||
size_t word_start = pos;
|
||||
while (pos < text.length() && !std::isspace(text[pos])) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
words.push_back(text.substr(word_start, pos - word_start));
|
||||
word_positions.push_back(word_start);
|
||||
}
|
||||
|
||||
// Build lines
|
||||
std::string current_line;
|
||||
std::vector<LinkPosition> current_links;
|
||||
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
const auto& word = words[i];
|
||||
size_t word_pos = word_positions[i];
|
||||
|
||||
bool can_fit = current_line.empty()
|
||||
? word.length() <= static_cast<size_t>(width)
|
||||
: current_line.length() + 1 + word.length() <= static_cast<size_t>(width);
|
||||
|
||||
if (!can_fit && !current_line.empty()) {
|
||||
// Save current line
|
||||
result.push_back({current_line, current_links});
|
||||
current_line.clear();
|
||||
current_links.clear();
|
||||
}
|
||||
|
||||
// Add word to current line
|
||||
if (!current_line.empty()) {
|
||||
current_line += " ";
|
||||
}
|
||||
size_t word_start_in_line = current_line.length();
|
||||
current_line += word;
|
||||
|
||||
// Check if this word overlaps with any links
|
||||
for (const auto& link : modified_links) {
|
||||
size_t word_end = word_pos + word.length();
|
||||
|
||||
// Check for overlap
|
||||
if (word_pos < link.end_pos && word_end > link.start_pos) {
|
||||
// Calculate link position in current line
|
||||
size_t link_start_in_line = word_start_in_line;
|
||||
if (link.start_pos > word_pos) {
|
||||
link_start_in_line += (link.start_pos - word_pos);
|
||||
}
|
||||
|
||||
size_t link_end_in_line = word_start_in_line + word.length();
|
||||
if (link.end_pos < word_end) {
|
||||
link_end_in_line -= (word_end - link.end_pos);
|
||||
}
|
||||
|
||||
// Check if link already added
|
||||
bool already_added = false;
|
||||
for (auto& existing : current_links) {
|
||||
if (existing.link_index == link.link_index) {
|
||||
// Extend existing link range
|
||||
existing.end = link_end_in_line;
|
||||
already_added = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!already_added) {
|
||||
LinkPosition lp;
|
||||
lp.link_index = link.link_index;
|
||||
lp.start = link_start_in_line;
|
||||
lp.end = link_end_in_line;
|
||||
current_links.push_back(lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add last line
|
||||
if (!current_line.empty()) {
|
||||
result.push_back({current_line, current_links});
|
||||
}
|
||||
|
||||
if (result.empty()) {
|
||||
result.push_back({"", {}});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string add_indent(const std::string& text, int indent) {
|
||||
return std::string(indent, ' ') + text;
|
||||
}
|
||||
|
||||
// Render a table with box-drawing characters
|
||||
std::vector<RenderedLine> render_table(const Table& table, int content_width, int margin) {
|
||||
std::vector<RenderedLine> lines;
|
||||
if (table.rows.empty()) return lines;
|
||||
|
||||
// Calculate column widths
|
||||
size_t num_cols = 0;
|
||||
for (const auto& row : table.rows) {
|
||||
num_cols = std::max(num_cols, row.cells.size());
|
||||
}
|
||||
|
||||
if (num_cols == 0) return lines;
|
||||
|
||||
std::vector<int> col_widths(num_cols, 0);
|
||||
int available_width = content_width - (num_cols + 1) * 3; // Account for borders and padding
|
||||
|
||||
// First pass: calculate minimum widths
|
||||
for (const auto& row : table.rows) {
|
||||
for (size_t i = 0; i < row.cells.size() && i < num_cols; ++i) {
|
||||
int cell_len = static_cast<int>(row.cells[i].text.length());
|
||||
int max_width = available_width / static_cast<int>(num_cols);
|
||||
int cell_width = std::min(cell_len, max_width);
|
||||
col_widths[i] = std::max(col_widths[i], cell_width);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize column widths
|
||||
int total_width = std::accumulate(col_widths.begin(), col_widths.end(), 0);
|
||||
if (total_width > available_width) {
|
||||
// Scale down proportionally
|
||||
for (auto& width : col_widths) {
|
||||
width = (width * available_width) / total_width;
|
||||
width = std::max(width, 5); // Minimum column width
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create separator line
|
||||
auto create_separator = [&](bool is_top, bool is_bottom, bool is_middle, bool is_header) {
|
||||
RenderedLine line;
|
||||
std::string sep = std::string(margin, ' ');
|
||||
|
||||
if (is_top) {
|
||||
sep += BoxChars::TOP_LEFT;
|
||||
} else if (is_bottom) {
|
||||
sep += BoxChars::BOTTOM_LEFT;
|
||||
} else {
|
||||
sep += BoxChars::T_RIGHT;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < num_cols; ++i) {
|
||||
const char* horiz = is_header ? BoxChars::HEAVY_HORIZONTAL : BoxChars::HORIZONTAL;
|
||||
sep += std::string(col_widths[i] + 2, horiz[0]);
|
||||
|
||||
if (i < num_cols - 1) {
|
||||
if (is_top) {
|
||||
sep += BoxChars::T_DOWN;
|
||||
} else if (is_bottom) {
|
||||
sep += BoxChars::T_UP;
|
||||
} else {
|
||||
sep += BoxChars::CROSS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_top) {
|
||||
sep += BoxChars::TOP_RIGHT;
|
||||
} else if (is_bottom) {
|
||||
sep += BoxChars::BOTTOM_RIGHT;
|
||||
} else {
|
||||
sep += BoxChars::T_LEFT;
|
||||
}
|
||||
|
||||
line.text = sep;
|
||||
line.color_pair = COLOR_DIM;
|
||||
line.is_bold = false;
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
return line;
|
||||
};
|
||||
|
||||
// Top border
|
||||
lines.push_back(create_separator(true, false, false, false));
|
||||
|
||||
// Render rows
|
||||
bool first_row = true;
|
||||
for (const auto& row : table.rows) {
|
||||
bool is_header_row = first_row && table.has_header;
|
||||
|
||||
// Wrap cell contents
|
||||
std::vector<std::vector<std::string>> wrapped_cells(num_cols);
|
||||
int max_cell_lines = 1;
|
||||
|
||||
for (size_t i = 0; i < row.cells.size() && i < num_cols; ++i) {
|
||||
const auto& cell = row.cells[i];
|
||||
auto cell_lines = wrap_text(cell.text, col_widths[i]);
|
||||
wrapped_cells[i] = cell_lines;
|
||||
max_cell_lines = std::max(max_cell_lines, static_cast<int>(cell_lines.size()));
|
||||
}
|
||||
|
||||
// Render cell lines
|
||||
for (int line_idx = 0; line_idx < max_cell_lines; ++line_idx) {
|
||||
RenderedLine line;
|
||||
std::string line_text = std::string(margin, ' ') + BoxChars::VERTICAL;
|
||||
|
||||
for (size_t col_idx = 0; col_idx < num_cols; ++col_idx) {
|
||||
std::string cell_text;
|
||||
if (col_idx < wrapped_cells.size() && line_idx < static_cast<int>(wrapped_cells[col_idx].size())) {
|
||||
cell_text = wrapped_cells[col_idx][line_idx];
|
||||
}
|
||||
|
||||
// Pad to column width
|
||||
int padding = col_widths[col_idx] - cell_text.length();
|
||||
line_text += " " + cell_text + std::string(padding + 1, ' ') + BoxChars::VERTICAL;
|
||||
}
|
||||
|
||||
line.text = line_text;
|
||||
line.color_pair = is_header_row ? COLOR_HEADING2 : COLOR_NORMAL;
|
||||
line.is_bold = is_header_row;
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
lines.push_back(line);
|
||||
}
|
||||
|
||||
// Separator after header or between rows
|
||||
if (is_header_row) {
|
||||
lines.push_back(create_separator(false, false, true, true));
|
||||
}
|
||||
|
||||
first_row = false;
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
lines.push_back(create_separator(false, true, false, false));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Render an image placeholder
|
||||
std::vector<RenderedLine> render_image(const Image& img, int content_width, int margin) {
|
||||
std::vector<RenderedLine> lines;
|
||||
|
||||
// Create a box for the image
|
||||
std::string img_text = "[IMG";
|
||||
if (!img.alt.empty()) {
|
||||
img_text += ": " + img.alt;
|
||||
}
|
||||
img_text += "]";
|
||||
|
||||
// Truncate if too long
|
||||
if (static_cast<int>(img_text.length()) > content_width) {
|
||||
img_text = img_text.substr(0, content_width - 3) + "...]";
|
||||
}
|
||||
|
||||
// Top border
|
||||
RenderedLine top;
|
||||
top.text = std::string(margin, ' ') + BoxChars::TOP_LEFT +
|
||||
std::string(img_text.length() + 2, BoxChars::HORIZONTAL[0]) +
|
||||
BoxChars::TOP_RIGHT;
|
||||
top.color_pair = COLOR_DIM;
|
||||
top.is_bold = false;
|
||||
top.is_link = false;
|
||||
top.link_index = -1;
|
||||
lines.push_back(top);
|
||||
|
||||
// Content
|
||||
RenderedLine content;
|
||||
content.text = std::string(margin, ' ') + BoxChars::VERTICAL + " " + img_text + " " + BoxChars::VERTICAL;
|
||||
content.color_pair = COLOR_LINK;
|
||||
content.is_bold = true;
|
||||
content.is_link = false;
|
||||
content.link_index = -1;
|
||||
lines.push_back(content);
|
||||
|
||||
// Dimensions if available
|
||||
if (img.width > 0 || img.height > 0) {
|
||||
std::string dims = " ";
|
||||
if (img.width > 0) dims += std::to_string(img.width) + "w";
|
||||
if (img.width > 0 && img.height > 0) dims += " × ";
|
||||
if (img.height > 0) dims += std::to_string(img.height) + "h";
|
||||
dims += " ";
|
||||
|
||||
RenderedLine dim_line;
|
||||
int padding = img_text.length() + 2 - dims.length();
|
||||
dim_line.text = std::string(margin, ' ') + BoxChars::VERTICAL + dims +
|
||||
std::string(padding, ' ') + BoxChars::VERTICAL;
|
||||
dim_line.color_pair = COLOR_DIM;
|
||||
dim_line.is_bold = false;
|
||||
dim_line.is_link = false;
|
||||
dim_line.link_index = -1;
|
||||
lines.push_back(dim_line);
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
RenderedLine bottom;
|
||||
bottom.text = std::string(margin, ' ') + BoxChars::BOTTOM_LEFT +
|
||||
std::string(img_text.length() + 2, BoxChars::HORIZONTAL[0]) +
|
||||
BoxChars::BOTTOM_RIGHT;
|
||||
bottom.color_pair = COLOR_DIM;
|
||||
bottom.is_bold = false;
|
||||
bottom.is_link = false;
|
||||
bottom.link_index = -1;
|
||||
lines.push_back(bottom);
|
||||
|
||||
return lines;
|
||||
}
|
||||
};
|
||||
|
||||
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {
|
||||
|
|
@ -149,8 +524,52 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
|||
prefix = "> ";
|
||||
break;
|
||||
case ElementType::LIST_ITEM:
|
||||
prefix = " • ";
|
||||
{
|
||||
// Different bullets for different nesting levels
|
||||
const char* bullets[] = {"•", "◦", "▪", "▫"};
|
||||
int indent = elem.nesting_level * 2;
|
||||
int bullet_idx = elem.nesting_level % 4;
|
||||
prefix = std::string(indent, ' ') + " " + bullets[bullet_idx] + " ";
|
||||
}
|
||||
break;
|
||||
case ElementType::ORDERED_LIST_ITEM:
|
||||
{
|
||||
// Numbered lists with proper indentation
|
||||
int indent = elem.nesting_level * 2;
|
||||
prefix = std::string(indent, ' ') + " " +
|
||||
std::to_string(elem.list_number) + ". ";
|
||||
}
|
||||
break;
|
||||
case ElementType::TABLE:
|
||||
{
|
||||
auto table_lines = pImpl->render_table(elem.table_data, content_width, margin);
|
||||
lines.insert(lines.end(), table_lines.begin(), table_lines.end());
|
||||
|
||||
// Add empty line after table
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
case ElementType::IMAGE:
|
||||
{
|
||||
auto img_lines = pImpl->render_image(elem.image_data, content_width, margin);
|
||||
lines.insert(lines.end(), img_lines.begin(), img_lines.end());
|
||||
|
||||
// Add empty line after image
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
case ElementType::HORIZONTAL_RULE:
|
||||
{
|
||||
RenderedLine hr;
|
||||
|
|
@ -163,22 +582,49 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
|||
lines.push_back(hr);
|
||||
continue;
|
||||
}
|
||||
case ElementType::HEADING4:
|
||||
case ElementType::HEADING5:
|
||||
case ElementType::HEADING6:
|
||||
color = COLOR_HEADING3; // Use same color as H3 for H4-H6
|
||||
bold = true;
|
||||
prefix = std::string(elem.level, '#') + " ";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length());
|
||||
for (size_t i = 0; i < wrapped_lines.size(); ++i) {
|
||||
auto wrapped_with_links = pImpl->wrap_text_with_links(elem.text,
|
||||
content_width - prefix.length(),
|
||||
elem.inline_links);
|
||||
|
||||
for (size_t i = 0; i < wrapped_with_links.size(); ++i) {
|
||||
const auto& [line_text, link_positions] = wrapped_with_links[i];
|
||||
RenderedLine line;
|
||||
|
||||
if (i == 0) {
|
||||
line.text = std::string(margin, ' ') + prefix + wrapped_lines[i];
|
||||
line.text = std::string(margin, ' ') + prefix + line_text;
|
||||
} else {
|
||||
line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i];
|
||||
line.text = std::string(margin + prefix.length(), ' ') + line_text;
|
||||
}
|
||||
|
||||
line.color_pair = color;
|
||||
line.is_bold = bold;
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
|
||||
// Store link information
|
||||
if (!link_positions.empty()) {
|
||||
line.is_link = true;
|
||||
line.link_index = link_positions[0].link_index; // Primary link for Tab navigation
|
||||
|
||||
// Adjust link positions for margin and prefix
|
||||
size_t offset = (i == 0) ? (margin + prefix.length()) : (margin + prefix.length());
|
||||
for (const auto& lp : link_positions) {
|
||||
line.link_ranges.push_back({lp.start + offset, lp.end + offset});
|
||||
}
|
||||
} else {
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
}
|
||||
|
||||
lines.push_back(line);
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +644,8 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
|||
}
|
||||
}
|
||||
|
||||
if (!doc.links.empty() && pImpl->config.show_link_indicators) {
|
||||
// Don't show separate links section if inline links are displayed
|
||||
if (!doc.links.empty() && !pImpl->config.show_link_indicators) {
|
||||
RenderedLine separator;
|
||||
std::string sepline(content_width, '-');
|
||||
separator.text = std::string(margin, ' ') + sepline;
|
||||
|
|
|
|||
|
|
@ -6,22 +6,21 @@
|
|||
#include <memory>
|
||||
#include <curses.h>
|
||||
|
||||
// 渲染后的行信息
|
||||
struct RenderedLine {
|
||||
std::string text;
|
||||
int color_pair;
|
||||
bool is_bold;
|
||||
bool is_link;
|
||||
int link_index; // 如果是链接,对应的链接索引
|
||||
int link_index;
|
||||
std::vector<std::pair<size_t, size_t>> link_ranges; // (start, end) positions of links in this line
|
||||
};
|
||||
|
||||
// 渲染配置
|
||||
struct RenderConfig {
|
||||
int max_width = 80; // 内容最大宽度
|
||||
int margin_left = 0; // 左边距(居中时自动计算)
|
||||
bool center_content = true; // 是否居中内容
|
||||
int paragraph_spacing = 1; // 段落间距
|
||||
bool show_link_indicators = true; // 是否显示链接指示器
|
||||
int max_width = 80;
|
||||
int margin_left = 0;
|
||||
bool center_content = true;
|
||||
int paragraph_spacing = 1;
|
||||
bool show_link_indicators = false; // Set to false to show inline links by default
|
||||
};
|
||||
|
||||
class TextRenderer {
|
||||
|
|
@ -29,13 +28,8 @@ public:
|
|||
TextRenderer();
|
||||
~TextRenderer();
|
||||
|
||||
// 渲染文档到行数组
|
||||
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
|
||||
|
||||
// 设置渲染配置
|
||||
void set_config(const RenderConfig& config);
|
||||
|
||||
// 获取当前配置
|
||||
RenderConfig get_config() const;
|
||||
|
||||
private:
|
||||
|
|
@ -43,7 +37,6 @@ private:
|
|||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
||||
// 颜色定义
|
||||
enum ColorScheme {
|
||||
COLOR_NORMAL = 1,
|
||||
COLOR_HEADING1,
|
||||
|
|
@ -57,5 +50,4 @@ enum ColorScheme {
|
|||
COLOR_DIM
|
||||
};
|
||||
|
||||
// 初始化颜色方案
|
||||
void init_color_scheme();
|
||||
|
|
|
|||
598
src/tui_view.cpp
598
src/tui_view.cpp
|
|
@ -1,598 +0,0 @@
|
|||
#include "tui_view.h"
|
||||
|
||||
#include <curses.h>
|
||||
#include <chrono>
|
||||
#include <clocale>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <thread> // Added this line
|
||||
|
||||
namespace {
|
||||
|
||||
// Define color pairs - Modern btop-inspired color scheme
|
||||
enum ColorPairs {
|
||||
NORMAL_TEXT = 1, // Default white text
|
||||
SHADOW_TEXT, // Dim shadow text
|
||||
BANNER_TEXT, // Bright cyan for banners
|
||||
SELECTED_ITEM, // Bright yellow for selected items
|
||||
BORDER_LINE, // Gray borders and boxes
|
||||
SUCCESS_TEXT, // Green for success states
|
||||
WARNING_TEXT, // Orange/yellow for warnings
|
||||
ERROR_TEXT, // Red for errors
|
||||
INFO_TEXT, // Blue for information
|
||||
ACCENT_TEXT, // Magenta for accents
|
||||
DIM_TEXT, // Dimmed secondary text
|
||||
PROGRESS_BAR, // Green progress bars
|
||||
CALENDAR_HEADER, // Calendar header styling
|
||||
EVENT_PAST, // Grayed out past events
|
||||
EVENT_TODAY, // Highlighted today's events
|
||||
EVENT_UPCOMING // Default upcoming events
|
||||
};
|
||||
|
||||
void init_colors() {
|
||||
if (has_colors()) {
|
||||
start_color();
|
||||
use_default_colors(); // Use terminal's default background
|
||||
|
||||
// Modern color scheme inspired by btop
|
||||
init_pair(NORMAL_TEXT, COLOR_WHITE, -1); // White text
|
||||
init_pair(SHADOW_TEXT, COLOR_BLACK, -1); // Black shadow text
|
||||
init_pair(BANNER_TEXT, COLOR_CYAN, -1); // Bright cyan for banners
|
||||
init_pair(SELECTED_ITEM, COLOR_YELLOW, -1); // Bright yellow selection
|
||||
init_pair(BORDER_LINE, COLOR_BLUE, -1); // Blue borders/boxes
|
||||
init_pair(SUCCESS_TEXT, COLOR_GREEN, -1); // Green for success
|
||||
init_pair(WARNING_TEXT, COLOR_YELLOW, -1); // Orange/yellow warnings
|
||||
init_pair(ERROR_TEXT, COLOR_RED, -1); // Red for errors
|
||||
init_pair(INFO_TEXT, COLOR_BLUE, -1); // Blue for info
|
||||
init_pair(ACCENT_TEXT, COLOR_MAGENTA, -1); // Magenta accents
|
||||
init_pair(DIM_TEXT, COLOR_BLACK, -1); // Dimmed text
|
||||
init_pair(PROGRESS_BAR, COLOR_GREEN, -1); // Green progress
|
||||
init_pair(CALENDAR_HEADER, COLOR_CYAN, -1); // Calendar headers
|
||||
init_pair(EVENT_PAST, COLOR_BLACK, -1); // Grayed past events
|
||||
init_pair(EVENT_TODAY, COLOR_YELLOW, -1); // Today's events highlighted
|
||||
init_pair(EVENT_UPCOMING, COLOR_WHITE, -1); // Default upcoming events
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to draw a box
|
||||
void draw_box(int start_y, int start_x, int width, int height, bool shadow = false) {
|
||||
if (shadow) {
|
||||
// Draw shadow first
|
||||
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||
for (int i = 0; i < height; i++) {
|
||||
mvprintw(start_y + i + 1, start_x + 1, "%s", std::string(width, ' ').c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||
}
|
||||
|
||||
attron(COLOR_PAIR(BORDER_LINE));
|
||||
// Draw corners
|
||||
mvprintw(start_y, start_x, "┌");
|
||||
mvprintw(start_y, start_x + width - 1, "┐");
|
||||
mvprintw(start_y + height - 1, start_x, "└");
|
||||
mvprintw(start_y + height - 1, start_x + width - 1, "┘");
|
||||
|
||||
// Draw horizontal lines
|
||||
for (int i = 1; i < width - 1; i++) {
|
||||
mvprintw(start_y, start_x + i, "─");
|
||||
mvprintw(start_y + height - 1, start_x + i, "─");
|
||||
}
|
||||
|
||||
// Draw vertical lines
|
||||
for (int i = 1; i < height - 1; i++) {
|
||||
mvprintw(start_y + i, start_x, "│");
|
||||
mvprintw(start_y + i, start_x + width - 1, "│");
|
||||
}
|
||||
attroff(COLOR_PAIR(BORDER_LINE));
|
||||
}
|
||||
|
||||
// Helper function to draw a progress bar
|
||||
void draw_progress_bar(int y, int x, int width, float percentage) {
|
||||
int filled_width = static_cast<int>(width * percentage);
|
||||
|
||||
attron(COLOR_PAIR(BORDER_LINE));
|
||||
mvprintw(y, x, "[");
|
||||
mvprintw(y, x + width - 1, "]");
|
||||
attroff(COLOR_PAIR(BORDER_LINE));
|
||||
|
||||
attron(COLOR_PAIR(PROGRESS_BAR));
|
||||
for (int i = 1; i < filled_width && i < width - 1; i++) {
|
||||
mvprintw(y, x + i, "█");
|
||||
}
|
||||
attroff(COLOR_PAIR(PROGRESS_BAR));
|
||||
}
|
||||
|
||||
// Helper function to center text within a box
|
||||
void draw_centered_text(int y, int box_start_x, int box_width, const std::string& text, int color_pair = NORMAL_TEXT) {
|
||||
int text_x = box_start_x + (box_width - text.length()) / 2;
|
||||
if (text_x < box_start_x) text_x = box_start_x;
|
||||
|
||||
attron(COLOR_PAIR(color_pair));
|
||||
mvprintw(y, text_x, "%s", text.c_str());
|
||||
attroff(COLOR_PAIR(color_pair));
|
||||
}
|
||||
|
||||
std::string format_date(const std::chrono::system_clock::time_point &tp) {
|
||||
auto tt = std::chrono::system_clock::to_time_t(tp);
|
||||
std::tm tm{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm, &tt);
|
||||
#else
|
||||
localtime_r(&tt, &tm);
|
||||
#endif
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm, "%Y-%m-%d %a %H:%M");
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
// Helper function to check if event is today, past, or upcoming
|
||||
int get_event_status(const std::chrono::system_clock::time_point &event_time) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
|
||||
if (event_time < now) {
|
||||
return EVENT_PAST;
|
||||
} else {
|
||||
// Simple check if it's today (within 24 hours)
|
||||
auto hours_until_event = std::chrono::duration_cast<std::chrono::hours>(event_time - now);
|
||||
if (hours_until_event.count() <= 24) {
|
||||
return EVENT_TODAY;
|
||||
} else {
|
||||
return EVENT_UPCOMING;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run_tui(const std::vector<IcsEvent> &events) {
|
||||
// 让 ncurses 按当前终端 locale 处理 UTF-8
|
||||
setlocale(LC_ALL, "");
|
||||
|
||||
initscr();
|
||||
init_colors(); // Initialize colors
|
||||
cbreak();
|
||||
noecho();
|
||||
keypad(stdscr, TRUE);
|
||||
curs_set(0);
|
||||
|
||||
int height, width;
|
||||
getmaxyx(stdscr, height, width);
|
||||
|
||||
// 如果没有任何事件,给出提示信息
|
||||
if (events.empty()) {
|
||||
clear();
|
||||
attron(COLOR_PAIR(BANNER_TEXT));
|
||||
mvprintw(0, 0, "NBTCA 未来一个月活动");
|
||||
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||
mvprintw(2, 0, "未来一个月内暂无活动。");
|
||||
mvprintw(4, 0, "按任意键退出...");
|
||||
refresh();
|
||||
getch();
|
||||
endwin();
|
||||
return;
|
||||
}
|
||||
|
||||
int top = 0;
|
||||
int selected = 0;
|
||||
|
||||
while (true) {
|
||||
clear();
|
||||
|
||||
// Modern ASCII art banner for "CALENDAR" - smaller and cleaner
|
||||
std::string calendar_banner[] = {
|
||||
" ╔═══════════════════════════════════╗ ",
|
||||
" ║ [CAL] NBTCA CALENDAR [CAL] ║ ",
|
||||
" ╚═══════════════════════════════════╝ "
|
||||
};
|
||||
|
||||
int banner_height = sizeof(calendar_banner) / sizeof(calendar_banner[0]);
|
||||
int banner_width = calendar_banner[0].length();
|
||||
|
||||
int start_col_banner = (width - banner_width) / 2;
|
||||
if (start_col_banner < 0) start_col_banner = 0;
|
||||
|
||||
// Draw shadow
|
||||
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||
for (int i = 0; i < banner_height; ++i) {
|
||||
mvprintw(i + 1, start_col_banner + 1, "%s", calendar_banner[i].c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||
|
||||
// Draw main banner
|
||||
attron(COLOR_PAIR(BANNER_TEXT));
|
||||
for (int i = 0; i < banner_height; ++i) {
|
||||
mvprintw(i, start_col_banner, "%s", calendar_banner[i].c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||
|
||||
// Draw status bar with current date and event count
|
||||
attron(COLOR_PAIR(BORDER_LINE));
|
||||
mvprintw(banner_height + 1, 0, "┌");
|
||||
mvprintw(banner_height + 1, width - 1, "┐");
|
||||
for (int i = 1; i < width - 1; i++) {
|
||||
mvprintw(banner_height + 1, i, "─");
|
||||
}
|
||||
attroff(COLOR_PAIR(BORDER_LINE));
|
||||
|
||||
// Status information
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto tt = std::chrono::system_clock::to_time_t(now);
|
||||
std::tm tm{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm, &tt);
|
||||
#else
|
||||
localtime_r(&tt, &tm);
|
||||
#endif
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm, "%Y-%m-%d %A");
|
||||
std::string current_date = "Today: " + oss.str() + " | Events: " + std::to_string(events.size());
|
||||
|
||||
attron(COLOR_PAIR(INFO_TEXT));
|
||||
mvprintw(banner_height + 1, 2, "%s", current_date.c_str());
|
||||
attroff(COLOR_PAIR(INFO_TEXT));
|
||||
|
||||
attron(COLOR_PAIR(DIM_TEXT));
|
||||
std::string instruction_msg = "[q:Exit ↑↓:Scroll]";
|
||||
mvprintw(banner_height + 1, width - instruction_msg.length() - 2, "%s", instruction_msg.c_str());
|
||||
attroff(COLOR_PAIR(DIM_TEXT));
|
||||
|
||||
int start_event_row = banner_height + 3; // Start events below the banner and instruction
|
||||
int visibleLines = height - start_event_row - 2; // Leave space for bottom border
|
||||
|
||||
// Draw main event container box
|
||||
draw_box(start_event_row - 1, 0, width, visibleLines + 2, true);
|
||||
|
||||
// Header for events list
|
||||
attron(COLOR_PAIR(CALENDAR_HEADER));
|
||||
mvprintw(start_event_row, 2, "╓ Upcoming Events");
|
||||
attroff(COLOR_PAIR(CALENDAR_HEADER));
|
||||
|
||||
int events_start_row = start_event_row + 1;
|
||||
int events_visible_lines = visibleLines - 2;
|
||||
|
||||
if (selected < top) {
|
||||
top = selected;
|
||||
} else if (selected >= top + events_visible_lines) {
|
||||
top = selected - events_visible_lines + 1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < events_visible_lines; ++i) {
|
||||
int idx = top + i;
|
||||
if (idx >= static_cast<int>(events.size())) break;
|
||||
|
||||
const auto &ev = events[idx];
|
||||
int event_status = get_event_status(ev.start);
|
||||
|
||||
// Event icon based on status
|
||||
std::string icon = "○";
|
||||
if (event_status == EVENT_TODAY) {
|
||||
icon = "*";
|
||||
} else if (event_status == EVENT_PAST) {
|
||||
icon = "v";
|
||||
}
|
||||
|
||||
// Format date more compactly
|
||||
auto tt = std::chrono::system_clock::to_time_t(ev.start);
|
||||
std::tm tm{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm, &tt);
|
||||
#else
|
||||
localtime_r(&tt, &tm);
|
||||
#endif
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm, "%m/%d %H:%M");
|
||||
std::string date_str = oss.str();
|
||||
|
||||
// Build the event line
|
||||
std::string line = icon + " " + date_str + " " + ev.summary;
|
||||
if (!ev.location.empty()) {
|
||||
line += " @" + ev.location;
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if ((int)line.length() > width - 4) {
|
||||
line.resize(width - 4);
|
||||
}
|
||||
|
||||
// Determine colors based on event status and selection
|
||||
int text_color = (event_status == EVENT_PAST) ? DIM_TEXT :
|
||||
(event_status == EVENT_TODAY) ? EVENT_TODAY :
|
||||
EVENT_UPCOMING;
|
||||
|
||||
if (idx == selected) {
|
||||
attron(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||
mvprintw(events_start_row + i, 2, "%s", std::string(width - 4, ' ').c_str());
|
||||
mvprintw(events_start_row + i, 3, "%s", line.c_str());
|
||||
attroff(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||
} else {
|
||||
attron(COLOR_PAIR(text_color));
|
||||
mvprintw(events_start_row + i, 3, "%s", line.c_str());
|
||||
attroff(COLOR_PAIR(text_color));
|
||||
}
|
||||
|
||||
// Add a subtle separator
|
||||
if (i < events_visible_lines - 1 && idx + 1 < static_cast<int>(events.size())) {
|
||||
attron(COLOR_PAIR(BORDER_LINE));
|
||||
mvprintw(events_start_row + i + 1, 2, "├");
|
||||
for (int j = 3; j < width - 2; j++) {
|
||||
mvprintw(events_start_row + i + 1, j, "─");
|
||||
}
|
||||
mvprintw(events_start_row + i + 1, width - 2, "┤");
|
||||
attroff(COLOR_PAIR(BORDER_LINE));
|
||||
}
|
||||
}
|
||||
|
||||
// Add scroll indicator if there are more events
|
||||
if (events.size() > events_visible_lines) {
|
||||
float scroll_percentage = static_cast<float>(top + events_visible_lines) / events.size();
|
||||
int scroll_y = start_event_row + 1;
|
||||
int scroll_x = width - 3;
|
||||
draw_progress_bar(scroll_y, scroll_x, 1, scroll_percentage);
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
int ch = getch();
|
||||
if (ch == 'q' || ch == 'Q') {
|
||||
break;
|
||||
} else if (ch == KEY_UP || ch == 'k') {
|
||||
if (selected > 0) selected--;
|
||||
} else if (ch == KEY_DOWN || ch == 'j') {
|
||||
if (selected + 1 < (int)events.size()) selected++;
|
||||
}
|
||||
}
|
||||
|
||||
endwin();
|
||||
}
|
||||
|
||||
int run_portal_tui() {
|
||||
setlocale(LC_ALL, "");
|
||||
|
||||
initscr();
|
||||
init_colors(); // Initialize colors
|
||||
cbreak();
|
||||
noecho();
|
||||
keypad(stdscr, TRUE);
|
||||
curs_set(0);
|
||||
|
||||
int height, width;
|
||||
getmaxyx(stdscr, height, width);
|
||||
|
||||
std::vector<std::string> menu_items = {"Calendar", "Exit"};
|
||||
int selected = 0;
|
||||
int choice = -1;
|
||||
|
||||
while (choice == -1) {
|
||||
clear();
|
||||
|
||||
// Modern ASCII art banner for "NBTCA Tools" - smaller and cleaner
|
||||
std::string banner_art[] = {
|
||||
" ╔══════════════════════════════════════╗ ",
|
||||
" ║ [TOOL] NBTCA UTILITY TOOLS [TOOL] ║ ",
|
||||
" ╚══════════════════════════════════════╝ "
|
||||
};
|
||||
|
||||
int banner_height = sizeof(banner_art) / sizeof(banner_art[0]);
|
||||
int banner_width = banner_art[0].length();
|
||||
|
||||
int start_col_banner = (width - banner_width) / 2;
|
||||
if (start_col_banner < 0) start_col_banner = 0;
|
||||
|
||||
// Draw shadow
|
||||
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||
for (int i = 0; i < banner_height; ++i) {
|
||||
mvprintw(i + 1, start_col_banner + 1, "%s", banner_art[i].c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||
|
||||
// Draw main banner
|
||||
attron(COLOR_PAIR(BANNER_TEXT));
|
||||
for (int i = 0; i < banner_height; ++i) {
|
||||
mvprintw(i, start_col_banner, "%s", banner_art[i].c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||
|
||||
// Draw info bar
|
||||
attron(COLOR_PAIR(BORDER_LINE));
|
||||
mvprintw(banner_height + 1, 0, "┌");
|
||||
mvprintw(banner_height + 1, width - 1, "┐");
|
||||
for (int i = 1; i < width - 1; i++) {
|
||||
mvprintw(banner_height + 1, i, "─");
|
||||
}
|
||||
attroff(COLOR_PAIR(BORDER_LINE));
|
||||
|
||||
// Status information
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto tt = std::chrono::system_clock::to_time_t(now);
|
||||
std::tm tm{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm, &tt);
|
||||
#else
|
||||
localtime_r(&tt, &tm);
|
||||
#endif
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
|
||||
std::string current_time = "Current: " + oss.str();
|
||||
|
||||
attron(COLOR_PAIR(INFO_TEXT));
|
||||
mvprintw(banner_height + 1, 2, "%s", current_time.c_str());
|
||||
attroff(COLOR_PAIR(INFO_TEXT));
|
||||
|
||||
attron(COLOR_PAIR(DIM_TEXT));
|
||||
std::string help_msg = "[↑↓:Navigate Enter:Select q:Exit]";
|
||||
mvprintw(banner_height + 1, width - help_msg.length() - 2, "%s", help_msg.c_str());
|
||||
attroff(COLOR_PAIR(DIM_TEXT));
|
||||
|
||||
// Draw menu box
|
||||
int menu_box_y = banner_height + 3;
|
||||
int menu_box_height = menu_items.size() + 4;
|
||||
int menu_box_width = 30;
|
||||
int menu_box_x = (width - menu_box_width) / 2;
|
||||
if (menu_box_x < 2) menu_box_x = 2;
|
||||
|
||||
draw_box(menu_box_y, menu_box_x, menu_box_width, menu_box_height, true);
|
||||
|
||||
// Menu box title
|
||||
attron(COLOR_PAIR(CALENDAR_HEADER));
|
||||
draw_centered_text(menu_box_y + 1, menu_box_x, menu_box_width, "Select Module");
|
||||
attroff(COLOR_PAIR(CALENDAR_HEADER));
|
||||
|
||||
// Draw menu items with icons
|
||||
for (size_t i = 0; i < menu_items.size(); ++i) {
|
||||
std::string display_item = menu_items[i];
|
||||
|
||||
// Add icons to menu items
|
||||
if (display_item == "Calendar") {
|
||||
display_item = "[CAL] " + display_item;
|
||||
} else if (display_item == "Exit") {
|
||||
display_item = "[X] " + display_item;
|
||||
}
|
||||
|
||||
int item_y = menu_box_y + 2 + i;
|
||||
|
||||
if ((int)i == selected) {
|
||||
// Draw selection highlight box
|
||||
attron(COLOR_PAIR(BORDER_LINE));
|
||||
mvprintw(item_y, menu_box_x + 1, "│");
|
||||
mvprintw(item_y, menu_box_x + menu_box_width - 2, "│");
|
||||
attroff(COLOR_PAIR(BORDER_LINE));
|
||||
|
||||
attron(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||
draw_centered_text(item_y, menu_box_x, menu_box_width, display_item, SELECTED_ITEM);
|
||||
attroff(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||
} else {
|
||||
attron(COLOR_PAIR(NORMAL_TEXT));
|
||||
draw_centered_text(item_y, menu_box_x, menu_box_width, display_item);
|
||||
attroff(COLOR_PAIR(NORMAL_TEXT));
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
int ch = getch();
|
||||
switch (ch) {
|
||||
case KEY_UP:
|
||||
case 'k':
|
||||
if (selected > 0) {
|
||||
selected--;
|
||||
}
|
||||
break;
|
||||
case KEY_DOWN:
|
||||
case 'j':
|
||||
if (selected < (int)menu_items.size() - 1) {
|
||||
selected++;
|
||||
}
|
||||
break;
|
||||
case 'q':
|
||||
case 'Q':
|
||||
choice = 1; // Corresponds to "Exit"
|
||||
break;
|
||||
case 10: // Enter key
|
||||
choice = selected;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endwin();
|
||||
return choice;
|
||||
}
|
||||
|
||||
// 显示启动画面
|
||||
void display_splash_screen() {
|
||||
setlocale(LC_ALL, "");
|
||||
initscr();
|
||||
init_colors(); // Initialize colors
|
||||
cbreak();
|
||||
noecho();
|
||||
curs_set(0);
|
||||
|
||||
int height, width;
|
||||
getmaxyx(stdscr, height, width);
|
||||
|
||||
// Animated splash screen with progress bar
|
||||
for (int frame = 0; frame < 20; ++frame) {
|
||||
clear();
|
||||
|
||||
// Modern ASCII art for "NBTCA Tools" - cleaner design
|
||||
std::string splash_art[] = {
|
||||
" ╔══════════════════════════════════════╗ ",
|
||||
" ║ [TOOL] NBTCA UTILITY TOOLS [TOOL] ║ ",
|
||||
" ╚══════════════════════════════════════╝ "
|
||||
};
|
||||
|
||||
int art_height = sizeof(splash_art) / sizeof(splash_art[0]);
|
||||
int art_width = splash_art[0].length();
|
||||
|
||||
int start_row = (height - art_height) / 2 - 3;
|
||||
int start_col = (width - art_width) / 2;
|
||||
|
||||
if (start_row < 0) start_row = 0;
|
||||
if (start_col < 0) start_col = 0;
|
||||
|
||||
// Draw shadow
|
||||
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||
for (int i = 0; i < art_height; ++i) {
|
||||
mvprintw(start_row + i + 1, start_col + 1, "%s", splash_art[i].c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||
|
||||
// Draw main art
|
||||
attron(COLOR_PAIR(BANNER_TEXT));
|
||||
for (int i = 0; i < art_height; ++i) {
|
||||
mvprintw(start_row + i, start_col, "%s", splash_art[i].c_str());
|
||||
}
|
||||
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||
|
||||
// Version info
|
||||
attron(COLOR_PAIR(INFO_TEXT));
|
||||
draw_centered_text(start_row + art_height + 1, start_col, art_width, "Version 0.0.1");
|
||||
attroff(COLOR_PAIR(INFO_TEXT));
|
||||
|
||||
// Loading text with rotating animation
|
||||
const char* spinner[] = {"|", "/", "-", "\\"};
|
||||
std::string loading_msg = std::string(spinner[frame % 4]) + " Initializing system components...";
|
||||
|
||||
attron(COLOR_PAIR(NORMAL_TEXT));
|
||||
draw_centered_text(start_row + art_height + 3, start_col, art_width, loading_msg);
|
||||
attroff(COLOR_PAIR(NORMAL_TEXT));
|
||||
|
||||
// Progress bar
|
||||
int progress_bar_y = start_row + art_height + 5;
|
||||
int progress_bar_width = 40;
|
||||
int progress_bar_x = (width - progress_bar_width) / 2;
|
||||
|
||||
float progress = static_cast<float>(frame) / 19.0f;
|
||||
draw_progress_bar(progress_bar_y, progress_bar_x, progress_bar_width, progress);
|
||||
|
||||
// Progress percentage
|
||||
attron(COLOR_PAIR(SUCCESS_TEXT));
|
||||
std::string progress_text = std::to_string(static_cast<int>(progress * 100)) + "% Complete";
|
||||
draw_centered_text(progress_bar_y + 1, progress_bar_x, progress_bar_width, progress_text);
|
||||
attroff(COLOR_PAIR(SUCCESS_TEXT));
|
||||
|
||||
// Status messages
|
||||
std::vector<std::string> status_msgs = {
|
||||
"Loading calendar module...",
|
||||
"Initializing network stack...",
|
||||
"Fetching latest events...",
|
||||
"Preparing user interface...",
|
||||
"System ready!"
|
||||
};
|
||||
|
||||
int msg_index = (frame * status_msgs.size()) / 20;
|
||||
if (msg_index >= status_msgs.size()) msg_index = status_msgs.size() - 1;
|
||||
|
||||
attron(COLOR_PAIR(DIM_TEXT));
|
||||
draw_centered_text(progress_bar_y + 2, progress_bar_x, progress_bar_width, status_msgs[msg_index]);
|
||||
attroff(COLOR_PAIR(DIM_TEXT));
|
||||
|
||||
refresh();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 100ms per frame
|
||||
}
|
||||
|
||||
endwin();
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "ics_parser.h"
|
||||
#include <vector>
|
||||
|
||||
// 运行 ncurses TUI,展示给定事件列表
|
||||
// 当 events 为空时,在界面上提示“未来一个月暂无活动”
|
||||
void run_tui(const std::vector<IcsEvent> &events);
|
||||
|
||||
// 运行 ncurses TUI for the portal
|
||||
int run_portal_tui();
|
||||
|
||||
// 显示启动画面
|
||||
void display_splash_screen();
|
||||
|
||||
24
test_inline_links.html
Normal file
24
test_inline_links.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Inline Links</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Page for Inline Links</h1>
|
||||
|
||||
<p>This is a paragraph with an <a href="https://example.com">inline link</a> in the middle of the text. You should be able to see the link highlighted directly in the text.</p>
|
||||
|
||||
<p>Here is another paragraph with multiple links: <a href="https://google.com">Google</a> and <a href="https://github.com">GitHub</a> are both popular websites.</p>
|
||||
|
||||
<p>This paragraph has a longer link text: <a href="https://en.wikipedia.org">Wikipedia is a free online encyclopedia</a> that anyone can edit.</p>
|
||||
|
||||
<h2>More Examples</h2>
|
||||
|
||||
<p>Press Tab to navigate between links, and Enter to follow them. The links should be <a href="https://example.com/test1">highlighted</a> directly in the text, not listed separately at the bottom.</p>
|
||||
|
||||
<ul>
|
||||
<li>List item with <a href="https://news.ycombinator.com">Hacker News</a></li>
|
||||
<li>Another item with <a href="https://reddit.com">Reddit</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue