Compare commits

...

11 commits

Author SHA1 Message Date
feefbfcf90 Merge feat/browser-interaction-enhancements: Major simplification following Unix philosophy 2025-12-17 15:44:53 +08:00
430e70d7b6 refactor: Major simplification following Unix philosophy
Removed ~45% dead code and simplified architecture:

Dead Code Removal (~1,687 LOC):
- calendar.cpp/h - Unused calendar stub
- ics_fetcher.cpp/h - Orphaned ICS fetching
- ics_parser.cpp/h - Abandoned iCalendar parsing
- tui_view.cpp/h - Separate UI implementation

Build System:
- Simplified Makefile to CMake wrapper
- Added install target to CMakeLists.txt
- Improved .gitignore for build artifacts
- Removed Chinese comments, replaced with English

Code Simplification:
- Removed unimplemented features:
  * VISUAL/VISUAL_LINE modes (no actual functionality)
  * YANK action (copy not implemented)
  * Tab support (NEXT_TAB, PREV_TAB, etc.)
  * TOGGLE_MOUSE (mouse always enabled)
- Removed process_visual_mode() function (~36 lines)
- Removed gt/gT keybindings for tabs
- Updated help text to remove placeholders

HTML Entity Decoding:
- Made entity list static const (performance)
- Added numeric entity support ({, «)
- Added UTF-8 encoding for decoded entities
- Cleaner, more complete implementation

This brings the browser closer to Unix principles:
- Do one thing well (browse, don't manage calendar)
- Keep it simple (removed over-engineered features)
- Clear, focused codebase (2,058 LOC vs 3,745)

Build tested successfully with only minor warnings.
2025-12-17 15:39:23 +08:00
8ba659c8d2 docs: Document marks and mouse support in README
Add documentation for vim-style marks (m[a-z] to set, '[a-z] to jump)
and mouse support (link clicks, scroll wheel) to match the features
implemented in the previous commit.
2025-12-17 14:21:26 +08:00
815c479a90 feat: Add marks and mouse support for better navigation
- Implement vim-style marks (ma to set, 'a to jump)
  * Store mark positions per character (a-z)
  * Display status messages when setting/jumping to marks
  * Integrated with vim keybinding infrastructure

- Add full mouse support
  * Click on links to follow them directly
  * Mouse wheel scrolling (up/down)
  * Proper click detection within link ranges
  * Works with most modern terminal emulators

- Enable ncurses mouse events
  * ALL_MOUSE_EVENTS for comprehensive support
  * Zero mouseinterval for instant response
  * Handle BUTTON1_CLICKED, BUTTON4_PRESSED (wheel up), BUTTON5_PRESSED (wheel down)

- Update help documentation
  * Document marks keybindings
  * Add mouse support section
  * Note infrastructure for visual mode and tabs

This brings TUT closer to feature parity with modern vim plugins
while maintaining excellent usability for both keyboard and mouse users.
2025-12-17 13:53:46 +08:00
860c8aaf56
🚀 Modern Browser Enhancements - Vimium-style Navigation & Beautiful Rendering (#11)
* feat: Add table, image, and nested list support to HTML parser

- Add Table, Image, and Form data structures
- Implement table extraction with proper row/column parsing
- Add image extraction with alt text and dimensions
- Implement recursive nested list parsing (ul/ol)
- Support ordered and unordered lists with nesting levels
- Extract list item numbers for ordered lists
- Add HEADING4-6, ORDERED_LIST_ITEM, TABLE, IMAGE element types

This enhancement allows TUT to properly extract and represent
structured content from HTML, enabling better rendering of
data-heavy websites.

* feat: Implement beautiful table and image rendering with box-drawing

- Add Unicode box-drawing characters for table borders (┌─┬─┐, │, etc.)
- Implement table rendering with proper column width calculation
- Add header row styling with heavy borders and bold text
- Support automatic text wrapping within table cells
- Implement image placeholder rendering with bordered boxes
- Display image alt text and dimensions (width × height)
- Enhance list rendering with different bullet styles per nesting level
  * Level 0: • (bullet)
  * Level 1: ◦ (white bullet)
  * Level 2: ▪ (small square)
  * Level 3: ▫ (white small square)
- Add ordered list rendering with proper numbering
- Support proper indentation for nested lists

These visual enhancements make TUT significantly more modern and
readable compared to traditional text browsers like w3m.

* feat: Add Vimium-style link hints and vim keybindings infrastructure

- Add LINK_HINTS mode for Vimium-style link navigation
- Implement 'f' key to activate link hints mode
- Add visual mode support (v/V keys)
- Implement marks support (m[a-z] to set, '[a-z] to jump)
- Add tab navigation keys (gt/gT for next/previous tab)
- Add new actions:
  * SHOW_LINK_HINTS - activate link hints overlay
  * FOLLOW_LINK_HINT - follow link by hint letters
  * ENTER_VISUAL_MODE / ENTER_VISUAL_LINE_MODE
  * SET_MARK / GOTO_MARK - vim-style position bookmarks
  * NEXT_TAB / PREV_TAB - tab navigation
  * YANK - copy selected text

This brings modern browser vim plugin functionality (like Vimium)
to the terminal, making link navigation much faster than traditional
tab-through methods.
2025-12-17 13:51:28 +08:00
ea71b0ca02 feat: Add inline link display and vim-style quick navigation
Major improvements to link handling and navigation:

Features:
- Display links inline with numbered indicators [0], [1], etc.
- Quick navigation: type number + Enter to jump to link
- Fast follow: press 'f' + number to open link directly
- Visual improvements: links shown with underline and highlight
- Remove separate link list at bottom for better readability

Technical changes:
- Add InlineLink structure to track link positions in text
- Implement wrap_text_with_links() for intelligent text wrapping
- Add GOTO_LINK and FOLLOW_LINK_NUM actions
- Implement LINK input mode for 'f' command
- Character-by-character rendering for proper link highlighting
- Update help documentation with new navigation methods

Usage examples:
- 3<Enter>  : Jump to link 3
- f5 or 5f  : Open link 5 directly
- Tab/Enter : Traditional navigation still works

All comments converted to standard Unix style (English).
2025-12-08 17:07:40 +08:00
354133b500 docs: Rewrite README as Unix man page, remove Chinese comments
Following Unix philosophy and documentation standards:
- Rewrite README.md in man page format (NAME, SYNOPSIS, DESCRIPTION, etc.)
- Remove all Chinese comments from source code
- Keep code clean and self-documenting
- Add PHILOSOPHY section explaining Unix principles
- Include proper EXIT STATUS, ENVIRONMENT, and FILES sections
- Reference related tools in SEE ALSO section
2025-12-08 16:11:39 +08:00
818f5ddc5e fix: Add missing memory headers for unique_ptr
All headers using std::unique_ptr now explicitly include <memory>
2025-12-08 16:05:22 +08:00
6564f23707 fix: Add missing memory header to html_parser.h
Linux build requires explicit include of <memory> for std::unique_ptr
2025-12-08 16:03:17 +08:00
ef80f9ab82 refactor: Improve code quality and Unix philosophy
- Remove redundant comments for cleaner code
- Simplify error messages and status display
- Improve code consistency across modules
- Fix GitHub Actions workflow binary names
- Enhance .gitignore for common editor files
- Align help text formatting
- Remove unnecessary verbose comments
2025-12-08 15:53:17 +08:00
ab2d1932e4 feat: Transform to vim-style terminal browser (#10)
* feat: Add HTTP/HTTPS client module

Implement HTTP client with libcurl for fetching web pages:
- Support for HTTP and HTTPS protocols
- Configurable timeout and user agent
- Automatic redirect following
- SSL certificate verification
- Pimpl pattern for implementation hiding

This module provides the foundation for web page retrieval
in the terminal browser.

* feat: Add HTML parser and content extraction

Implement HTML parser for extracting readable content:
- Parse HTML structure (headings, paragraphs, lists, links)
- Extract and decode HTML entities
- Smart content area detection (article, main, body)
- Relative URL to absolute URL conversion
- Support for both absolute and relative paths
- Filter out scripts, styles, and non-content elements

The parser uses regex-based extraction optimized for
text-heavy websites and documentation.

* feat: Add newspaper-style text rendering engine

Implement text renderer with adaptive layout:
- Adaptive width with maximum 80 characters
- Center-aligned content for comfortable reading
- Smart text wrapping and paragraph spacing
- Color scheme optimized for terminal reading
- Support for headings, paragraphs, lists, and links
- Link indicators with numbering
- Horizontal rules and visual separators

The renderer creates a newspaper-like reading experience
optimized for terminal displays.

* feat: Implement vim-style input handling

Add complete vim-style keyboard navigation:
- Normal mode: hjkl movement, gg/G jump, numeric prefixes
- Command mode: :q, :o URL, :r, :h, :[number]
- Search mode: / for search, n/N for next/previous match
- Link navigation: Tab/Shift-Tab, Enter to follow
- Scroll commands: Ctrl-D/U, Space, b for page up/down
- History navigation: h for back, l for forward

Input handler manages mode transitions and command parsing
with full vim compatibility.

* feat: Implement browser core with TUI interface

Add main browser engine and user interface:
- Page loading with HTTP client integration
- HTML parsing and text rendering pipeline
- History management (back/forward navigation)
- Link selection and following with Tab navigation
- Search functionality with highlighting
- Scrolling with position tracking
- Status bar with mode indicator and progress
- Built-in help page with usage instructions
- Error handling and user feedback
- Support for static HTML websites

The browser provides a complete vim-style terminal
browsing experience optimized for reading text content.

* build: Update build system for terminal browser

Update CMake and add Makefile for the new project:
- Rename project from NBTCA_TUI to TUT
- Update executable name from nbtca_tui to tut
- Add all new source files to build
- Include Makefile for environments without CMake
- Update .gitignore for build artifacts

Both CMake and Make build systems are now supported
for maximum compatibility.

* docs: Complete project transformation to terminal browser

Transform project from ICS calendar viewer to terminal browser:
- Rewrite main.cpp for browser launch with URL argument support
- Complete README rewrite with:
  - New project description and features
  - Comprehensive keyboard shortcuts documentation
  - Installation guide for multiple platforms
  - Usage examples and best practices
  - JavaScript/SPA limitations explanation
  - Architecture overview
- Add help command line option
- Update version to 1.0.0

The project is now TUT (Terminal User Interface Browser),
a vim-style terminal web browser optimized for reading.
2025-12-05 15:01:21 +08:00
26 changed files with 3199 additions and 1013 deletions

View file

@ -47,13 +47,13 @@ jobs:
- name: Rename binary with platform suffix
run: |
mv build/nbtca_tui build/nbtca_tui-${{ matrix.name }}
mv build/tut build/tut-${{ matrix.name }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: nbtca_tui-${{ matrix.name }}
path: build/nbtca_tui-${{ matrix.name }}
name: tut-${{ matrix.name }}
path: build/tut-${{ matrix.name }}
release:
needs: build
@ -85,13 +85,13 @@ jobs:
Automated release for commit ${{ github.sha }}
## Download
- **macOS**: `nbtca_tui-macos`
- **Linux**: `nbtca_tui-linux`
- **macOS**: `tut-macos`
- **Linux**: `tut-linux`
## Build from source
See the [README](https://github.com/${{ github.repository }}/blob/main/README.md) for build instructions.
files: |
artifacts/nbtca_tui-macos/nbtca_tui-macos
artifacts/nbtca_tui-linux/nbtca_tui-linux
artifacts/tut-macos/tut-macos
artifacts/tut-linux/tut-linux
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

25
.gitignore vendored
View file

@ -1 +1,24 @@
build/
# 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
*~
.vscode/
.idea/

View file

@ -1,13 +1,13 @@
cmake_minimum_required(VERSION 3.15)
project(NBTCA_TUI 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,15 +15,27 @@ endif()
find_package(Curses REQUIRED)
find_package(CURL REQUIRED)
add_executable(nbtca_tui
# Executable
add_executable(tut
src/main.cpp
src/ics_fetcher.cpp
src/ics_parser.cpp
src/tui_view.cpp
src/calendar.cpp
src/http_client.cpp
src/html_parser.cpp
src/text_renderer.cpp
src/input_handler.cpp
src/browser.cpp
)
target_include_directories(nbtca_tui PRIVATE ${CURSES_INCLUDE_DIR})
target_link_libraries(nbtca_tui PRIVATE ${CURSES_LIBRARIES} CURL::libcurl)
target_include_directories(tut PRIVATE ${CURSES_INCLUDE_DIR})
target_link_libraries(tut PRIVATE ${CURSES_LIBRARIES} CURL::libcurl)
# 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
View 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!

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
# Simple Makefile wrapper for CMake build system
# Follows Unix convention: simple interface to underlying build
BUILD_DIR = build
TARGET = tut
.PHONY: all clean install test help
all:
@mkdir -p $(BUILD_DIR)
@cd $(BUILD_DIR) && cmake .. && cmake --build .
@cp $(BUILD_DIR)/$(TARGET) .
clean:
@rm -rf $(BUILD_DIR) $(TARGET)
install: all
@install -m 755 $(TARGET) /usr/local/bin/
test: all
@./$(TARGET) https://example.com
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"

298
README.md
View file

@ -1,70 +1,274 @@
# TUT - TUI Utility Tools (WIP)
TUT(1) - Terminal User Interface Browser
========================================
This project, "TUT," is a collection of TUI (Terminal User Interface) utility modules written in C++. The initial focus is on the integrated **ICS Calendar Module**. This module fetches, parses, and displays iCal calendar events from `https://ical.nbtca.space/nbtca.ics` using `ncurses` to show upcoming activities within the next month.
NAME
----
tut - vim-style terminal web browser
### 依赖
SYNOPSIS
--------
**tut** [*URL*]
- CMake ≥ 3.15
- C++17 编译器macOS 上建议 `clang`
- `ncurses`
- `libcurl`
**tut** **-h** | **--help**
#### 在 macOS (Homebrew) 安装依赖
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.
```bash
brew install cmake ncurses curl
```
The browser does not execute JavaScript or display images. It is designed
for reading static HTML content, documentation, and text-heavy websites.
### 构建
OPTIONS
-------
*URL*
Open the specified URL on startup. If omitted, displays the built-in
help page.
在项目根目录执行:
**-h**, **--help**
Display usage information and exit.
```bash
mkdir -p build
cd build
cmake ..
cmake --build .
```
KEYBINDINGS
-----------
**tut** uses vim-style keybindings throughout.
生成的可执行文件为 `nbtca_tui`
### Navigation
### 运行
**j**, **Down**
Scroll down one line.
`build` 目录中运行:
**k**, **Up**
Scroll up one line.
```bash
./nbtca_tui
```
**Ctrl-D**, **Space**
Scroll down one page.
程序会:
**Ctrl-U**, **b**
Scroll up one page.
1. 通过 `libcurl` 请求 `https://ical.nbtca.space/nbtca.ics`
2. 解析所有 VEVENT 事件,提取开始时间、结束时间、标题、地点、描述
3. 过滤出从当前时间起未来 30 天内的事件
4. 使用 ncurses TUI 滚动展示列表
**gg**
Jump to top of page.
### TUI 操作说明
**G**
Jump to bottom of page.
- `↑` / `↓`:上下移动选中事件
- `q`:退出程序
**[***count***]G**
Jump to line *count* (e.g., **50G** jumps to line 50).
### Developer Guide
**[***count***]j**, **[***count***]k**
Scroll down/up *count* lines (e.g., **5j** scrolls down 5 lines).
For contributors and developers, follow these guidelines:
### Link Navigation
1. **Clone the Repository:**
```bash
git clone https://github.com/m1ngsama/TUT.git
cd TUT
```
2. **Build Environment Setup:** Ensure all [Dependencies](#dependencies) are installed.
3. **Local Build:** Follow the [构建](#构建) instructions.
4. **Code Style:** Adhere to the existing code style in `src/`.
5. **Testing:** Currently, there are no automated tests. Please manually verify changes.
6. **Contributing:** Submit Pull Requests for new features or bug fixes.
**Tab**
Move to next link.
### 版本 (Version)
**Shift-Tab**, **T**
Move to previous link.
- `v0.0.1`
**Enter**
Follow current link.
**h**, **Left**
Go back in history.
**l**, **Right**
Go forward in history.
### Search
**/**
Start search. Enter search term and press **Enter**.
**n**
Jump to next search match.
**N**
Jump to previous search match.
### Marks
**m***[a-z]*
Set mark at current position (e.g., **ma**, **mb**).
**'***[a-z]*
Jump to mark (e.g., **'a**, **'b**).
### Mouse
**Left Click**
Click on links to follow them directly.
**Scroll Wheel Up/Down**
Scroll page up or down.
Works with most modern terminal emulators that support mouse events.
### Commands
Press **:** to enter command mode. Available commands:
**:q**, **:quit**
Quit the browser.
**:o** *URL*, **:open** *URL*
Open *URL*.
**:r**, **:refresh**
Reload current page.
**:h**, **:help**
Display help page.
**:***number*
Jump to line *number*.
### Other
**r**
Reload current page.
**q**
Quit the browser.
**?**
Display help page.
**ESC**
Cancel command or search input.
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:
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.
Additionally:
- No image display
- No CSS layout support
- No form submission
- No cookie or session management
- No AJAX or dynamic content loading
EXAMPLES
--------
View the built-in help:
tut
Browse Hacker News:
tut https://news.ycombinator.com
Read Wikipedia:
tut https://en.wikipedia.org/wiki/Unix_philosophy
Open a URL, search for "unix", and navigate:
tut https://example.com
/unix<Enter>
n
DEPENDENCIES
------------
- ncurses or ncursesw (for terminal UI)
- libcurl (for HTTPS support)
- CMake >= 3.15 (build time)
- C++17 compiler (build time)
INSTALLATION
------------
### From Source
**macOS (Homebrew):**
brew install cmake ncurses curl
mkdir -p build && cd build
cmake ..
cmake --build .
sudo install -m 755 tut /usr/local/bin/
**Linux (Debian/Ubuntu):**
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):**
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/
### Using Makefile
make
sudo make install
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:
**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.

604
src/browser.cpp Normal file
View file

@ -0,0 +1,604 @@
#include "browser.h"
#include <curses.h>
#include <clocale>
#include <algorithm>
#include <sstream>
#include <map>
class Browser::Impl {
public:
HttpClient http_client;
HtmlParser html_parser;
TextRenderer renderer;
InputHandler input_handler;
ParsedDocument current_doc;
std::vector<RenderedLine> rendered_lines;
std::string current_url;
std::vector<std::string> history;
int history_pos = -1;
int scroll_pos = 0;
int current_link = -1;
std::string status_message;
std::string search_term;
std::vector<int> search_results;
int screen_height = 0;
int screen_width = 0;
// Marks support (vim-style position bookmarks)
std::map<char, int> marks;
void init_screen() {
setlocale(LC_ALL, "");
initscr();
init_color_scheme();
cbreak();
noecho();
keypad(stdscr, TRUE);
curs_set(0);
timeout(0);
// Enable mouse support
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL);
mouseinterval(0); // No click delay
getmaxyx(stdscr, screen_height, screen_width);
}
void cleanup_screen() {
endwin();
}
bool load_page(const std::string& url) {
status_message = "Loading " + url + "...";
draw_screen();
refresh();
auto response = http_client.fetch(url);
if (!response.is_success()) {
status_message = response.error_message.empty() ?
"HTTP " + std::to_string(response.status_code) :
response.error_message;
return false;
}
current_doc = html_parser.parse(response.body, url);
rendered_lines = renderer.render(current_doc, screen_width);
current_url = url;
scroll_pos = 0;
current_link = -1;
search_results.clear();
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
history.erase(history.begin() + history_pos + 1, history.end());
}
history.push_back(url);
history_pos = history.size() - 1;
status_message = current_doc.title.empty() ? url : current_doc.title;
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());
std::string mode_str;
InputMode mode = input_handler.get_mode();
switch (mode) {
case InputMode::NORMAL:
mode_str = "NORMAL";
break;
case InputMode::COMMAND:
case InputMode::SEARCH:
mode_str = input_handler.get_buffer();
break;
default:
mode_str = "";
break;
}
mvprintw(screen_height - 1, 0, " %s", mode_str.c_str());
if (!status_message.empty() && mode == InputMode::NORMAL) {
int msg_x = (screen_width - status_message.length()) / 2;
if (msg_x < static_cast<int>(mode_str.length()) + 2) {
msg_x = mode_str.length() + 2;
}
mvprintw(screen_height - 1, msg_x, "%s", status_message.c_str());
}
int total_lines = rendered_lines.size();
int visible_lines = screen_height - 2;
int percentage = 0;
if (total_lines > 0) {
if (scroll_pos == 0) {
percentage = 0;
} else if (scroll_pos + visible_lines >= total_lines) {
percentage = 100;
} else {
percentage = (scroll_pos * 100) / total_lines;
}
}
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
std::to_string(total_lines) + " " +
std::to_string(percentage) + "%";
if (current_link >= 0 && current_link < static_cast<int>(current_doc.links.size())) {
pos_str = "[Link " + std::to_string(current_link) + "] " + pos_str;
}
mvprintw(screen_height - 1, screen_width - pos_str.length() - 1, "%s", pos_str.c_str());
attroff(COLOR_PAIR(COLOR_STATUS_BAR));
}
void draw_screen() {
clear();
int visible_lines = screen_height - 2;
int content_lines = std::min(static_cast<int>(rendered_lines.size()) - scroll_pos, visible_lines);
for (int i = 0; i < content_lines; ++i) {
int line_idx = scroll_pos + i;
const auto& line = rendered_lines[line_idx];
// 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++;
}
} else {
// 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));
}
}
}
draw_status_bar();
}
void handle_action(const InputResult& result) {
int visible_lines = screen_height - 2;
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - visible_lines);
int count = result.has_count ? result.count : 1;
switch (result.action) {
case Action::SCROLL_UP:
scroll_pos = std::max(0, scroll_pos - count);
break;
case Action::SCROLL_DOWN:
scroll_pos = std::min(max_scroll, scroll_pos + count);
break;
case Action::SCROLL_PAGE_UP:
scroll_pos = std::max(0, scroll_pos - visible_lines);
break;
case Action::SCROLL_PAGE_DOWN:
scroll_pos = std::min(max_scroll, scroll_pos + visible_lines);
break;
case Action::GOTO_TOP:
scroll_pos = 0;
break;
case Action::GOTO_BOTTOM:
scroll_pos = max_scroll;
break;
case Action::GOTO_LINE:
if (result.number > 0 && result.number <= static_cast<int>(rendered_lines.size())) {
scroll_pos = std::min(result.number - 1, max_scroll);
}
break;
case Action::NEXT_LINK:
if (!current_doc.links.empty()) {
current_link = (current_link + 1) % current_doc.links.size();
scroll_to_link(current_link);
}
break;
case Action::PREV_LINK:
if (!current_doc.links.empty()) {
current_link = (current_link - 1 + current_doc.links.size()) % current_doc.links.size();
scroll_to_link(current_link);
}
break;
case Action::FOLLOW_LINK:
if (current_link >= 0 && current_link < static_cast<int>(current_doc.links.size())) {
load_page(current_doc.links[current_link].url);
}
break;
case Action::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--;
load_page(history[history_pos]);
} else {
status_message = "No previous page";
}
break;
case Action::GO_FORWARD:
if (history_pos < static_cast<int>(history.size()) - 1) {
history_pos++;
load_page(history[history_pos]);
} else {
status_message = "No next page";
}
break;
case Action::OPEN_URL:
if (!result.text.empty()) {
load_page(result.text);
}
break;
case Action::REFRESH:
if (!current_url.empty()) {
load_page(current_url);
}
break;
case Action::SEARCH_FORWARD:
search_term = result.text;
search_results.clear();
for (size_t i = 0; i < rendered_lines.size(); ++i) {
if (rendered_lines[i].text.find(search_term) != std::string::npos) {
search_results.push_back(i);
}
}
if (!search_results.empty()) {
scroll_pos = search_results[0];
status_message = "Found " + std::to_string(search_results.size()) + " matches";
} else {
status_message = "Pattern not found: " + search_term;
}
break;
case Action::SEARCH_NEXT:
if (!search_results.empty()) {
auto it = std::upper_bound(search_results.begin(), search_results.end(), scroll_pos);
if (it != search_results.end()) {
scroll_pos = *it;
} else {
scroll_pos = search_results[0];
status_message = "Search wrapped to top";
}
}
break;
case Action::SEARCH_PREV:
if (!search_results.empty()) {
auto it = std::lower_bound(search_results.begin(), search_results.end(), scroll_pos);
if (it != search_results.begin()) {
scroll_pos = *(--it);
} else {
scroll_pos = search_results.back();
status_message = "Search wrapped to bottom";
}
}
break;
case Action::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;
default:
break;
}
}
void scroll_to_link(int link_idx) {
for (size_t i = 0; i < rendered_lines.size(); ++i) {
if (rendered_lines[i].is_link && rendered_lines[i].link_index == link_idx) {
int visible_lines = screen_height - 2;
if (static_cast<int>(i) < scroll_pos || static_cast<int>(i) >= scroll_pos + visible_lines) {
scroll_pos = std::max(0, static_cast<int>(i) - visible_lines / 2);
}
break;
}
}
}
void show_help() {
std::ostringstream help_html;
help_html << "<html><head><title>TUT Browser Help</title></head><body>"
<< "<h1>TUT Browser - Vim-style Terminal Browser</h1>"
<< "<h2>Navigation</h2>"
<< "<p>j/k or ↓/↑: Scroll down/up</p>"
<< "<p>Ctrl-D or Space: Scroll page down</p>"
<< "<p>Ctrl-U or b: Scroll page up</p>"
<< "<p>gg: Go to top</p>"
<< "<p>G: Go to bottom</p>"
<< "<p>[number]G: Go to line number</p>"
<< "<h2>Links</h2>"
<< "<p>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 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>"
<< "<p>/: Start search</p>"
<< "<p>n: Next match</p>"
<< "<p>N: Previous match</p>"
<< "<h2>Commands</h2>"
<< "<p>:q or :quit - Quit browser</p>"
<< "<p>:o URL or :open URL - Open URL</p>"
<< "<p>:r or :refresh - Refresh page</p>"
<< "<p>:h or :help - Show this help</p>"
<< "<p>:[number] - Go to line number</p>"
<< "<h2>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 "
<< "as they render content dynamically with JavaScript.</p>"
<< "<p><strong>Works best with:</strong></p>"
<< "<ul>"
<< "<li>Static HTML websites</li>"
<< "<li>Server-side rendered pages</li>"
<< "<li>Documentation sites</li>"
<< "<li>News sites with HTML content</li>"
<< "<li>Blogs with traditional HTML</li>"
<< "</ul>"
<< "<p><strong>Example sites that work well:</strong></p>"
<< "<p>- https://example.com</p>"
<< "<p>- https://en.wikipedia.org</p>"
<< "<p>- Text-based news sites</p>"
<< "<p><strong>For JavaScript-heavy sites:</strong> You may need to find alternative URLs "
<< "that provide the same content in plain HTML format.</p>"
<< "</body></html>";
current_doc = html_parser.parse(help_html.str(), "help://");
rendered_lines = renderer.render(current_doc, screen_width);
scroll_pos = 0;
current_link = -1;
status_message = "Help - Press q to return";
}
};
Browser::Browser() : pImpl(std::make_unique<Impl>()) {
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
pImpl->status_message = msg;
});
}
Browser::~Browser() = default;
void Browser::run(const std::string& initial_url) {
pImpl->init_screen();
if (!initial_url.empty()) {
load_url(initial_url);
} else {
pImpl->show_help();
}
bool running = true;
while (running) {
pImpl->draw_screen();
refresh();
int ch = getch();
if (ch == ERR) {
napms(50);
continue;
}
// Handle mouse events
if (ch == KEY_MOUSE) {
MEVENT event;
if (getmouse(&event) == OK) {
pImpl->handle_mouse(event);
}
continue;
}
auto result = pImpl->input_handler.handle_key(ch);
if (result.action == Action::QUIT) {
running = false;
} else if (result.action != Action::NONE) {
pImpl->handle_action(result);
}
}
pImpl->cleanup_screen();
}
bool Browser::load_url(const std::string& url) {
return pImpl->load_page(url);
}
std::string Browser::get_current_url() const {
return pImpl->current_url;
}

23
src/browser.h Normal file
View file

@ -0,0 +1,23 @@
#pragma once
#include "http_client.h"
#include "html_parser.h"
#include "text_renderer.h"
#include "input_handler.h"
#include <string>
#include <vector>
#include <memory>
class Browser {
public:
Browser();
~Browser();
void run(const std::string& initial_url = "");
bool load_url(const std::string& url);
std::string get_current_url() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};

View file

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

View file

@ -1,6 +0,0 @@
#pragma once
class Calendar {
public:
void run();
};

619
src/html_parser.cpp Normal file
View file

@ -0,0 +1,619 @@
#include "html_parser.h"
#include <regex>
#include <algorithm>
#include <cctype>
#include <sstream>
#include <functional>
class HtmlParser::Impl {
public:
bool keep_code_blocks = true;
bool keep_lists = true;
// Remove HTML tags
std::string remove_tags(const std::string& html) {
std::string result;
bool in_tag = false;
for (char c : html) {
if (c == '<') {
in_tag = true;
} else if (c == '>') {
in_tag = false;
} else if (!in_tag) {
result += c;
}
}
return result;
}
// Decode HTML entities (named and numeric)
std::string decode_html_entities(const std::string& text) {
static const std::vector<std::pair<std::string, std::string>> named_entities = {
{"&nbsp;", " "},
{"&amp;", "&"},
{"&lt;", "<"},
{"&gt;", ">"},
{"&quot;", "\""},
{"&apos;", "'"},
{"&#39;", "'"},
{"&mdash;", "\u2014"},
{"&ndash;", "\u2013"},
{"&hellip;", "..."},
{"&ldquo;", "\u201C"},
{"&rdquo;", "\u201D"},
{"&lsquo;", "\u2018"},
{"&rsquo;", "\u2019"}
};
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);
pos += replacement.length();
}
}
// Replace numeric entities (&#123; and &#xAB;)
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);
std::smatch match;
if (std::regex_search(html, match, tag_regex)) {
return match[1].str();
}
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 + ">",
std::regex::icase);
auto begin = std::sregex_iterator(html.begin(), html.end(), tag_regex);
auto end = std::sregex_iterator();
for (std::sregex_iterator i = begin; i != end; ++i) {
std::smatch match = *i;
results.push_back(match[1].str());
}
return results;
}
// 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>)",
std::regex::icase);
auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex);
auto end = std::sregex_iterator();
int position = 0;
for (std::sregex_iterator i = begin; i != end; ++i) {
std::smatch match = *i;
Link link;
link.url = match[1].str();
link.text = decode_html_entities(remove_tags(match[2].str()));
link.position = position++;
// 处理相对URL
if (!link.url.empty() && link.url[0] != '#') {
// 如果是相对路径
if (link.url.find("://") == std::string::npos) {
// 提取base_url的协议和域名
std::regex base_regex(R"((https?://[^/]+)(/.*)?)", std::regex::icase);
std::smatch base_match;
if (std::regex_match(base_url, base_match, base_regex)) {
std::string base_domain = base_match[1].str();
std::string base_path = base_match[2].str();
if (link.url[0] == '/') {
// 绝对路径(从根目录开始)
link.url = base_domain + link.url;
} else {
// 相对路径
// 获取当前页面的目录
size_t last_slash = base_path.rfind('/');
std::string current_dir = (last_slash != std::string::npos)
? base_path.substr(0, last_slash + 1)
: "/";
link.url = base_domain + current_dir + link.url;
}
}
}
// 过滤空链接文本
if (!link.text.empty()) {
links.push_back(link);
}
}
}
return links;
}
// 从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)) {
++start;
}
auto end = str.end();
do {
--end;
} while (std::distance(start, end) > 0 && std::isspace(*end));
return std::string(start, end + 1);
}
// 移除脚本和样式
std::string remove_scripts_and_styles(const std::string& html) {
std::string result = html;
// 移除script标签
result = std::regex_replace(result,
std::regex("<script[^>]*>[\\s\\S]*?</script>", std::regex::icase),
"");
// 移除style标签
result = std::regex_replace(result,
std::regex("<style[^>]*>[\\s\\S]*?</style>", std::regex::icase),
"");
return result;
}
// 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>()) {}
HtmlParser::~HtmlParser() = default;
ParsedDocument HtmlParser::parse(const std::string& html, const std::string& base_url) {
ParsedDocument doc;
doc.url = base_url;
// 清理HTML
std::string clean_html = pImpl->remove_scripts_and_styles(html);
// 提取标题
std::string title_content = pImpl->extract_tag_content(clean_html, "title");
doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(title_content)));
if (doc.title.empty()) {
std::string h1_content = pImpl->extract_tag_content(clean_html, "h1");
doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(h1_content)));
}
// 提取主要内容区域article, main, 或 body
std::string main_content = pImpl->extract_tag_content(clean_html, "article");
if (main_content.empty()) {
main_content = pImpl->extract_tag_content(clean_html, "main");
}
if (main_content.empty()) {
main_content = pImpl->extract_tag_content(clean_html, "body");
}
if (main_content.empty()) {
main_content = clean_html;
}
// 提取链接
doc.links = pImpl->extract_links(main_content, base_url);
// 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;
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) {
// 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) {
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);
}
}
// 如果内容很少尝试提取div中的文本
if (doc.elements.size() < 3) {
auto divs = pImpl->extract_all_tags(main_content, "div");
for (const auto& div : divs) {
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(div)));
if (!text.empty() && text.length() > 20) { // 忽略太短的div
ContentElement elem;
elem.type = ElementType::PARAGRAPH;
elem.text = text;
elem.level = 0;
elem.list_number = 0;
elem.nesting_level = 0;
doc.elements.push_back(elem);
}
}
}
// 如果仍然没有内容,尝试提取整个文本
if (doc.elements.empty()) {
std::string all_text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(main_content)));
if (!all_text.empty()) {
// 按换行符分割
std::istringstream iss(all_text);
std::string line;
while (std::getline(iss, line)) {
line = pImpl->trim(line);
if (!line.empty() && line.length() > 1) {
ContentElement elem;
elem.type = ElementType::PARAGRAPH;
elem.text = line;
elem.level = 0;
elem.list_number = 0;
elem.nesting_level = 0;
doc.elements.push_back(elem);
}
}
}
}
return doc;
}
void HtmlParser::set_keep_code_blocks(bool keep) {
pImpl->keep_code_blocks = keep;
}
void HtmlParser::set_keep_lists(bool keep) {
pImpl->keep_lists = keep;
}

122
src/html_parser.h Normal file
View file

@ -0,0 +1,122 @@
#pragma once
#include <string>
#include <vector>
#include <memory>
enum class ElementType {
TEXT,
HEADING1,
HEADING2,
HEADING3,
HEADING4,
HEADING5,
HEADING6,
PARAGRAPH,
LINK,
LIST_ITEM,
ORDERED_LIST_ITEM,
BLOCKQUOTE,
CODE_BLOCK,
HORIZONTAL_RULE,
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;
};
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;
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 {
std::string title;
std::string url;
std::vector<ContentElement> elements;
std::vector<Link> links;
};
class HtmlParser {
public:
HtmlParser();
~HtmlParser();
ParsedDocument parse(const std::string& html, const std::string& base_url = "");
void set_keep_code_blocks(bool keep);
void set_keep_lists(bool keep);
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};

111
src/http_client.cpp Normal file
View file

@ -0,0 +1,111 @@
#include "http_client.h"
#include <curl/curl.h>
#include <stdexcept>
// 回调函数用于接收数据
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t total_size = size * nmemb;
userp->append(static_cast<char*>(contents), total_size);
return total_size;
}
class HttpClient::Impl {
public:
CURL* curl;
long timeout;
std::string user_agent;
bool follow_redirects;
Impl() : timeout(30),
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
follow_redirects(true) {
curl = curl_easy_init();
if (!curl) {
throw std::runtime_error("Failed to initialize CURL");
}
}
~Impl() {
if (curl) {
curl_easy_cleanup(curl);
}
}
};
HttpClient::HttpClient() : pImpl(std::make_unique<Impl>()) {}
HttpClient::~HttpClient() = default;
HttpResponse HttpClient::fetch(const std::string& url) {
HttpResponse response;
response.status_code = 0;
if (!pImpl->curl) {
response.error_message = "CURL not initialized";
return response;
}
// 重置选项
curl_easy_reset(pImpl->curl);
// 设置URL
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
// 设置写回调
std::string response_body;
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_body);
// 设置超时
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
// 设置用户代理
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
// 设置是否跟随重定向
if (pImpl->follow_redirects) {
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
}
// 支持 HTTPS
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
// 执行请求
CURLcode res = curl_easy_perform(pImpl->curl);
if (res != CURLE_OK) {
response.error_message = curl_easy_strerror(res);
return response;
}
// 获取响应码
long http_code = 0;
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
response.status_code = static_cast<int>(http_code);
// 获取 Content-Type
char* content_type = nullptr;
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type);
if (content_type) {
response.content_type = content_type;
}
response.body = std::move(response_body);
return response;
}
void HttpClient::set_timeout(long timeout_seconds) {
pImpl->timeout = timeout_seconds;
}
void HttpClient::set_user_agent(const std::string& user_agent) {
pImpl->user_agent = user_agent;
}
void HttpClient::set_follow_redirects(bool follow) {
pImpl->follow_redirects = follow;
}

30
src/http_client.h Normal file
View file

@ -0,0 +1,30 @@
#pragma once
#include <string>
#include <memory>
struct HttpResponse {
int status_code;
std::string body;
std::string content_type;
std::string error_message;
bool is_success() const {
return status_code >= 200 && status_code < 300;
}
};
class HttpClient {
public:
HttpClient();
~HttpClient();
HttpResponse fetch(const std::string& url);
void set_timeout(long timeout_seconds);
void set_user_agent(const std::string& user_agent);
void set_follow_redirects(bool follow);
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};

View file

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

View file

@ -1,8 +0,0 @@
#pragma once
#include <string>
// 从给定 URL 获取 ICS 文本,失败抛出 std::runtime_error
std::string fetch_ics(const std::string &url);

View file

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

View file

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

370
src/input_handler.cpp Normal file
View file

@ -0,0 +1,370 @@
#include "input_handler.h"
#include <curses.h>
#include <cctype>
#include <sstream>
class InputHandler::Impl {
public:
InputMode mode = InputMode::NORMAL;
std::string buffer;
std::string count_buffer;
std::function<void(const std::string&)> status_callback;
void set_status(const std::string& msg) {
if (status_callback) {
status_callback(msg);
}
}
InputResult process_normal_mode(int ch) {
InputResult result;
result.action = Action::NONE;
result.number = 0;
result.has_count = false;
result.count = 1;
// 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;
}
if (!count_buffer.empty()) {
result.has_count = true;
result.count = std::stoi(count_buffer);
}
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';
if (buffer == "gg") {
result.action = Action::GOTO_TOP;
buffer.clear();
}
count_buffer.clear();
break;
case 'G':
if (result.has_count) {
result.action = Action::GOTO_LINE;
result.number = result.count;
} 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':
// 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;
buffer = ":";
break;
case 'r':
result.action = Action::REFRESH;
break;
case 'q':
result.action = Action::QUIT;
break;
case '?':
result.action = Action::HELP;
break;
default:
buffer.clear();
break;
}
return result;
}
InputResult process_command_mode(int ch) {
InputResult result;
result.action = Action::NONE;
if (ch == '\n' || ch == '\r') {
std::string command = buffer.substr(1);
if (command == "q" || command == "quit") {
result.action = Action::QUIT;
} else if (command == "h" || command == "help") {
result.action = Action::HELP;
} else if (command == "r" || command == "refresh") {
result.action = Action::REFRESH;
} else if (command.rfind("o ", 0) == 0 || command.rfind("open ", 0) == 0) {
size_t space_pos = command.find(' ');
if (space_pos != std::string::npos) {
result.action = Action::OPEN_URL;
result.text = command.substr(space_pos + 1);
}
} else if (!command.empty() && std::isdigit(command[0])) {
try {
result.action = Action::GOTO_LINE;
result.number = std::stoi(command);
} catch (...) {
set_status("Invalid line number");
}
}
mode = InputMode::NORMAL;
buffer.clear();
} else if (ch == 27) {
mode = InputMode::NORMAL;
buffer.clear();
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
if (buffer.length() > 1) {
buffer.pop_back();
} else {
mode = InputMode::NORMAL;
buffer.clear();
}
} else if (std::isprint(ch)) {
buffer += static_cast<char>(ch);
}
return result;
}
InputResult process_search_mode(int ch) {
InputResult result;
result.action = Action::NONE;
if (ch == '\n' || ch == '\r') {
if (buffer.length() > 1) {
result.action = Action::SEARCH_FORWARD;
result.text = buffer.substr(1);
}
mode = InputMode::NORMAL;
buffer.clear();
} else if (ch == 27) {
mode = InputMode::NORMAL;
buffer.clear();
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
if (buffer.length() > 1) {
buffer.pop_back();
} else {
mode = InputMode::NORMAL;
buffer.clear();
}
} else if (std::isprint(ch)) {
buffer += static_cast<char>(ch);
}
return result;
}
InputResult process_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>()) {}
InputHandler::~InputHandler() = default;
InputResult InputHandler::handle_key(int ch) {
switch (pImpl->mode) {
case InputMode::NORMAL:
return pImpl->process_normal_mode(ch);
case InputMode::COMMAND:
return pImpl->process_command_mode(ch);
case InputMode::SEARCH:
return pImpl->process_search_mode(ch);
case InputMode::LINK:
return pImpl->process_link_mode(ch);
case InputMode::LINK_HINTS:
return pImpl->process_link_hints_mode(ch);
default:
break;
}
InputResult result;
result.action = Action::NONE;
return result;
}
InputMode InputHandler::get_mode() const {
return pImpl->mode;
}
std::string InputHandler::get_buffer() const {
return pImpl->buffer;
}
void InputHandler::reset() {
pImpl->mode = InputMode::NORMAL;
pImpl->buffer.clear();
pImpl->count_buffer.clear();
}
void InputHandler::set_status_callback(std::function<void(const std::string&)> callback) {
pImpl->status_callback = callback;
}

66
src/input_handler.h Normal file
View file

@ -0,0 +1,66 @@
#pragma once
#include <string>
#include <functional>
#include <memory>
enum class InputMode {
NORMAL,
COMMAND,
SEARCH,
LINK,
LINK_HINTS // Vimium-style 'f' mode
};
enum class Action {
NONE,
SCROLL_UP,
SCROLL_DOWN,
SCROLL_PAGE_UP,
SCROLL_PAGE_DOWN,
GOTO_TOP,
GOTO_BOTTOM,
GOTO_LINE,
SEARCH_FORWARD,
SEARCH_NEXT,
SEARCH_PREV,
NEXT_LINK,
PREV_LINK,
FOLLOW_LINK,
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,
SET_MARK, // Set a mark (m + letter)
GOTO_MARK // Jump to mark (' + letter)
};
struct InputResult {
Action action;
std::string text;
int number;
bool has_count;
int count;
};
class InputHandler {
public:
InputHandler();
~InputHandler();
InputResult handle_key(int ch);
InputMode get_mode() const;
std::string get_buffer() const;
void reset();
void set_status_callback(std::function<void(const std::string&)> callback);
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};

View file

@ -1,22 +1,45 @@
#include "tui_view.h"
#include "calendar.h"
#include "browser.h"
#include <iostream>
#include <cstring>
int main() {
display_splash_screen(); // Display splash screen at startup
void print_usage(const char* prog_name) {
std::cout << "TUT - Terminal User Interface Browser\n"
<< "A vim-style terminal web browser for comfortable reading\n\n"
<< "Usage: " << prog_name << " [URL]\n\n"
<< "If no URL is provided, the browser will start with a help page.\n\n"
<< "Examples:\n"
<< " " << prog_name << "\n"
<< " " << prog_name << " https://example.com\n"
<< " " << prog_name << " https://news.ycombinator.com\n\n"
<< "Vim-style keybindings:\n"
<< " j/k - Scroll down/up\n"
<< " gg/G - Go to top/bottom\n"
<< " / - Search\n"
<< " Tab - Next link\n"
<< " Enter - Follow link\n"
<< " h/l - Back/Forward\n"
<< " :o URL - Open URL\n"
<< " :q - Quit\n"
<< " ? - Show help\n";
}
while (true) {
int choice = run_portal_tui();
int main(int argc, char* argv[]) {
std::string initial_url;
switch (choice) {
case 0: { // Calendar
Calendar calendar;
calendar.run();
break;
}
case 1: { // Exit
return 0;
}
if (argc > 1) {
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
print_usage(argv[0]);
return 0;
}
initial_url = argv[1];
}
try {
Browser browser;
browser.run(initial_url);
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;

731
src/text_renderer.cpp Normal file
View file

@ -0,0 +1,731 @@
#include "text_renderer.h"
#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()) {
return lines;
}
std::istringstream words_stream(text);
std::string word;
std::string current_line;
while (words_stream >> word) {
if (word.length() > static_cast<size_t>(width)) {
if (!current_line.empty()) {
lines.push_back(current_line);
current_line.clear();
}
for (size_t i = 0; i < word.length(); i += width) {
lines.push_back(word.substr(i, width));
}
continue;
}
if (current_line.empty()) {
current_line = word;
} else if (current_line.length() + 1 + word.length() <= static_cast<size_t>(width)) {
current_line += " " + word;
} else {
lines.push_back(current_line);
current_line = word;
}
}
if (!current_line.empty()) {
lines.push_back(current_line);
}
if (lines.empty()) {
lines.push_back("");
}
return lines;
}
// 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>()) {
pImpl->config = RenderConfig();
}
TextRenderer::~TextRenderer() = default;
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
std::vector<RenderedLine> lines;
int content_width = std::min(pImpl->config.max_width, screen_width - 4);
if (content_width < 40) {
content_width = screen_width - 4;
}
int margin = 0;
if (pImpl->config.center_content && content_width < screen_width) {
margin = (screen_width - content_width) / 2;
}
pImpl->config.margin_left = margin;
if (!doc.title.empty()) {
RenderedLine title_line;
title_line.text = std::string(margin, ' ') + doc.title;
title_line.color_pair = COLOR_HEADING1;
title_line.is_bold = true;
title_line.is_link = false;
title_line.link_index = -1;
lines.push_back(title_line);
RenderedLine underline;
underline.text = std::string(margin, ' ') + std::string(std::min((int)doc.title.length(), content_width), '=');
underline.color_pair = COLOR_HEADING1;
underline.is_bold = false;
underline.is_link = false;
underline.link_index = -1;
lines.push_back(underline);
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
}
if (!doc.url.empty()) {
RenderedLine url_line;
url_line.text = std::string(margin, ' ') + "URL: " + doc.url;
url_line.color_pair = COLOR_URL_BAR;
url_line.is_bold = false;
url_line.is_link = false;
url_line.link_index = -1;
lines.push_back(url_line);
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
}
for (const auto& elem : doc.elements) {
int color = COLOR_NORMAL;
bool bold = false;
std::string prefix = "";
switch (elem.type) {
case ElementType::HEADING1:
color = COLOR_HEADING1;
bold = true;
prefix = "# ";
break;
case ElementType::HEADING2:
color = COLOR_HEADING2;
bold = true;
prefix = "## ";
break;
case ElementType::HEADING3:
color = COLOR_HEADING3;
bold = true;
prefix = "### ";
break;
case ElementType::PARAGRAPH:
color = COLOR_NORMAL;
bold = false;
break;
case ElementType::BLOCKQUOTE:
color = COLOR_DIM;
prefix = "> ";
break;
case ElementType::LIST_ITEM:
{
// 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;
std::string hrline(content_width, '-');
hr.text = std::string(margin, ' ') + hrline;
hr.color_pair = COLOR_DIM;
hr.is_bold = false;
hr.is_link = false;
hr.link_index = -1;
lines.push_back(hr);
continue;
}
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_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 + line_text;
} else {
line.text = std::string(margin + prefix.length(), ' ') + line_text;
}
line.color_pair = color;
line.is_bold = bold;
// 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);
}
if (elem.type == ElementType::PARAGRAPH ||
elem.type == ElementType::HEADING1 ||
elem.type == ElementType::HEADING2 ||
elem.type == ElementType::HEADING3) {
for (int i = 0; i < pImpl->config.paragraph_spacing; ++i) {
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
}
}
}
// 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;
separator.color_pair = COLOR_DIM;
separator.is_bold = false;
separator.is_link = false;
separator.link_index = -1;
lines.push_back(separator);
RenderedLine links_header;
links_header.text = std::string(margin, ' ') + "Links:";
links_header.color_pair = COLOR_HEADING3;
links_header.is_bold = true;
links_header.is_link = false;
links_header.link_index = -1;
lines.push_back(links_header);
RenderedLine empty;
empty.text = "";
empty.color_pair = COLOR_NORMAL;
empty.is_bold = false;
empty.is_link = false;
empty.link_index = -1;
lines.push_back(empty);
for (size_t i = 0; i < doc.links.size(); ++i) {
const auto& link = doc.links[i];
std::string link_text = "[" + std::to_string(i) + "] " + link.text;
auto wrapped = pImpl->wrap_text(link_text, content_width - 4);
for (size_t j = 0; j < wrapped.size(); ++j) {
RenderedLine link_line;
link_line.text = std::string(margin + 2, ' ') + wrapped[j];
link_line.color_pair = COLOR_LINK;
link_line.is_bold = false;
link_line.is_link = true;
link_line.link_index = i;
lines.push_back(link_line);
}
auto url_wrapped = pImpl->wrap_text(link.url, content_width - 6);
for (const auto& url_line_text : url_wrapped) {
RenderedLine url_line;
url_line.text = std::string(margin + 4, ' ') + "" + url_line_text;
url_line.color_pair = COLOR_DIM;
url_line.is_bold = false;
url_line.is_link = false;
url_line.link_index = -1;
lines.push_back(url_line);
}
lines.push_back(empty);
}
}
return lines;
}
void TextRenderer::set_config(const RenderConfig& config) {
pImpl->config = config;
}
RenderConfig TextRenderer::get_config() const {
return pImpl->config;
}
void init_color_scheme() {
if (has_colors()) {
start_color();
use_default_colors();
init_pair(COLOR_NORMAL, COLOR_WHITE, -1);
init_pair(COLOR_HEADING1, COLOR_CYAN, -1);
init_pair(COLOR_HEADING2, COLOR_BLUE, -1);
init_pair(COLOR_HEADING3, COLOR_MAGENTA, -1);
init_pair(COLOR_LINK, COLOR_YELLOW, -1);
init_pair(COLOR_LINK_ACTIVE, COLOR_BLACK, COLOR_YELLOW);
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
init_pair(COLOR_URL_BAR, COLOR_GREEN, -1);
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
init_pair(COLOR_DIM, COLOR_BLACK, -1);
}
}

53
src/text_renderer.h Normal file
View file

@ -0,0 +1,53 @@
#pragma once
#include "html_parser.h"
#include <string>
#include <vector>
#include <memory>
#include <curses.h>
struct RenderedLine {
std::string text;
int color_pair;
bool is_bold;
bool is_link;
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 = false; // Set to false to show inline links by default
};
class TextRenderer {
public:
TextRenderer();
~TextRenderer();
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
void set_config(const RenderConfig& config);
RenderConfig get_config() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};
enum ColorScheme {
COLOR_NORMAL = 1,
COLOR_HEADING1,
COLOR_HEADING2,
COLOR_HEADING3,
COLOR_LINK,
COLOR_LINK_ACTIVE,
COLOR_STATUS_BAR,
COLOR_URL_BAR,
COLOR_SEARCH_HIGHLIGHT,
COLOR_DIM
};
void init_color_scheme();

View file

@ -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();
}

View file

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