mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 00:54:05 +00:00
feat: Add full in-page search functionality
Completed Phase 1 high priority task - comprehensive search system: Search Features: - Press '/' to enter search mode with dedicated search input - Case-insensitive search across all content - Enter to execute search and find all matches - Real-time match highlighting: * Yellow background = current match * Blue background = other matches - Navigate results with 'n' (next) and 'N' (previous) - Smart scrolling - auto-scroll to current match - Match counter in status bar (e.g., "Match 3/10") - Esc to cancel search input - Search state clears when loading new pages Implementation Details: - Added search state to MainWindow::Impl * search_mode, search_query, search_matches, current_match - Search input component (similar to address bar) - executeSearch() - finds all matching lines - nextMatch()/previousMatch() - cycle through results - Content renderer highlights matches dynamically - Status panel shows search results with emoji indicator User Experience: - Intuitive vim-style '/' to search - Visual feedback with color highlighting - Match position indicator in status - Non-intrusive - doesn't interfere with navigation - Seamless integration with existing UI Keyboard shortcuts: - /: Start search - Enter: Execute search - n: Next match - N: Previous match - Esc: Cancel search Documentation: - Updated KEYBOARD.md with search section and usage example - Updated STATUS.md to reflect completion - Added search to interactive features list The browser now has powerful in-page search! 🔍✅
This commit is contained in:
parent
159e299e96
commit
be6cc4ca44
3 changed files with 180 additions and 24 deletions
22
KEYBOARD.md
22
KEYBOARD.md
|
|
@ -60,6 +60,18 @@
|
||||||
# 4. Press 'Esc' to cancel
|
# 4. Press 'Esc' to cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### In-Page Search
|
||||||
|
```bash
|
||||||
|
# Search for text on current page
|
||||||
|
# 1. Press '/' to open search bar
|
||||||
|
# 2. Type your search query (case-insensitive)
|
||||||
|
# 3. Press 'Enter' to find all matches
|
||||||
|
# 4. Press 'n' to go to next match
|
||||||
|
# 5. Press 'N' to go to previous match
|
||||||
|
# 6. Matches are highlighted (yellow = current, blue = other matches)
|
||||||
|
# 7. Status bar shows "Match X/Y" count
|
||||||
|
```
|
||||||
|
|
||||||
## 🎨 UI Elements
|
## 🎨 UI Elements
|
||||||
|
|
||||||
### Top Bar
|
### Top Bar
|
||||||
|
|
@ -93,16 +105,22 @@
|
||||||
4. **Efficient Browsing**: Use `g` to jump to top, `G` to jump to bottom
|
4. **Efficient Browsing**: Use `g` to jump to top, `G` to jump to bottom
|
||||||
5. **Address Bar**: Type `o` quickly to enter a new URL
|
5. **Address Bar**: Type `o` quickly to enter a new URL
|
||||||
|
|
||||||
|
### Search
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `/` | Start search (type query and press Enter) |
|
||||||
|
| `n` | Next search result |
|
||||||
|
| `N` | Previous search result |
|
||||||
|
| `Esc` | Cancel search |
|
||||||
|
|
||||||
## 🐛 Known Limitations
|
## 🐛 Known Limitations
|
||||||
|
|
||||||
- Ctrl+L not yet working for address bar (use 'o' instead)
|
- Ctrl+L not yet working for address bar (use 'o' instead)
|
||||||
- No search functionality yet (/ key)
|
|
||||||
- No bookmarks yet (Ctrl+D)
|
- No bookmarks yet (Ctrl+D)
|
||||||
- No history panel yet (F3)
|
- No history panel yet (F3)
|
||||||
|
|
||||||
## 🚀 Coming Soon
|
## 🚀 Coming Soon
|
||||||
|
|
||||||
- [ ] In-page search (`/` to search, `n`/`N` to navigate results)
|
|
||||||
- [ ] Bookmarks (add, remove, list)
|
- [ ] Bookmarks (add, remove, list)
|
||||||
- [ ] History (view and navigate)
|
- [ ] History (view and navigate)
|
||||||
- [ ] Better link highlighting
|
- [ ] Better link highlighting
|
||||||
|
|
|
||||||
20
STATUS.md
20
STATUS.md
|
|
@ -35,8 +35,9 @@
|
||||||
- **Link Navigation** - Tab, number keys (1-9), Enter to follow
|
- **Link Navigation** - Tab, number keys (1-9), Enter to follow
|
||||||
- **Address Bar** - 'o' to open, type URL, Enter to navigate
|
- **Address Bar** - 'o' to open, type URL, Enter to navigate
|
||||||
- **Browser Controls** - Backspace to go back, 'f' to go forward, r/F5 to refresh
|
- **Browser Controls** - Backspace to go back, 'f' to go forward, r/F5 to refresh
|
||||||
- **Real-time Status** - Load stats, scroll position, selected link
|
- **In-Page Search** - '/' to search, n/N to navigate results, highlighted matches
|
||||||
- **Visual Feedback** - Navigation button states, link highlighting
|
- **Real-time Status** - Load stats, scroll position, selected link, search results
|
||||||
|
- **Visual Feedback** - Navigation button states, link highlighting, search highlighting
|
||||||
|
|
||||||
### Build & Deployment
|
### Build & Deployment
|
||||||
- ✅ Binary size: **827KB** (well under 1MB target!)
|
- ✅ Binary size: **827KB** (well under 1MB target!)
|
||||||
|
|
@ -58,11 +59,6 @@
|
||||||
- No visual history panel (F3)
|
- No visual history panel (F3)
|
||||||
- No persistence across sessions
|
- No persistence across sessions
|
||||||
|
|
||||||
- ⚠️ **Search** - Not implemented
|
|
||||||
- / search command not working
|
|
||||||
- n/N navigation not working
|
|
||||||
- No highlight of matches
|
|
||||||
|
|
||||||
### Feature Gaps
|
### Feature Gaps
|
||||||
- ⚠️ No form support (input fields, buttons, etc.)
|
- ⚠️ No form support (input fields, buttons, etc.)
|
||||||
- ⚠️ No image rendering (even ASCII art)
|
- ⚠️ No image rendering (even ASCII art)
|
||||||
|
|
@ -72,18 +68,14 @@
|
||||||
## 🎯 Next Steps Priority
|
## 🎯 Next Steps Priority
|
||||||
|
|
||||||
### Phase 1: Enhanced UX (High Priority)
|
### Phase 1: Enhanced UX (High Priority)
|
||||||
4. **Implement Search** (src/ui/content_view.cpp)
|
|
||||||
- Add / to start search
|
|
||||||
- Highlight matches
|
|
||||||
- n/N to navigate results
|
|
||||||
|
|
||||||
5. **Add Bookmark System** (new files)
|
1. **Add Bookmark System** (new files)
|
||||||
- Implement bookmark storage (JSON file)
|
- Implement bookmark storage (JSON file)
|
||||||
- Create bookmark panel UI
|
- Create bookmark panel UI
|
||||||
- Add Ctrl+D to bookmark
|
- Add Ctrl+D to bookmark
|
||||||
- F2 to view bookmarks
|
- F2 to view bookmarks
|
||||||
|
|
||||||
6. **Add History** (new files)
|
2. **Add History** (new files)
|
||||||
- Implement history storage (JSON file)
|
- Implement history storage (JSON file)
|
||||||
- Create history panel UI
|
- Create history panel UI
|
||||||
- F3 to view history
|
- F3 to view history
|
||||||
|
|
@ -122,6 +114,8 @@ Interactive test:
|
||||||
✅ Enter to follow link - WORKS
|
✅ Enter to follow link - WORKS
|
||||||
✅ Backspace to go back - WORKS
|
✅ Backspace to go back - WORKS
|
||||||
✅ 'f' to go forward - WORKS
|
✅ 'f' to go forward - WORKS
|
||||||
|
✅ '/' to search - WORKS
|
||||||
|
✅ 'n'/'N' to navigate search results - WORKS
|
||||||
✅ 'r' to refresh - WORKS
|
✅ 'r' to refresh - WORKS
|
||||||
✅ 'o' to open address bar - WORKS
|
✅ 'o' to open address bar - WORKS
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
namespace tut {
|
namespace tut {
|
||||||
|
|
||||||
|
|
@ -40,6 +41,12 @@ public:
|
||||||
// Split content into lines for scrolling
|
// Split content into lines for scrolling
|
||||||
std::vector<std::string> content_lines_;
|
std::vector<std::string> content_lines_;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
bool search_mode_{false};
|
||||||
|
std::string search_query_;
|
||||||
|
std::vector<int> search_matches_; // Line indices with matches
|
||||||
|
int current_match_{-1}; // Index into search_matches_
|
||||||
|
|
||||||
void setContent(const std::string& content) {
|
void setContent(const std::string& content) {
|
||||||
content_lines_.clear();
|
content_lines_.clear();
|
||||||
std::istringstream iss(content);
|
std::istringstream iss(content);
|
||||||
|
|
@ -48,6 +55,12 @@ public:
|
||||||
content_lines_.push_back(line);
|
content_lines_.push_back(line);
|
||||||
}
|
}
|
||||||
scroll_offset_ = 0;
|
scroll_offset_ = 0;
|
||||||
|
|
||||||
|
// Clear search state when new content is loaded
|
||||||
|
search_mode_ = false;
|
||||||
|
search_query_.clear();
|
||||||
|
search_matches_.clear();
|
||||||
|
current_match_ = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void scrollDown(int lines = 1) {
|
void scrollDown(int lines = 1) {
|
||||||
|
|
@ -79,6 +92,54 @@ public:
|
||||||
selected_link_ = static_cast<int>(links_.size()) - 1;
|
selected_link_ = static_cast<int>(links_.size()) - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void executeSearch() {
|
||||||
|
search_matches_.clear();
|
||||||
|
current_match_ = -1;
|
||||||
|
|
||||||
|
if (search_query_.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert search query to lowercase for case-insensitive search
|
||||||
|
std::string query_lower = search_query_;
|
||||||
|
std::transform(query_lower.begin(), query_lower.end(), query_lower.begin(), ::tolower);
|
||||||
|
|
||||||
|
// Find all lines containing the search query
|
||||||
|
for (size_t i = 0; i < content_lines_.size(); ++i) {
|
||||||
|
std::string line_lower = content_lines_[i];
|
||||||
|
std::transform(line_lower.begin(), line_lower.end(), line_lower.begin(), ::tolower);
|
||||||
|
|
||||||
|
if (line_lower.find(query_lower) != std::string::npos) {
|
||||||
|
search_matches_.push_back(static_cast<int>(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!search_matches_.empty()) {
|
||||||
|
current_match_ = 0;
|
||||||
|
// Scroll to first match
|
||||||
|
scroll_offset_ = search_matches_[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void nextMatch() {
|
||||||
|
if (search_matches_.empty()) return;
|
||||||
|
|
||||||
|
current_match_ = (current_match_ + 1) % static_cast<int>(search_matches_.size());
|
||||||
|
// Scroll to the match
|
||||||
|
scroll_offset_ = search_matches_[current_match_];
|
||||||
|
}
|
||||||
|
|
||||||
|
void previousMatch() {
|
||||||
|
if (search_matches_.empty()) return;
|
||||||
|
|
||||||
|
current_match_--;
|
||||||
|
if (current_match_ < 0) {
|
||||||
|
current_match_ = static_cast<int>(search_matches_.size()) - 1;
|
||||||
|
}
|
||||||
|
// Scroll to the match
|
||||||
|
scroll_offset_ = search_matches_[current_match_];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MainWindow::MainWindow() : impl_(std::make_unique<Impl>()) {}
|
MainWindow::MainWindow() : impl_(std::make_unique<Impl>()) {}
|
||||||
|
|
@ -99,6 +160,11 @@ int MainWindow::run() {
|
||||||
auto address_input = Input(&address_content, "Enter URL...");
|
auto address_input = Input(&address_content, "Enter URL...");
|
||||||
bool address_focused = false;
|
bool address_focused = false;
|
||||||
|
|
||||||
|
// 搜索输入
|
||||||
|
std::string search_content;
|
||||||
|
auto search_input = Input(&search_content, "Search...");
|
||||||
|
bool search_focused = false;
|
||||||
|
|
||||||
// 内容渲染器
|
// 内容渲染器
|
||||||
auto content_renderer = Renderer([this] {
|
auto content_renderer = Renderer([this] {
|
||||||
Elements lines;
|
Elements lines;
|
||||||
|
|
@ -115,7 +181,28 @@ int MainWindow::run() {
|
||||||
static_cast<int>(impl_->content_lines_.size()));
|
static_cast<int>(impl_->content_lines_.size()));
|
||||||
|
|
||||||
for (int i = start; i < end; i++) {
|
for (int i = start; i < end; i++) {
|
||||||
lines.push_back(text(impl_->content_lines_[i]));
|
// Check if this line is a search match
|
||||||
|
bool is_match = false;
|
||||||
|
bool is_current_match = false;
|
||||||
|
|
||||||
|
if (!impl_->search_matches_.empty()) {
|
||||||
|
auto it = std::find(impl_->search_matches_.begin(),
|
||||||
|
impl_->search_matches_.end(), i);
|
||||||
|
if (it != impl_->search_matches_.end()) {
|
||||||
|
is_match = true;
|
||||||
|
int match_index = std::distance(impl_->search_matches_.begin(), it);
|
||||||
|
is_current_match = (match_index == impl_->current_match_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render line with appropriate highlighting
|
||||||
|
auto line_element = text(impl_->content_lines_[i]);
|
||||||
|
if (is_current_match) {
|
||||||
|
line_element = line_element | bgcolor(Color::Yellow) | color(Color::Black);
|
||||||
|
} else if (is_match) {
|
||||||
|
line_element = line_element | bgcolor(Color::Blue) | color(Color::White);
|
||||||
|
}
|
||||||
|
lines.push_back(line_element);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll indicator
|
// Scroll indicator
|
||||||
|
|
@ -135,7 +222,16 @@ int MainWindow::run() {
|
||||||
auto status_panel = Renderer([this] {
|
auto status_panel = Renderer([this] {
|
||||||
Elements status_items;
|
Elements status_items;
|
||||||
|
|
||||||
if (impl_->loading_) {
|
// Search status takes priority
|
||||||
|
if (!impl_->search_matches_.empty()) {
|
||||||
|
std::string search_info = "🔍 Match " +
|
||||||
|
std::to_string(impl_->current_match_ + 1) + "/" +
|
||||||
|
std::to_string(impl_->search_matches_.size()) +
|
||||||
|
" for \"" + impl_->search_query_ + "\"";
|
||||||
|
status_items.push_back(text(search_info) | color(Color::Yellow));
|
||||||
|
} else if (impl_->search_mode_ && !impl_->search_query_.empty()) {
|
||||||
|
status_items.push_back(text("🔍 No matches for \"" + impl_->search_query_ + "\"") | dim);
|
||||||
|
} else if (impl_->loading_) {
|
||||||
status_items.push_back(text("⏳ Loading...") | dim);
|
status_items.push_back(text("⏳ Loading...") | dim);
|
||||||
} else if (impl_->load_time_ > 0) {
|
} else if (impl_->load_time_ > 0) {
|
||||||
std::string stats = "⬇ " + std::to_string(impl_->load_bytes_ / 1024) + " KB " +
|
std::string stats = "⬇ " + std::to_string(impl_->load_bytes_ / 1024) + " KB " +
|
||||||
|
|
@ -146,7 +242,8 @@ int MainWindow::run() {
|
||||||
status_items.push_back(text("Ready") | dim);
|
status_items.push_back(text("Ready") | dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (impl_->selected_link_ >= 0 && impl_->selected_link_ < static_cast<int>(impl_->links_.size())) {
|
if (impl_->selected_link_ >= 0 && impl_->selected_link_ < static_cast<int>(impl_->links_.size()) &&
|
||||||
|
impl_->search_matches_.empty()) {
|
||||||
status_items.push_back(separator());
|
status_items.push_back(separator());
|
||||||
std::string link_info = "[" + std::to_string(impl_->selected_link_ + 1) + "] " +
|
std::string link_info = "[" + std::to_string(impl_->selected_link_ + 1) + "] " +
|
||||||
impl_->links_[impl_->selected_link_].url;
|
impl_->links_[impl_->selected_link_].url;
|
||||||
|
|
@ -158,9 +255,18 @@ int MainWindow::run() {
|
||||||
|
|
||||||
// 主布局
|
// 主布局
|
||||||
auto main_renderer = Renderer([&] {
|
auto main_renderer = Renderer([&] {
|
||||||
return vbox({
|
Elements top_bar_elements;
|
||||||
// 顶部栏
|
|
||||||
hbox({
|
if (search_focused) {
|
||||||
|
// Show search bar when in search mode
|
||||||
|
top_bar_elements.push_back(hbox({
|
||||||
|
text("🔍 "),
|
||||||
|
search_input->Render() | flex | border | focus,
|
||||||
|
text(" [Esc to cancel]") | dim,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Normal address bar
|
||||||
|
top_bar_elements.push_back(hbox({
|
||||||
text(impl_->can_go_back_ ? "[◀]" : "[◀]") | (impl_->can_go_back_ ? bold : dim),
|
text(impl_->can_go_back_ ? "[◀]" : "[◀]") | (impl_->can_go_back_ ? bold : dim),
|
||||||
text(" "),
|
text(" "),
|
||||||
text(impl_->can_go_forward_ ? "[▶]" : "[▶]") | (impl_->can_go_forward_ ? bold : dim),
|
text(impl_->can_go_forward_ ? "[▶]" : "[▶]") | (impl_->can_go_forward_ ? bold : dim),
|
||||||
|
|
@ -172,7 +278,12 @@ int MainWindow::run() {
|
||||||
text("[⚙]") | bold,
|
text("[⚙]") | bold,
|
||||||
text(" "),
|
text(" "),
|
||||||
text("[?]") | bold,
|
text("[?]") | bold,
|
||||||
}),
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return vbox({
|
||||||
|
// 顶部栏
|
||||||
|
vbox(top_bar_elements),
|
||||||
separator(),
|
separator(),
|
||||||
// 内容区
|
// 内容区
|
||||||
content_renderer->Render() | flex,
|
content_renderer->Render() | flex,
|
||||||
|
|
@ -235,11 +346,44 @@ int MainWindow::run() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't handle other keys if address bar is focused
|
// Search mode activation
|
||||||
if (address_focused) {
|
if (event == Event::Character('/') && !address_focused && !search_focused) {
|
||||||
|
search_focused = true;
|
||||||
|
search_content.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
if (event == Event::Return && search_focused) {
|
||||||
|
impl_->search_mode_ = true;
|
||||||
|
impl_->search_query_ = search_content;
|
||||||
|
impl_->executeSearch();
|
||||||
|
search_focused = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit search mode
|
||||||
|
if (event == Event::Escape && search_focused) {
|
||||||
|
search_focused = false;
|
||||||
|
search_content.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't handle other keys if address bar or search is focused
|
||||||
|
if (address_focused || search_focused) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigate search results (only when not in input mode)
|
||||||
|
if (event == Event::Character('n') && !impl_->search_matches_.empty()) {
|
||||||
|
impl_->nextMatch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event == Event::Character('N') && !impl_->search_matches_.empty()) {
|
||||||
|
impl_->previousMatch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Scrolling
|
// Scrolling
|
||||||
if (event == Event::Character('j') || event == Event::ArrowDown) {
|
if (event == Event::Character('j') || event == Event::ArrowDown) {
|
||||||
impl_->scrollDown(1);
|
impl_->scrollDown(1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue