mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 09:04:04 +00:00
Compare commits
7 commits
7ac0fc1c91
...
1233ae52ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 1233ae52ca | |||
| 63fbee6d30 | |||
| c7c11e08f8 | |||
| 5e2850f7d3 | |||
| 58b7607074 | |||
| 7e55ade793 | |||
| 55fc7c79f5 |
11 changed files with 689 additions and 29 deletions
|
|
@ -1,9 +1,9 @@
|
|||
# TUT 2.0 - 下次继续从这里开始
|
||||
|
||||
## 当前位置
|
||||
- **阶段**: Phase 7 - 历史记录持久化 (已完成!)
|
||||
- **进度**: 历史记录自动保存,支持 :history 命令查看
|
||||
- **最后提交**: `feat: Add persistent browsing history`
|
||||
- **阶段**: Phase 9 - 性能优化和测试工具 (已完成!)
|
||||
- **进度**: 图片缓存、测试工具、文档完善
|
||||
- **最后提交**: `feat: Add comprehensive testing tools and improve help`
|
||||
|
||||
## 立即可做的事
|
||||
|
||||
|
|
@ -19,8 +19,40 @@
|
|||
|
||||
历史记录存储在 `~/.config/tut/history.json`
|
||||
|
||||
### 3. 表单交互
|
||||
- **i** - 聚焦到第一个表单字段
|
||||
- **Tab** - 下一个表单字段
|
||||
- **Shift+Tab** - 上一个表单字段
|
||||
- **Enter** - 激活字段(文本输入/下拉选择/复选框)
|
||||
- 在文本输入模式下:
|
||||
- 输入文字实时更新
|
||||
- **Enter** 或 **Esc** - 退出编辑模式
|
||||
- 在下拉选择模式下:
|
||||
- **j/k** 或 **↓/↑** - 导航选项
|
||||
- **Enter** - 选择当前选项
|
||||
- **Esc** - 取消选择
|
||||
|
||||
## 已完成的功能清单
|
||||
|
||||
### Phase 9 - 性能优化和测试工具
|
||||
- [x] 图片 LRU 缓存 (100张,10分钟过期)
|
||||
- [x] 缓存命中统计显示
|
||||
- [x] 交互式测试脚本 (test_browser.sh)
|
||||
- [x] 完整测试指南 (TESTING.md)
|
||||
- [x] 帮助文档更新(包含所有新功能)
|
||||
- [x] 测试清单和成功标准
|
||||
|
||||
### Phase 8 - 表单交互增强
|
||||
- [x] 文本输入框编辑
|
||||
- [x] 实时文本编辑和预览
|
||||
- [x] Tab/Shift+Tab 字段导航
|
||||
- [x] 复选框切换
|
||||
- [x] 下拉选择(SELECT/OPTION)
|
||||
- [x] SELECT 选项解析和存储
|
||||
- [x] j/k 导航选项
|
||||
- [x] 状态栏显示 INSERT/SELECT 模式
|
||||
- [x] 'i' 键聚焦首个表单字段
|
||||
|
||||
### Phase 7 - 历史记录持久化
|
||||
- [x] HistoryEntry 数据结构 (URL, 标题, 访问时间)
|
||||
- [x] JSON 持久化存储 (~/.config/tut/history.json)
|
||||
|
|
@ -142,8 +174,9 @@ cmake --build build
|
|||
| j/k | 上下滚动 |
|
||||
| Ctrl+d/u | 翻页 |
|
||||
| gg/G | 顶部/底部 |
|
||||
| Tab/Shift+Tab | 切换链接 |
|
||||
| Enter | 跟随链接 |
|
||||
| Tab/Shift+Tab | 切换链接/表单字段 |
|
||||
| Enter | 跟随链接/激活字段 |
|
||||
| i | 聚焦首个表单字段 |
|
||||
| h/l | 后退/前进 |
|
||||
| / | 搜索 |
|
||||
| n/N | 下一个/上一个匹配 |
|
||||
|
|
@ -155,18 +188,27 @@ cmake --build build
|
|||
| :history | 查看历史 |
|
||||
| :q | 退出 |
|
||||
| ? | 帮助 |
|
||||
| Esc | 取消加载 |
|
||||
| Esc | 取消加载/退出编辑 |
|
||||
|
||||
**表单编辑模式** (INSERT):
|
||||
- 输入字符 - 编辑文本
|
||||
- Enter/Esc - 完成编辑
|
||||
|
||||
**下拉选择模式** (SELECT):
|
||||
- j/k, ↓/↑ - 导航选项
|
||||
- Enter - 选择选项
|
||||
- Esc - 取消选择
|
||||
|
||||
## 下一步功能优先级
|
||||
|
||||
1. **更多表单交互** - 文本输入编辑,下拉选择
|
||||
2. **图片缓存** - 避免重复下载相同图片
|
||||
3. **异步图片加载** - 图片也使用异步加载
|
||||
4. **Cookie 支持** - 保存和发送 Cookie
|
||||
1. **异步图片加载** - 图片也使用异步加载
|
||||
2. **Cookie 支持** - 保存和发送 Cookie
|
||||
3. **表单提交** - 实现 POST 表单提交
|
||||
4. **更多HTML5支持** - 更完善的HTML渲染
|
||||
|
||||
## 恢复对话时说
|
||||
|
||||
> "继续TUT 2.0开发"
|
||||
> "continue"
|
||||
|
||||
## Git 信息
|
||||
|
||||
|
|
@ -182,5 +224,24 @@ cmake --build build
|
|||
./build/tut
|
||||
```
|
||||
|
||||
## 测试指南
|
||||
|
||||
查看 `TESTING.md` 获取完整测试指南,或运行:
|
||||
|
||||
```bash
|
||||
./test_browser.sh
|
||||
```
|
||||
|
||||
## 浏览器特性总结
|
||||
|
||||
✓ **核心功能** - 异步HTTP加载、页面缓存、差分渲染
|
||||
✓ **导航** - 滚动、链接、历史记录
|
||||
✓ **搜索** - 全文搜索、高亮、导航
|
||||
✓ **表单** - 文本输入、复选框、下拉选择
|
||||
✓ **书签** - 持久化书签管理
|
||||
✓ **历史** - 浏览历史记录
|
||||
✓ **图片** - ASCII艺术渲染、智能缓存
|
||||
✓ **性能** - LRU缓存、差分渲染、异步加载
|
||||
|
||||
---
|
||||
更新时间: 2025-12-27
|
||||
更新时间: 2025-12-28
|
||||
|
|
|
|||
146
TESTING.md
Normal file
146
TESTING.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# TUT Browser Testing Guide
|
||||
|
||||
This document provides comprehensive testing instructions to ensure the browser works correctly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build the browser
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
|
||||
# Run with a test site
|
||||
./build/tut http://example.com
|
||||
|
||||
# Or use the interactive test script
|
||||
./test_browser.sh
|
||||
```
|
||||
|
||||
## Feature Testing Checklist
|
||||
|
||||
### Basic Navigation
|
||||
- [ ] Browser loads and displays page correctly
|
||||
- [ ] Scroll with j/k works smoothly
|
||||
- [ ] Page up/down (Ctrl+d/u) works
|
||||
- [ ] Go to top (gg) and bottom (G) works
|
||||
- [ ] Back (h) and forward (l) navigation works
|
||||
|
||||
### Link Navigation
|
||||
- [ ] Tab cycles through links
|
||||
- [ ] Shift+Tab goes to previous link
|
||||
- [ ] Enter follows the active link
|
||||
- [ ] Links are highlighted when active
|
||||
- [ ] Link URLs shown in status bar
|
||||
|
||||
### Search
|
||||
- [ ] Press / to enter search mode
|
||||
- [ ] Type search term and press Enter
|
||||
- [ ] Matches are highlighted
|
||||
- [ ] n/N navigate between matches
|
||||
- [ ] Search count shown in status bar
|
||||
|
||||
### Form Interaction
|
||||
- [ ] Press 'i' to focus first form field
|
||||
- [ ] Tab/Shift+Tab navigate between fields
|
||||
- [ ] Enter on text input enters edit mode
|
||||
- [ ] Text can be typed and edited
|
||||
- [ ] Backspace removes characters
|
||||
- [ ] Enter or Esc exits edit mode
|
||||
- [ ] Checkbox toggles with Enter
|
||||
- [ ] SELECT dropdown shows options
|
||||
- [ ] j/k navigate dropdown options
|
||||
- [ ] Enter selects option in dropdown
|
||||
- [ ] Selected option displays correctly
|
||||
|
||||
### Bookmarks
|
||||
- [ ] Press B to bookmark current page
|
||||
- [ ] Press D to remove bookmark
|
||||
- [ ] Type :bookmarks to view all bookmarks
|
||||
- [ ] Bookmarks persist between sessions
|
||||
- [ ] Can click bookmarks to open pages
|
||||
|
||||
### History
|
||||
- [ ] Type :history to view browsing history
|
||||
- [ ] History shows URLs and titles
|
||||
- [ ] History entries are clickable
|
||||
- [ ] History persists between sessions
|
||||
- [ ] Recent pages appear at top
|
||||
|
||||
### Performance
|
||||
- [ ] Page loads are async with spinner
|
||||
- [ ] Esc cancels page loading
|
||||
- [ ] Page cache works (revisit loads instantly)
|
||||
- [ ] Image cache works (images load from cache)
|
||||
- [ ] Status shows "cached: N" for cached images
|
||||
- [ ] Scrolling is smooth
|
||||
- [ ] No noticeable lag in UI
|
||||
|
||||
### Commands
|
||||
- [ ] :o URL opens new URL
|
||||
- [ ] :q quits the browser
|
||||
- [ ] :bookmarks shows bookmarks
|
||||
- [ ] :history shows history
|
||||
- [ ] :help shows help page
|
||||
- [ ] ? also shows help page
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Window resize updates layout correctly
|
||||
- [ ] Very long pages scroll correctly
|
||||
- [ ] Pages without links/forms work
|
||||
- [ ] Unicode text displays correctly
|
||||
- [ ] CJK characters display correctly
|
||||
- [ ] Images render as ASCII art (if stb_image available)
|
||||
- [ ] Error handling for failed page loads
|
||||
|
||||
## Test Websites
|
||||
|
||||
### Simple Test Sites
|
||||
1. **http://example.com** - Basic HTML test
|
||||
2. **http://info.cern.ch** - First website ever, very simple
|
||||
3. **http://motherfuckingwebsite.com** - Minimalist design
|
||||
4. **http://textfiles.com** - Text-only content
|
||||
|
||||
### Form Testing
|
||||
Create a local test file (test_form.html is provided):
|
||||
```bash
|
||||
python3 -m http.server 8000
|
||||
./build/tut http://localhost:8000/test_form.html
|
||||
```
|
||||
|
||||
Test the form features:
|
||||
- Text input editing
|
||||
- Checkbox toggling
|
||||
- Dropdown selection
|
||||
- Tab navigation
|
||||
|
||||
### Performance Testing
|
||||
1. Load a page
|
||||
2. Press 'r' to refresh (should use cache)
|
||||
3. Load the same page again (should be instant from cache)
|
||||
4. Check status bar shows "cached" messages
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- HTTPS support depends on libcurl configuration
|
||||
- Some complex JavaScript-heavy sites won't work (static HTML only)
|
||||
- File:// URLs may not work depending on curl configuration
|
||||
- Form submission is not yet implemented
|
||||
- Cookies are not yet supported
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues, please include:
|
||||
1. The URL you were trying to load
|
||||
2. The exact steps to reproduce
|
||||
3. Expected vs actual behavior
|
||||
4. Any error messages
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The browser is working correctly if:
|
||||
1. ✓ Can load and display simple HTML pages
|
||||
2. ✓ Navigation (scroll, links) works smoothly
|
||||
3. ✓ Form interaction is responsive and intuitive
|
||||
4. ✓ Bookmarks and history persist correctly
|
||||
5. ✓ Caching improves performance noticeably
|
||||
6. ✓ No crashes or hangs during normal use
|
||||
202
src/browser.cpp
202
src/browser.cpp
|
|
@ -42,6 +42,18 @@ struct CacheEntry {
|
|||
}
|
||||
};
|
||||
|
||||
// 图片缓存条目
|
||||
struct ImageCacheEntry {
|
||||
tut::ImageData image_data;
|
||||
std::chrono::steady_clock::time_point timestamp;
|
||||
|
||||
bool is_expired(int max_age_seconds = 600) const { // 图片缓存10分钟
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto age = std::chrono::duration_cast<std::chrono::seconds>(now - timestamp).count();
|
||||
return age > max_age_seconds;
|
||||
}
|
||||
};
|
||||
|
||||
class Browser::Impl {
|
||||
public:
|
||||
// 网络和解析
|
||||
|
|
@ -85,6 +97,11 @@ public:
|
|||
static constexpr int CACHE_MAX_AGE = 300; // 5分钟缓存
|
||||
static constexpr size_t CACHE_MAX_SIZE = 20; // 最多缓存20个页面
|
||||
|
||||
// 图片缓存
|
||||
std::map<std::string, ImageCacheEntry> image_cache;
|
||||
static constexpr int IMAGE_CACHE_MAX_AGE = 600; // 10分钟缓存
|
||||
static constexpr size_t IMAGE_CACHE_MAX_SIZE = 100; // 最多缓存100张图片
|
||||
|
||||
// 异步加载状态
|
||||
LoadingState loading_state = LoadingState::IDLE;
|
||||
std::string pending_url; // 正在加载的URL
|
||||
|
|
@ -380,6 +397,7 @@ public:
|
|||
}
|
||||
|
||||
int loaded = 0;
|
||||
int cached = 0;
|
||||
int total = static_cast<int>(tree.images.size());
|
||||
|
||||
for (DomNode* img_node : tree.images) {
|
||||
|
|
@ -387,9 +405,22 @@ public:
|
|||
continue;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
loaded++;
|
||||
status_message = "🖼 Loading image " + std::to_string(loaded) + "/" + std::to_string(total) + "...";
|
||||
|
||||
// 检查缓存
|
||||
auto cache_it = image_cache.find(img_node->img_src);
|
||||
if (cache_it != image_cache.end() && !cache_it->second.is_expired(IMAGE_CACHE_MAX_AGE)) {
|
||||
// 使用缓存的图片
|
||||
img_node->image_data = cache_it->second.image_data;
|
||||
cached++;
|
||||
status_message = "🖼 Loading image " + std::to_string(loaded) + "/" + std::to_string(total) +
|
||||
" (cached: " + std::to_string(cached) + ")";
|
||||
draw_screen();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
status_message = "🖼 Downloading image " + std::to_string(loaded) + "/" + std::to_string(total) + "...";
|
||||
draw_screen();
|
||||
|
||||
// 下载图片
|
||||
|
|
@ -401,9 +432,32 @@ public:
|
|||
// 解码图片
|
||||
tut::ImageData img_data = tut::ImageRenderer::load_from_memory(response.data);
|
||||
if (img_data.is_valid()) {
|
||||
img_node->image_data = std::move(img_data);
|
||||
img_node->image_data = img_data;
|
||||
|
||||
// 添加到缓存
|
||||
// 限制缓存大小
|
||||
if (image_cache.size() >= IMAGE_CACHE_MAX_SIZE) {
|
||||
// 移除最老的缓存条目
|
||||
auto oldest = image_cache.begin();
|
||||
for (auto it = image_cache.begin(); it != image_cache.end(); ++it) {
|
||||
if (it->second.timestamp < oldest->second.timestamp) {
|
||||
oldest = it;
|
||||
}
|
||||
}
|
||||
image_cache.erase(oldest);
|
||||
}
|
||||
|
||||
ImageCacheEntry entry;
|
||||
entry.image_data = std::move(img_data);
|
||||
entry.timestamp = std::chrono::steady_clock::now();
|
||||
image_cache[img_node->img_src] = std::move(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (cached > 0) {
|
||||
status_message = "✓ Loaded " + std::to_string(total) + " images (" +
|
||||
std::to_string(cached) + " from cache)";
|
||||
}
|
||||
}
|
||||
|
||||
// 从URL中提取主机名
|
||||
|
|
@ -456,6 +510,8 @@ public:
|
|||
case InputMode::NORMAL: mode_str = "NORMAL"; break;
|
||||
case InputMode::COMMAND:
|
||||
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
|
||||
case InputMode::FORM_EDIT: mode_str = "-- INSERT -- " + input_handler.get_buffer(); break;
|
||||
case InputMode::SELECT_OPTION: mode_str = "-- SELECT --"; break;
|
||||
default: mode_str = ""; break;
|
||||
}
|
||||
framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
|
|
@ -540,7 +596,29 @@ public:
|
|||
break;
|
||||
|
||||
case Action::FOLLOW_LINK:
|
||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
||||
// If on a form field, activate it instead of following link
|
||||
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field) {
|
||||
if (field->input_type == "text" || field->input_type == "password") {
|
||||
// Enter edit mode
|
||||
input_handler.set_mode(InputMode::FORM_EDIT);
|
||||
input_handler.set_buffer(field->value);
|
||||
status_message = "-- INSERT --";
|
||||
} else if (field->input_type == "checkbox") {
|
||||
// Toggle checkbox
|
||||
field->checked = !field->checked;
|
||||
status_message = field->checked ? "☑ Checked" : "☐ Unchecked";
|
||||
} else if (field->input_type == "select") {
|
||||
// Enter dropdown selection mode
|
||||
input_handler.set_mode(InputMode::SELECT_OPTION);
|
||||
status_message = "-- SELECT -- (j/k to navigate, Enter to select, Esc to cancel)";
|
||||
} else if (field->input_type == "submit" || field->element_type == ElementType::BUTTON) {
|
||||
// TODO: Submit form
|
||||
status_message = "Form submit (not yet implemented)";
|
||||
}
|
||||
}
|
||||
} else if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
||||
start_async_load(current_tree.links[active_link].url);
|
||||
}
|
||||
break;
|
||||
|
|
@ -609,6 +687,97 @@ public:
|
|||
show_history();
|
||||
break;
|
||||
|
||||
case Action::NEXT_FIELD:
|
||||
if (!current_tree.form_fields.empty()) {
|
||||
// Save current text if in edit mode
|
||||
if (input_handler.get_mode() == InputMode::FORM_EDIT &&
|
||||
active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field && (field->input_type == "text" || field->input_type == "password")) {
|
||||
field->value = result.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next field
|
||||
if (active_field < 0) {
|
||||
active_field = 0; // First field
|
||||
} else {
|
||||
active_field = (active_field + 1) % current_tree.form_fields.size();
|
||||
}
|
||||
|
||||
// Auto-scroll to field
|
||||
// TODO: Implement scroll to field
|
||||
status_message = "Field " + std::to_string(active_field + 1) + "/" +
|
||||
std::to_string(current_tree.form_fields.size());
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::PREV_FIELD:
|
||||
if (!current_tree.form_fields.empty()) {
|
||||
// Save current text if in edit mode
|
||||
if (input_handler.get_mode() == InputMode::FORM_EDIT &&
|
||||
active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field && (field->input_type == "text" || field->input_type == "password")) {
|
||||
field->value = result.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to previous field
|
||||
if (active_field < 0) {
|
||||
active_field = current_tree.form_fields.size() - 1; // Last field
|
||||
} else {
|
||||
active_field = (active_field - 1 + current_tree.form_fields.size()) %
|
||||
current_tree.form_fields.size();
|
||||
}
|
||||
|
||||
status_message = "Field " + std::to_string(active_field + 1) + "/" +
|
||||
std::to_string(current_tree.form_fields.size());
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::EDIT_TEXT:
|
||||
// Update field value in real-time
|
||||
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field && (field->input_type == "text" || field->input_type == "password")) {
|
||||
field->value = result.text;
|
||||
status_message = "Editing: " + result.text;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::NEXT_OPTION:
|
||||
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field && field->input_type == "select" && !field->options.empty()) {
|
||||
field->selected_option = (field->selected_option + 1) % field->options.size();
|
||||
status_message = "Option: " + field->options[field->selected_option].second;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::PREV_OPTION:
|
||||
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field && field->input_type == "select" && !field->options.empty()) {
|
||||
field->selected_option = (field->selected_option - 1 + field->options.size()) %
|
||||
field->options.size();
|
||||
status_message = "Option: " + field->options[field->selected_option].second;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::SELECT_CURRENT_OPTION:
|
||||
if (active_field >= 0 && active_field < static_cast<int>(current_tree.form_fields.size())) {
|
||||
auto* field = current_tree.form_fields[active_field];
|
||||
if (field && field->input_type == "select" && !field->options.empty()) {
|
||||
field->value = field->options[field->selected_option].first;
|
||||
status_message = "Selected: " + field->options[field->selected_option].second;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::QUIT:
|
||||
break; // 在main loop处理
|
||||
|
||||
|
|
@ -810,8 +979,29 @@ public:
|
|||
|
||||
<h2>Forms</h2>
|
||||
<ul>
|
||||
<li>Tab - Navigate links and form fields</li>
|
||||
<li>Enter - Activate link or submit form</li>
|
||||
<li>i - Focus first form field</li>
|
||||
<li>Tab/Shift+Tab - Navigate between fields</li>
|
||||
<li>Enter - Activate field (text input/checkbox/dropdown)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Text Input</h3>
|
||||
<ul>
|
||||
<li>Type to edit text</li>
|
||||
<li>Backspace to delete</li>
|
||||
<li>Enter or Esc to finish editing</li>
|
||||
</ul>
|
||||
|
||||
<h3>Dropdown Selection</h3>
|
||||
<ul>
|
||||
<li>Enter on SELECT to open options</li>
|
||||
<li>j/k or arrows to navigate options</li>
|
||||
<li>Enter to select, Esc to cancel</li>
|
||||
</ul>
|
||||
|
||||
<h2>Other</h2>
|
||||
<ul>
|
||||
<li>r - Refresh page (bypass cache)</li>
|
||||
<li>Esc - Cancel loading</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
|
|
|
|||
|
|
@ -369,7 +369,30 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For SELECT, collect all OPTION children
|
||||
if (element.tag == GUMBO_TAG_SELECT) {
|
||||
for (const auto& child : node->children) {
|
||||
if (child->element_type == ElementType::OPTION) {
|
||||
std::string option_value = child->value.empty() ? child->get_all_text() : child->value;
|
||||
std::string option_text = child->get_all_text();
|
||||
node->options.push_back({option_value, option_text});
|
||||
|
||||
// Set selected option if marked
|
||||
if (child->checked) {
|
||||
node->selected_option = node->options.size() - 1;
|
||||
node->value = option_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default value to first option if no option is selected
|
||||
if (!node->options.empty() && node->value.empty()) {
|
||||
node->value = node->options[0].first;
|
||||
node->selected_option = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form ID if we are exiting a form
|
||||
if (element.tag == GUMBO_TAG_FORM) {
|
||||
g_current_form_id = -1; // Assuming no nested forms
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ struct DomNode {
|
|||
bool checked = false;
|
||||
int form_id = -1;
|
||||
|
||||
// SELECT元素的选项
|
||||
std::vector<std::pair<std::string, std::string>> options; // (value, text) pairs
|
||||
int selected_option = 0; // 当前选中的选项索引
|
||||
|
||||
// 辅助方法
|
||||
bool is_block_element() const;
|
||||
bool is_inline_element() const;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ public:
|
|||
count_buffer.clear();
|
||||
break;
|
||||
case '\t':
|
||||
// Tab can navigate both links and fields - browser will decide
|
||||
result.action = Action::NEXT_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
|
|
@ -135,7 +136,7 @@ public:
|
|||
break;
|
||||
case '\n':
|
||||
case '\r':
|
||||
// If count buffer has a number, jump to that link
|
||||
// Enter can follow links or activate fields - browser will decide
|
||||
if (result.has_count) {
|
||||
result.action = Action::GOTO_LINK;
|
||||
result.number = result.count;
|
||||
|
|
@ -144,6 +145,11 @@ public:
|
|||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'i':
|
||||
// 'i' to focus on first form field (like vim insert mode)
|
||||
result.action = Action::NEXT_FIELD;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'f':
|
||||
// 'f' command: vimium-style link hints
|
||||
result.action = Action::SHOW_LINK_HINTS;
|
||||
|
|
@ -334,6 +340,78 @@ public:
|
|||
return result;
|
||||
}
|
||||
|
||||
InputResult process_form_edit_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC exits form edit mode
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Enter submits the text
|
||||
result.action = Action::EDIT_TEXT;
|
||||
result.text = buffer;
|
||||
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();
|
||||
}
|
||||
return result;
|
||||
} else if (ch == '\t') {
|
||||
// Tab moves to next field while saving current text
|
||||
result.action = Action::NEXT_FIELD;
|
||||
result.text = buffer;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == KEY_BTAB) {
|
||||
// Shift+Tab moves to previous field while saving current text
|
||||
result.action = Action::PREV_FIELD;
|
||||
result.text = buffer;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (std::isprint(ch)) {
|
||||
// Add printable characters to buffer
|
||||
buffer += static_cast<char>(ch);
|
||||
// Return EDIT_TEXT to update in real-time
|
||||
result.action = Action::EDIT_TEXT;
|
||||
result.text = buffer;
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_select_option_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC cancels selection
|
||||
mode = InputMode::NORMAL;
|
||||
return result;
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Enter selects current option
|
||||
result.action = Action::SELECT_CURRENT_OPTION;
|
||||
mode = InputMode::NORMAL;
|
||||
return result;
|
||||
} else if (ch == 'j' || ch == KEY_DOWN) {
|
||||
// Next option
|
||||
result.action = Action::NEXT_OPTION;
|
||||
return result;
|
||||
} else if (ch == 'k' || ch == KEY_UP) {
|
||||
// Previous option
|
||||
result.action = Action::PREV_OPTION;
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
|
@ -352,6 +430,10 @@ InputResult InputHandler::handle_key(int ch) {
|
|||
return pImpl->process_link_mode(ch);
|
||||
case InputMode::LINK_HINTS:
|
||||
return pImpl->process_link_hints_mode(ch);
|
||||
case InputMode::FORM_EDIT:
|
||||
return pImpl->process_form_edit_mode(ch);
|
||||
case InputMode::SELECT_OPTION:
|
||||
return pImpl->process_select_option_mode(ch);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
@ -375,6 +457,14 @@ void InputHandler::reset() {
|
|||
pImpl->count_buffer.clear();
|
||||
}
|
||||
|
||||
void InputHandler::set_mode(InputMode mode) {
|
||||
pImpl->mode = mode;
|
||||
}
|
||||
|
||||
void InputHandler::set_buffer(const std::string& buffer) {
|
||||
pImpl->buffer = buffer;
|
||||
}
|
||||
|
||||
void InputHandler::set_status_callback(std::function<void(const std::string&)> callback) {
|
||||
pImpl->status_callback = callback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ enum class InputMode {
|
|||
COMMAND,
|
||||
SEARCH,
|
||||
LINK,
|
||||
LINK_HINTS // Vimium-style 'f' mode
|
||||
LINK_HINTS, // Vimium-style 'f' mode
|
||||
FORM_EDIT, // Form field editing mode
|
||||
SELECT_OPTION // Dropdown selection mode
|
||||
};
|
||||
|
||||
enum class Action {
|
||||
|
|
@ -42,7 +44,16 @@ enum class Action {
|
|||
ADD_BOOKMARK, // Add current page to bookmarks (B)
|
||||
REMOVE_BOOKMARK, // Remove current page from bookmarks (D)
|
||||
SHOW_BOOKMARKS, // Show bookmarks page (:bookmarks)
|
||||
SHOW_HISTORY // Show history page (:history)
|
||||
SHOW_HISTORY, // Show history page (:history)
|
||||
NEXT_FIELD, // Move to next form field (Tab)
|
||||
PREV_FIELD, // Move to previous form field (Shift+Tab)
|
||||
ACTIVATE_FIELD, // Activate current field for editing (Enter)
|
||||
TOGGLE_CHECKBOX, // Toggle checkbox state
|
||||
EDIT_TEXT, // Edit text input (updates text buffer)
|
||||
SUBMIT_FORM, // Submit form (Enter on submit button)
|
||||
NEXT_OPTION, // Move to next dropdown option (j/down)
|
||||
PREV_OPTION, // Move to previous dropdown option (k/up)
|
||||
SELECT_CURRENT_OPTION // Select current dropdown option (Enter)
|
||||
};
|
||||
|
||||
struct InputResult {
|
||||
|
|
@ -62,6 +73,8 @@ public:
|
|||
InputMode get_mode() const;
|
||||
std::string get_buffer() const;
|
||||
void reset();
|
||||
void set_mode(InputMode mode);
|
||||
void set_buffer(const std::string& buffer);
|
||||
void set_status_callback(std::function<void(const std::string&)> callback);
|
||||
|
||||
private:
|
||||
|
|
|
|||
|
|
@ -432,9 +432,13 @@ void LayoutEngine::layout_form_element(const DomNode* node, Context& /*ctx*/, st
|
|||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
} else if (node->element_type == ElementType::SELECT) {
|
||||
// 下拉选择
|
||||
// 下拉选择 - 显示当前选中的选项
|
||||
StyledSpan span;
|
||||
span.text = "[▼ Select]";
|
||||
std::string selected_text = "Select";
|
||||
if (node->selected_option >= 0 && node->selected_option < static_cast<int>(node->options.size())) {
|
||||
selected_text = node->options[node->selected_option].second;
|
||||
}
|
||||
span.text = "[▼ " + selected_text + "]";
|
||||
span.fg = colors::INPUT_FOCUS;
|
||||
span.bg = colors::INPUT_BG;
|
||||
span.field_index = node->field_index;
|
||||
|
|
|
|||
|
|
@ -125,11 +125,14 @@ public:
|
|||
}
|
||||
|
||||
void clear() {
|
||||
::clear();
|
||||
// 使用 ANSI 转义码清屏并移动光标到左上角
|
||||
std::printf("\033[2J\033[H");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
::refresh();
|
||||
// ANSI 模式下不需要 ncurses 的 refresh,只需 flush
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ==================== True Color ====================
|
||||
|
|
@ -223,15 +226,20 @@ public:
|
|||
// ==================== 光标控制 ====================
|
||||
|
||||
void move_cursor(int x, int y) {
|
||||
move(y, x); // ncurses使用 (y, x) 顺序
|
||||
// 使用 ANSI 转义码移动光标(和 printf 输出兼容)
|
||||
// 注意:ANSI 坐标是 1-indexed,所以需要 +1
|
||||
std::printf("\033[%d;%dH", y + 1, x + 1);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void hide_cursor() {
|
||||
curs_set(0);
|
||||
std::printf("\033[?25l"); // DECTCEM: 隐藏光标
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void show_cursor() {
|
||||
curs_set(1);
|
||||
std::printf("\033[?25h"); // DECTCEM: 显示光标
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ==================== 文本输出 ====================
|
||||
|
|
|
|||
82
test_browser.sh
Executable file
82
test_browser.sh
Executable file
|
|
@ -0,0 +1,82 @@
|
|||
#!/bin/bash
|
||||
|
||||
# TUT Browser Interactive Test Script
|
||||
# This script helps test the browser with various real websites
|
||||
|
||||
echo "========================================"
|
||||
echo " TUT 2.0 Browser Interactive Testing"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "This script will help you test the browser with real websites."
|
||||
echo "Press Ctrl+C to exit at any time."
|
||||
echo ""
|
||||
|
||||
# Build the browser
|
||||
echo "Building the browser..."
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug > /dev/null 2>&1
|
||||
cmake --build build > /dev/null 2>&1
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Build successful!"
|
||||
echo ""
|
||||
|
||||
# Test sites
|
||||
declare -a sites=(
|
||||
"http://example.com"
|
||||
"http://info.cern.ch"
|
||||
"http://motherfuckingwebsite.com"
|
||||
"http://textfiles.com"
|
||||
)
|
||||
|
||||
echo "Available test sites:"
|
||||
echo "1. example.com - Simple static page"
|
||||
echo "2. info.cern.ch - First website ever (historical)"
|
||||
echo "3. motherfuckingwebsite.com - Minimalist design manifesto"
|
||||
echo "4. textfiles.com - Text-only content"
|
||||
echo "5. Custom URL"
|
||||
echo ""
|
||||
|
||||
read -p "Select a site (1-5): " choice
|
||||
|
||||
case $choice in
|
||||
1) url="${sites[0]}" ;;
|
||||
2) url="${sites[1]}" ;;
|
||||
3) url="${sites[2]}" ;;
|
||||
4) url="${sites[3]}" ;;
|
||||
5)
|
||||
read -p "Enter URL (include http://): " url
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice, using example.com"
|
||||
url="${sites[0]}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Launching TUT Browser"
|
||||
echo "========================================"
|
||||
echo "URL: $url"
|
||||
echo ""
|
||||
echo "Keyboard shortcuts:"
|
||||
echo " j/k - Scroll up/down"
|
||||
echo " Tab - Next link/field"
|
||||
echo " Enter - Follow link/activate field"
|
||||
echo " i - Focus first form field"
|
||||
echo " / - Search"
|
||||
echo " h/l - Back/Forward"
|
||||
echo " B - Bookmark"
|
||||
echo " :o URL - Open URL"
|
||||
echo " :q - Quit"
|
||||
echo ""
|
||||
read -p "Press Enter to start..."
|
||||
|
||||
# Launch the browser
|
||||
./build/tut "$url"
|
||||
|
||||
echo ""
|
||||
echo "Browser exited. Test complete!"
|
||||
39
test_form.html
Normal file
39
test_form.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Form Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Form</h1>
|
||||
|
||||
<form action="/submit" method="POST">
|
||||
<p>Name: <input type="text" name="name" placeholder="Enter your name"></p>
|
||||
<p>Email: <input type="text" name="email" placeholder="email@example.com"></p>
|
||||
<p>Password: <input type="password" name="password"></p>
|
||||
|
||||
<p>Country: <select name="country">
|
||||
<option value="">Select a country</option>
|
||||
<option value="us">United States</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="au">Australia</option>
|
||||
</select></p>
|
||||
|
||||
<p>Age Group: <select name="age">
|
||||
<option value="18-25" selected>18-25</option>
|
||||
<option value="26-35">26-35</option>
|
||||
<option value="36-50">36-50</option>
|
||||
<option value="51+">51+</option>
|
||||
</select></p>
|
||||
|
||||
<p><input type="checkbox" name="subscribe"> Subscribe to newsletter</p>
|
||||
<p><input type="checkbox" name="agree"> I agree to terms</p>
|
||||
|
||||
<p><input type="submit" value="Submit Form"></p>
|
||||
<p><button>Click Me</button></p>
|
||||
</form>
|
||||
|
||||
<h2>Links</h2>
|
||||
<p><a href="https://example.com">Example Link</a></p>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue