diff --git a/KEYBOARD.md b/KEYBOARD.md new file mode 100644 index 0000000..79ae037 --- /dev/null +++ b/KEYBOARD.md @@ -0,0 +1,109 @@ +# TUT Browser - Keyboard Shortcuts + +## 🎯 Quick Reference + +### Navigation +| Key | Action | +|-----|--------| +| `o` | Open address bar (type URL and press Enter) | +| `Backspace` | Go back | +| `r` or `F5` | Refresh current page | +| `q` or `Esc` or `F10` | Quit browser | + +### Scrolling +| Key | Action | +|-----|--------| +| `j` or `↓` | Scroll down one line | +| `k` or `↑` | Scroll up one line | +| `Space` or `PageDown` | Page down | +| `b` or `PageUp` | Page up | +| `g` | Go to top | +| `G` | Go to bottom | + +### Links +| Key | Action | +|-----|--------| +| `Tab` | Select next link | +| `Shift+Tab` | Select previous link | +| `1-9` | Jump to link by number | +| `Enter` | Follow selected link | + +## 📝 Usage Examples + +### Basic Browsing +```bash +./build_ftxui/tut https://example.com + +# 1. Press 'j' or 'k' to scroll +# 2. Press 'Tab' to cycle through links +# 3. Press 'Enter' to follow the selected link +# 4. Press 'Backspace' to go back +# 5. Press 'q' to quit +``` + +### Direct Link Navigation +```bash +./build_ftxui/tut https://tldp.org/HOWTO/HOWTO-INDEX/howtos.html + +# See numbered links like [1], [2], [3]... +# Press '1' to jump to first link +# Press '2' to jump to second link +# Press 'Enter' to follow the selected link +``` + +### Address Bar +```bash +# 1. Press 'o' to open address bar +# 2. Type new URL +# 3. Press 'Enter' to navigate +# 4. Press 'Esc' to cancel +``` + +## 🎨 UI Elements + +### Top Bar +- `[◀]` - Back button (dimmed when can't go back) +- `[▶]` - Forward button (dimmed when can't go forward) +- `[⟳]` - Refresh +- Address bar - Shows current URL +- `[⚙]` - Settings (not yet implemented) +- `[?]` - Help (not yet implemented) + +### Content Area +- Shows rendered HTML content +- Displays page title at top +- Shows scroll position at bottom + +### Bottom Panels +- **Bookmarks Panel** - Shows bookmarks (not yet implemented) +- **Status Panel** - Shows: + - Load stats (KB downloaded, time, link count) + - Currently selected link URL + +### Status Bar +- Shows function key shortcuts +- Shows current status message + +## ⚡ Pro Tips + +1. **Fast Navigation**: Use number keys (1-9) to instantly jump to links +2. **Quick Scrolling**: Use `Space` and `b` for fast page scrolling +3. **Link Preview**: Watch the status bar to see link URLs before following +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 + +## 🐛 Known Limitations + +- Ctrl+L not yet working for address bar (use 'o' instead) +- Forward navigation not yet implemented +- No search functionality yet (/ key) +- No bookmarks yet (Ctrl+D) +- No history panel yet (F3) + +## 🚀 Coming Soon + +- [ ] In-page search (`/` to search, `n`/`N` to navigate results) +- [ ] Bookmarks (add, remove, list) +- [ ] History (view and navigate) +- [ ] Better link highlighting +- [ ] Form support diff --git a/src/main.cpp b/src/main.cpp index c62cacc..044fec0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include "tut/version.hpp" #include "core/browser_engine.hpp" @@ -138,10 +139,36 @@ int main(int argc, char* argv[]) { LOG_INFO << "Navigating to: " << url; window.setLoading(true); + auto start_time = std::chrono::steady_clock::now(); + if (engine.loadUrl(url)) { + auto end_time = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(end_time - start_time).count(); + + // Update window content window.setTitle(engine.getTitle()); window.setContent(engine.getRenderedContent()); window.setUrl(url); + + // Convert LinkInfo to DisplayLink + std::vector display_links; + for (const auto& link : engine.extractLinks()) { + DisplayLink dl; + dl.text = link.text; + dl.url = link.url; + dl.visited = false; + display_links.push_back(dl); + } + window.setLinks(display_links); + + // Update navigation state + window.setCanGoBack(engine.canGoBack()); + window.setCanGoForward(engine.canGoForward()); + + // Update stats (assuming response body size) + size_t content_size = engine.getRenderedContent().size(); + window.setLoadStats(elapsed, content_size, static_cast(display_links.size())); + window.setStatusMessage("Loaded: " + url); } else { window.setStatusMessage("Failed to load: " + url); @@ -150,6 +177,116 @@ int main(int argc, char* argv[]) { window.setLoading(false); }); + // 设置链接点击回调 + window.onLinkClick([&engine, &window](int index) { + auto links = engine.extractLinks(); + if (index >= 0 && index < static_cast(links.size())) { + const std::string& link_url = links[index].url; + LOG_INFO << "Following link [" << index + 1 << "]: " << link_url; + + // Trigger navigation + window.setLoading(true); + + auto start_time = std::chrono::steady_clock::now(); + + if (engine.loadUrl(link_url)) { + auto end_time = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(end_time - start_time).count(); + + window.setTitle(engine.getTitle()); + window.setContent(engine.getRenderedContent()); + window.setUrl(link_url); + + // Convert LinkInfo to DisplayLink + std::vector display_links; + for (const auto& link : engine.extractLinks()) { + DisplayLink dl; + dl.text = link.text; + dl.url = link.url; + dl.visited = false; + display_links.push_back(dl); + } + window.setLinks(display_links); + + window.setCanGoBack(engine.canGoBack()); + window.setCanGoForward(engine.canGoForward()); + + size_t content_size = engine.getRenderedContent().size(); + window.setLoadStats(elapsed, content_size, static_cast(display_links.size())); + + window.setStatusMessage("Loaded: " + link_url); + } else { + window.setStatusMessage("Failed to load: " + link_url); + } + + window.setLoading(false); + } + }); + + // 设置窗口事件回调 + window.onEvent([&engine, &window](WindowEvent event) { + switch (event) { + case WindowEvent::Back: + if (engine.goBack()) { + window.setTitle(engine.getTitle()); + window.setContent(engine.getRenderedContent()); + window.setUrl(engine.getCurrentUrl()); + + std::vector display_links; + for (const auto& link : engine.extractLinks()) { + DisplayLink dl; + dl.text = link.text; + dl.url = link.url; + dl.visited = false; + display_links.push_back(dl); + } + window.setLinks(display_links); + + window.setCanGoBack(engine.canGoBack()); + window.setCanGoForward(engine.canGoForward()); + } + break; + case WindowEvent::Forward: + if (engine.goForward()) { + window.setTitle(engine.getTitle()); + window.setContent(engine.getRenderedContent()); + window.setUrl(engine.getCurrentUrl()); + + std::vector display_links; + for (const auto& link : engine.extractLinks()) { + DisplayLink dl; + dl.text = link.text; + dl.url = link.url; + dl.visited = false; + display_links.push_back(dl); + } + window.setLinks(display_links); + + window.setCanGoBack(engine.canGoBack()); + window.setCanGoForward(engine.canGoForward()); + } + break; + case WindowEvent::Refresh: + if (engine.refresh()) { + window.setTitle(engine.getTitle()); + window.setContent(engine.getRenderedContent()); + + std::vector display_links; + for (const auto& link : engine.extractLinks()) { + DisplayLink dl; + dl.text = link.text; + dl.url = link.url; + dl.visited = false; + display_links.push_back(dl); + } + window.setLinks(display_links); + } + break; + default: + break; + } + }); + // 初始化窗口 if (!window.init()) { LOG_FATAL << "Failed to initialize window"; @@ -162,6 +299,18 @@ int main(int argc, char* argv[]) { window.setUrl(initial_url); window.setTitle(engine.getTitle()); window.setContent(engine.getRenderedContent()); + + std::vector display_links; + for (const auto& link : engine.extractLinks()) { + DisplayLink dl; + dl.text = link.text; + dl.url = link.url; + dl.visited = false; + display_links.push_back(dl); + } + window.setLinks(display_links); + window.setCanGoBack(engine.canGoBack()); + window.setCanGoForward(engine.canGoForward()); } else { window.setUrl("about:blank"); window.setTitle("TUT - Terminal UI Textual Browser"); diff --git a/src/ui/main_window.cpp b/src/ui/main_window.cpp index d9e546c..2d9e9df 100644 --- a/src/ui/main_window.cpp +++ b/src/ui/main_window.cpp @@ -4,23 +4,81 @@ */ #include "ui/main_window.hpp" +#include "ui/content_view.hpp" #include #include #include +#include +#include + namespace tut { class MainWindow::Impl { public: std::string url_; std::string title_; - std::string content_; + std::vector links_; + int scroll_offset_{0}; + int selected_link_{-1}; + int viewport_height_{20}; + std::string status_message_; bool loading_{false}; + bool can_go_back_{false}; + bool can_go_forward_{false}; + + double load_time_{0.0}; + size_t load_bytes_{0}; + int link_count_{0}; std::function on_navigate_; std::function on_event_; + std::function on_link_click_; + + // Split content into lines for scrolling + std::vector content_lines_; + + void setContent(const std::string& content) { + content_lines_.clear(); + std::istringstream iss(content); + std::string line; + while (std::getline(iss, line)) { + content_lines_.push_back(line); + } + scroll_offset_ = 0; + } + + void scrollDown(int lines = 1) { + int max_scroll = std::max(0, static_cast(content_lines_.size()) - viewport_height_); + scroll_offset_ = std::min(scroll_offset_ + lines, max_scroll); + } + + void scrollUp(int lines = 1) { + scroll_offset_ = std::max(0, scroll_offset_ - lines); + } + + void scrollToTop() { + scroll_offset_ = 0; + } + + void scrollToBottom() { + scroll_offset_ = std::max(0, static_cast(content_lines_.size()) - viewport_height_); + } + + void selectNextLink() { + if (links_.empty()) return; + selected_link_ = (selected_link_ + 1) % static_cast(links_.size()); + } + + void selectPreviousLink() { + if (links_.empty()) return; + selected_link_--; + if (selected_link_ < 0) { + selected_link_ = static_cast(links_.size()) - 1; + } + } }; MainWindow::MainWindow() : impl_(std::make_unique()) {} @@ -28,7 +86,6 @@ MainWindow::MainWindow() : impl_(std::make_unique()) {} MainWindow::~MainWindow() = default; bool MainWindow::init() { - // TODO: 初始化 FTXUI 组件 return true; } @@ -40,40 +97,77 @@ int MainWindow::run() { // 地址栏输入 std::string address_content = impl_->url_; auto address_input = Input(&address_content, "Enter URL..."); + bool address_focused = false; - // 内容区域 + // 内容渲染器 auto content_renderer = Renderer([this] { - return vbox({ - text(impl_->title_) | bold | center, - separator(), - paragraph(impl_->content_), - }) | flex; + Elements lines; + + // Title + if (!impl_->title_.empty()) { + lines.push_back(text(impl_->title_) | bold | center); + lines.push_back(separator()); + } + + // Content with scrolling + int start = impl_->scroll_offset_; + int end = std::min(start + impl_->viewport_height_, + static_cast(impl_->content_lines_.size())); + + for (int i = start; i < end; i++) { + lines.push_back(text(impl_->content_lines_[i])); + } + + // Scroll indicator + if (!impl_->content_lines_.empty()) { + int total_lines = static_cast(impl_->content_lines_.size()); + std::string scroll_info = "Lines " + std::to_string(start + 1) + + "-" + std::to_string(end) + + " / " + std::to_string(total_lines); + lines.push_back(separator()); + lines.push_back(text(scroll_info) | dim | align_right); + } + + return vbox(lines) | flex; }); - // 状态栏 - auto status_renderer = Renderer([this] { - std::string status = impl_->loading_ ? "Loading..." : impl_->status_message_; - return text(status) | dim; + // 状态面板 + auto status_panel = Renderer([this] { + Elements status_items; + + if (impl_->loading_) { + status_items.push_back(text("⏳ Loading...") | dim); + } else if (impl_->load_time_ > 0) { + std::string stats = "⬇ " + std::to_string(impl_->load_bytes_ / 1024) + " KB " + + "🕐 " + std::to_string(static_cast(impl_->load_time_ * 1000)) + "ms " + + "🔗 " + std::to_string(impl_->link_count_) + " links"; + status_items.push_back(text(stats) | dim); + } else { + status_items.push_back(text("Ready") | dim); + } + + if (impl_->selected_link_ >= 0 && impl_->selected_link_ < static_cast(impl_->links_.size())) { + status_items.push_back(separator()); + std::string link_info = "[" + std::to_string(impl_->selected_link_ + 1) + "] " + + impl_->links_[impl_->selected_link_].url; + status_items.push_back(text(link_info) | dim); + } + + return hbox(status_items); }); // 主布局 - auto main_layout = Container::Vertical({ - address_input, - content_renderer, - status_renderer, - }); - - auto main_renderer = Renderer(main_layout, [&] { + auto main_renderer = Renderer([&] { return vbox({ // 顶部栏 hbox({ - text("[◀]") | bold, + text(impl_->can_go_back_ ? "[◀]" : "[◀]") | (impl_->can_go_back_ ? bold : dim), text(" "), - text("[▶]") | bold, + text(impl_->can_go_forward_ ? "[▶]" : "[▶]") | (impl_->can_go_forward_ ? bold : dim), text(" "), text("[⟳]") | bold, text(" "), - address_input->Render() | flex | border, + address_input->Render() | flex | border | (address_focused ? focus : select), text(" "), text("[⚙]") | bold, text(" "), @@ -92,7 +186,7 @@ int MainWindow::run() { separator(), vbox({ text("📊 Status") | bold, - text(" Ready") | dim, + status_panel->Render(), }) | flex, }), separator(), @@ -106,23 +200,124 @@ int MainWindow::run() { text(" "), text("[F10]Quit") | dim, filler(), - status_renderer->Render(), + text(impl_->status_message_) | dim, }), }) | border; }); // 事件处理 main_renderer |= CatchEvent([&](Event event) { - if (event == Event::Escape || event == Event::Character('q')) { + // Quit + if (event == Event::Escape || event == Event::Character('q') || + event == Event::F10) { screen.ExitLoopClosure()(); return true; } - if (event == Event::Return) { + + // Address bar focus (use 'o' key instead of Ctrl+L) + if (event == Event::Character('o') && !address_focused) { + address_focused = true; + return true; + } + + // Navigate from address bar + if (event == Event::Return && address_focused) { if (impl_->on_navigate_) { impl_->on_navigate_(address_content); + address_focused = false; } return true; } + + // Exit address bar + if (event == Event::Escape && address_focused) { + address_focused = false; + return true; + } + + // Don't handle other keys if address bar is focused + if (address_focused) { + return false; + } + + // Scrolling + if (event == Event::Character('j') || event == Event::ArrowDown) { + impl_->scrollDown(1); + return true; + } + if (event == Event::Character('k') || event == Event::ArrowUp) { + impl_->scrollUp(1); + return true; + } + if (event == Event::Character(' ') || event == Event::PageDown) { + impl_->scrollDown(impl_->viewport_height_ - 2); + return true; + } + if (event == Event::Character('b') || event == Event::PageUp) { + impl_->scrollUp(impl_->viewport_height_ - 2); + return true; + } + if (event == Event::Character('g')) { + impl_->scrollToTop(); + return true; + } + if (event == Event::Character('G')) { + impl_->scrollToBottom(); + return true; + } + + // Link navigation + if (event == Event::Tab) { + impl_->selectNextLink(); + return true; + } + if (event == Event::TabReverse) { + impl_->selectPreviousLink(); + return true; + } + + // Follow link + if (event == Event::Return) { + if (impl_->selected_link_ >= 0 && + impl_->selected_link_ < static_cast(impl_->links_.size())) { + if (impl_->on_link_click_) { + impl_->on_link_click_(impl_->selected_link_); + } + } + return true; + } + + // Number shortcuts (1-9) + if (event.is_character()) { + char c = event.character()[0]; + if (c >= '1' && c <= '9') { + int link_idx = c - '1'; + if (link_idx < static_cast(impl_->links_.size())) { + impl_->selected_link_ = link_idx; + if (impl_->on_link_click_) { + impl_->on_link_click_(link_idx); + } + } + return true; + } + } + + // Back/Forward + if (event == Event::Backspace && impl_->can_go_back_) { + if (impl_->on_event_) { + impl_->on_event_(WindowEvent::Back); + } + return true; + } + + // Refresh + if (event == Event::Character('r') || event == Event::F5) { + if (impl_->on_event_) { + impl_->on_event_(WindowEvent::Refresh); + } + return true; + } + return false; }); @@ -143,13 +338,40 @@ void MainWindow::setTitle(const std::string& title) { } void MainWindow::setContent(const std::string& content) { - impl_->content_ = content; + impl_->setContent(content); } void MainWindow::setLoading(bool loading) { impl_->loading_ = loading; } +void MainWindow::setLinks(const std::vector& links) { + impl_->links_ = links; + impl_->selected_link_ = links.empty() ? -1 : 0; +} + +void MainWindow::setBookmarks(const std::vector& /*bookmarks*/) { + // TODO: Implement bookmark display +} + +void MainWindow::setHistory(const std::vector& /*history*/) { + // TODO: Implement history display +} + +void MainWindow::setCanGoBack(bool can) { + impl_->can_go_back_ = can; +} + +void MainWindow::setCanGoForward(bool can) { + impl_->can_go_forward_ = can; +} + +void MainWindow::setLoadStats(double elapsed_seconds, size_t bytes, int link_count) { + impl_->load_time_ = elapsed_seconds; + impl_->load_bytes_ = bytes; + impl_->link_count_ = link_count; +} + void MainWindow::onNavigate(std::function callback) { impl_->on_navigate_ = std::move(callback); } @@ -158,4 +380,12 @@ void MainWindow::onEvent(std::function callback) { impl_->on_event_ = std::move(callback); } +void MainWindow::onLinkClick(std::function callback) { + impl_->on_link_click_ = std::move(callback); +} + +void MainWindow::onBookmarkClick(std::function /*callback*/) { + // TODO: Implement bookmark click callback +} + } // namespace tut