Merge branch 'dev'
61 files · +4915 -1532
M CHANGELOG.md +33 -0
| @@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ## [0.14.0] - 2025-11-18 | |
| 8 | + | ||
| 9 | + | ### Added | |
| 10 | + | - **Zellij-style tab system**: Each tab contains an independent workspace with panes, splits, and stacks. `Ctrl+T` enters tab mode with modal keyboard control (`n`=new, `x`=close, `l/h`=navigate). `Alt+1-9/0` for direct tab switching. `Ctrl+Tab`/`Ctrl+Shift+Tab` for next/previous tab. Tab bar auto-hides with single tab. Orange border indicator (distinct from blue pane mode). | |
| 11 | + | - **International keyboard layout support**: Hardware keycode-based shortcuts for `Alt+number` keys work across all keyboard layouts (QWERTY, AZERTY, QWERTZ, etc.). Physical key position used instead of character mapping. | |
| 12 | + | - **Click-to-activate stacked panes**: Click on any collapsed title bar to instantly activate that pane. Uses GTK4 gesture clicks for native interaction feel. | |
| 13 | + | - **Title bar visual separators**: Subtle 1px borders between stacked title bars. Theme-aware colors automatically adjust for light/dark mode. | |
| 14 | + | - **UI scaling for title bars**: New `workspace.styling.ui_scale` config option (default: 1.0). Scales font size, padding, height, and favicon size. Range: 0.5 to 3.0 (e.g., 1.2 = 120% size). | |
| 15 | + | - **Favorites system**: Full favorites management with keyboard controls and persistent storage. Press `Tab` to switch between History and Favorites views. Press `Space` to add/remove items. Yellow border indicates favorited items in History view. View mode persists across sessions. Database-backed with RAM-first caching for performance. | |
| 16 | + | - **Omnibox quick navigation**: Press `Ctrl+1` through `Ctrl+9` to instantly navigate to the first 9 results, or `Ctrl+0` for the 10th. Works in both History and Favorites views. Visual number hints appear on each item. Keyboard layout independent (works on QWERTY, AZERTY, QWERTZ, etc.). | |
| 17 | + | - **Middle-click and Ctrl+click link handling**: Links can now be opened in new workspace panes using standard browser gestures (middle-click or Ctrl+left-click). New panes split in the configured direction (default: right) and respect `workspace.popups.placement` configuration. Implemented via WebKit's `decide-policy` signal to intercept navigation actions before they occur. | |
| 18 | + | - **Configurable pane border styling**: Added `workspace.styling.inactive_border_width`, `workspace.styling.inactive_border_color`, and `workspace.styling.show_stacked_title_border` config options. Active and inactive borders default to 1px to prevent layout shift, with only color changing on focus. Borders use GTK theme variables by default and support smooth CSS transitions. | |
| 19 | + | - **Configurable default search engine**: Added `default_search_engine` config option with explicit URL template format. DuckDuckGo is now the default (was Google). Users can set any search engine with `%s` placeholder (e.g., `default_search_engine = "https://duckduckgo.com/?q=%s"`). | |
| 20 | + | ||
| 21 | + | ### Changed | |
| 22 | + | - **Target="_blank" and gesture link behavior**: Links with `target="_blank"`, Ctrl+click, and middle-click now open in stacked panes mode by default instead of split-right. New config option `workspace.popups.blank_target_behavior` (values: "split", "stacked", "tabbed") controls this behavior separately from JavaScript popups. Prevents browser exit when closing parent pane by marking _blank panes as popup-type. | |
| 23 | + | - **Config format**: TOML is now the default format (JSON/YAML still supported). Action bindings inverted to `action→keys` structure for maintainability with O(1) runtime lookup. Added comprehensive validation. | |
| 24 | + | - **Code cleanup**: Removed dead code files and emojis from GUI modules | |
| 25 | + | - **Bridge architecture**: Unified main-world to isolated-world bridge system with comprehensive documentation. Single dispatcher for all window function bridges (omnibox, favorites, toasts). Better separation between CustomEvents (shared between worlds) and window functions (require bridging). | |
| 26 | + | - **Omnibox spacing**: Increased vertical spacing between suggestion items from 0.65rem to 0.85rem for better readability | |
| 27 | + | - **Smart Escape key**: First press clears input text, second press closes omnibox. Empty input closes immediately. | |
| 28 | + | ||
| 29 | + | ### Fixed | |
| 30 | + | - **Ctrl+Tab shortcuts**: Fixed focus cycling behavior, now properly switches between tabs | |
| 31 | + | - **Active tab visibility**: Fixed identical styling - active tab now clearly visible with distinct gray shade | |
| 32 | + | - **AZERTY keyboard support**: Alt+number shortcuts now work on all keyboard layouts using hardware keycodes | |
| 33 | + | - **Keyboard layout detection**: Fixed always detecting "en" - now correctly detects fr/de/etc layouts | |
| 34 | + | - **Zoom visual glitch**: Fixed zoom level for next page being applied before navigation completed, causing visible zoom change on current page. Zoom now applies on load-committed event instead of URI change. | |
| 35 | + | - **Toast notifications**: Fixed toast notifications not appearing for copy URL (Ctrl+Shift+C) and clipboard operations. Event name was `dumber:showToast` but ToastContainer listens for `dumber:toast`. | |
| 36 | + | - **Page reload shortcuts**: Restored `Ctrl+R`, `Ctrl+Shift+R`, `F5`, and `Ctrl+F5` shortcuts. They were registered but not actually wired to the window-level handler. | |
| 37 | + | - **Bootstrap initialization**: Fixed omnibox component not mounting due to invalid toast function check in isolated world. Toast functions now bridge correctly via CustomEvents, allowing omnibox to initialize properly. | |
| 38 | + | - **Empty state display**: History view now hides empty state when input is empty, showing it only when user has typed and no results are found. Favorites view always shows empty state with helpful hints. | |
| 39 | + | ||
| 7 | 40 | ## [0.13.0] - 2025-10-24 | |
| 8 | 41 | ||
| 9 | 42 | ### Added | |
M README.md +60 -102
| @@ -4,34 +4,23 @@ | |||
| 4 | 4 | <img src="assets/logo.svg" alt="Dumber Logo" width="256" height="256" /> | |
| 5 | 5 | </p> | |
| 6 | 6 | ||
| 7 | - | Dumber is a minimalist browser and launcher companion focused on speed and simplicity. It ships a built‑in WebKitGTK (GTK4) window for navigation and a CLI with dmenu‑style launcher integration (rofi, fuzzel). | |
| 7 | + | A dumb browser that works like your favorite terminal multiplexer. | |
| 8 | 8 | ||
| 9 | - | - Native GUI via WebKitGTK 6 (GTK4, CGO) with an embedded homepage UI. | |
| 10 | - | - CLI that parses URLs, supports search shortcuts (e.g., `g:query`), and integrates with rofi/dmenu. | |
| 11 | - | - Persistent history and per‑domain zoom levels (SQLite, XDG locations). | |
| 12 | - | - Configurable via `config.json` and environment variables, with live reload. | |
| 13 | - | ||
| 14 | - | ## Wayland‑First | |
| 15 | - | - Dumb on purpose, designed for Wayland window managers (Sway, Hyprland, River, Niri, etc..) it's a tiny companion that interact nicely with your launcher. | |
| 16 | - | - Uses a native Wayland WebKitGTK-6.0 window; no external browser required and no X11/XWayland dependency. | |
| 17 | - | - Optimized for dmenu‑style launchers (rofi, fuzzel, wofi) and keyboard‑driven workflows. | |
| 9 | + | ## Features | |
| 10 | + | - **Wayland native**: No X11/XWayland dependency, works with Sway, Hyprland, River, Niri, etc. | |
| 11 | + | - **Tabs and workspaces**: Each tab holds a workspace with split or stacked panes. | |
| 12 | + | - **Keyboard-driven**: Complete keyboard workflow inspired by Zellij. | |
| 13 | + | - **Smart popup handling**: OAuth flows, window.open(), and popup deduplication for tiling WMs. | |
| 14 | + | - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection. | |
| 15 | + | - **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress). | |
| 16 | + | - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history. | |
| 17 | + | - **Search shortcuts**: Quick search via aliases (e.g., `g:golang` for Google). | |
| 18 | + | - **Customizable themes**: Light and dark palettes with semantic color tokens. | |
| 19 | + | - **Persistent storage**: SQLite for history, zoom levels, and settings. | |
| 20 | + | - **Live configuration**: Single config file with hot reload when possible. | |
| 18 | 21 | ||
| 19 | 22 | ## Status | |
| 20 | - | - Early development stage with regular releases. The workspace management system is fully functional and the browser works well for daily use. Expect some rough edges but core features are stable. | |
| 21 | - | ||
| 22 | - | ## Features | |
| 23 | - | - **Complete workspace management**: Zellij-inspired pane splitting with binary tree layout, stacked panes with title bars, focus tracking, and modal keyboard controls | |
| 24 | - | - **Multi-pane WebView architecture**: Independent browsing sessions per pane with proper lifecycle management | |
| 25 | - | - **Advanced popup handling**: Intelligent popup management for tiling WMs - OAuth flows, window.open(), and popup deduplication | |
| 26 | - | - **Window-level global shortcuts**: Centralized shortcut handling to prevent conflicts between multiple WebView instances | |
| 27 | - | - **GPU rendering and hardware video acceleration**: Automatic GPU detection with VA-API/VDPAU support | |
| 28 | - | - **Built-in ad blocker**: UBlock-based content filtering (network blocking functional, cosmetic filtering in progress) | |
| 29 | - | - **Configurable color palettes**: Customizable light and dark themes with semantic color tokens injected as CSS custom properties | |
| 30 | - | - **Terminal-style UI design**: Monospace fonts, sharp corners, dashed borders with configurable appearance settings | |
| 31 | - | - **dmenu‑style launcher integration**: rofi, fuzzel integration with favicon display, history and search shortcut suggestions | |
| 32 | - | - **Comprehensive keyboard controls**: Complete keyboard-driven workflow with shortcuts and gestures | |
| 33 | - | - **Persistent history and zoom**: SQLite-based history with configurable default zoom and per‑domain zoom persistence | |
| 34 | - | - **Fully configurable**: Single config file with live reload and environment variable overrides | |
| 23 | + | Early development with regular releases. Core features work well for daily use but expect some rough edges. | |
| 35 | 24 | ||
| 36 | 25 | ## Controls & Shortcuts | |
| 37 | 26 | ||
| @@ -53,7 +42,21 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl | |||
| 53 | 42 | | **Ctrl+Shift+R** | Hard Reload | Refresh ignoring cache | | |
| 54 | 43 | | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation | | |
| 55 | 44 | ||
| 56 | - | #### Zellij-Inspired Pane Management | |
| 45 | + | #### Tab Management | |
| 46 | + | | Shortcut | Action | Notes | | |
| 47 | + | |----------|--------|-------| | |
| 48 | + | | **Ctrl+T** | Enter Tab Mode | Modal mode for tab operations | | |
| 49 | + | | **Ctrl+Tab** | Next Tab | Quick tab switching | | |
| 50 | + | | **Ctrl+Shift+Tab** | Previous Tab | Quick tab switching | | |
| 51 | + | | **Ctrl+W** | Close Tab | Close current tab | | |
| 52 | + | | **N** / **C** (in tab mode) | New Tab | Create new tab | | |
| 53 | + | | **X** (in tab mode) | Close Tab | Close current tab | | |
| 54 | + | | **L** / **Tab** (in tab mode) | Next Tab | Navigate to next tab | | |
| 55 | + | | **H** / **Shift+Tab** (in tab mode) | Previous Tab | Navigate to previous tab | | |
| 56 | + | | **R** (in tab mode) | Rename Tab | Rename current tab | | |
| 57 | + | | **Escape** (in tab mode) | Exit Tab Mode | Return to normal navigation | | |
| 58 | + | ||
| 59 | + | #### Pane Management | |
| 57 | 60 | | Shortcut | Action | Notes | | |
| 58 | 61 | |----------|--------|-------| | |
| 59 | 62 | | **Ctrl+P** | Enter Pane Mode | Modal mode for pane operations | | |
| @@ -80,10 +83,13 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl | |||
| 80 | 83 | ### Omnibox/Find Mode (when active) | |
| 81 | 84 | | Shortcut | Action | Notes | | |
| 82 | 85 | |----------|--------|-------| | |
| 83 | - | | **Escape** | Close overlay | Exit omnibox or find mode | | |
| 86 | + | | **Escape** | Close/Clear | Clear input if text present, close if empty | | |
| 84 | 87 | | **Enter** | Execute/Navigate | Navigate to URL or jump to match | | |
| 85 | 88 | | **Shift+Enter** | Previous Match | Find mode: go to previous match | | |
| 86 | 89 | | **Alt+Enter** | Center Match | Find mode: center on match, keep open | | |
| 90 | + | | **Tab** | Toggle View | Omnibox mode: switch between history and favorites | | |
| 91 | + | | **Space** | Toggle Favorite | Omnibox mode: favorite/unfavorite selected item | | |
| 92 | + | | **Ctrl+1-9, 0** | Quick Navigate | Jump to result by number (1-10) | | |
| 87 | 93 | | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches | | |
| 88 | 94 | ||
| 89 | 95 | All zoom changes are automatically persisted per-domain and restored on next visit. | |
| @@ -99,7 +105,7 @@ Prerequisites: | |||
| 99 | 105 | Build options: | |
| 100 | 106 | - GUI (default, recommended): | |
| 101 | 107 | - `make build` # builds frontend + Go with `-tags=webkit_cgo` | |
| 102 | - | - Run: `./dist/dumber` # opens the embedded homepage | |
| 108 | + | - Run: `./dist/dumber` # to see all available commands. | |
| 103 | 109 | - CLI‑only (no GUI, CGO disabled): | |
| 104 | 110 | - `make build-no-gui` | |
| 105 | 111 | - Run: `./dist/dumber-no-gui version` | |
| @@ -112,7 +118,6 @@ Development: | |||
| 112 | 118 | **Note**: `go install` will not work for this project because the frontend assets must be built first and embedded into the binary. Use the build commands instead: | |
| 113 | 119 | ||
| 114 | 120 | - GUI build (default, recommended): | |
| 115 | - | - Ensure WebKit2GTK/GTK dev packages are installed (see prerequisites) | |
| 116 | 121 | - Clone the repo and run `make build` (builds frontend first, then Go with GUI support) | |
| 117 | 122 | - The resulting `./dist/dumber` binary includes the native GUI window | |
| 118 | 123 | - CLI‑only (no native window): | |
| @@ -123,16 +128,15 @@ Development: | |||
| 123 | 128 | - Open a URL or search: | |
| 124 | 129 | - `dumber browse https://example.com` | |
| 125 | 130 | - `dumber browse example.com` # scheme auto‑added | |
| 131 | + | - `dumber browse dumb://home` # built-in homepage with stats | |
| 126 | 132 | - `dumber browse g:golang` # Google search via shortcut | |
| 127 | - | - Launch the GUI directly: | |
| 128 | - | - `dumber` # no args → opens GUI homepage | |
| 129 | 133 | - Show version information: | |
| 130 | 134 | - `dumber version` # display version, commit, and build date | |
| 131 | 135 | - Launcher integration (dmenu‑style examples): | |
| 132 | - | - rofi: `dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select` | |
| 133 | - | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select` | |
| 136 | + | - rofi: `dumber dmenu | rofi -dmenu -p "🔍 " | dumber dmenu --select` | |
| 137 | + | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | |
| 134 | 138 | - Manage browsing history: | |
| 135 | - | - `dumber history` # list recent history (default: 20 entries) | |
| 139 | + | - `dumber history` # list recent history (default: 20 entries) | |
| 136 | 140 | - `dumber history list -n 50` # list 50 recent entries | |
| 137 | 141 | - `dumber history search golang` # search history for "golang" | |
| 138 | 142 | - `dumber history stats` # show history statistics | |
| @@ -157,9 +161,24 @@ Note: The root flag path only generates options; for processing a selection (`-- | |||
| 157 | 161 | ||
| 158 | 162 | In GUI mode the app serves an embedded homepage via `dumb://homepage`, and frontend assets under `dumb://app/...`. | |
| 159 | 163 | ||
| 160 | - | ## Pane Management (Zellij-Inspired) | |
| 164 | + | ## Tab Management | |
| 161 | 165 | ||
| 162 | - | Dumber features a Zellij-inspired pane management system that allows you to split your browser window into multiple panes, each running independent web sessions. | |
| 166 | + | Each tab holds its own workspace. Each workspace can contain multiple panes (split or stacked). Switch between tabs to switch between independent workspaces. | |
| 167 | + | ||
| 168 | + | ### How it Works | |
| 169 | + | 1. **Enter Tab Mode**: Press `Ctrl+T` to enter tab mode (modal interface with timeout) | |
| 170 | + | 2. **Create/Close Tabs**: Use keyboard shortcuts: | |
| 171 | + | - `N` or `C` - New tab | |
| 172 | + | - `X` - Close current tab | |
| 173 | + | - `R` - Rename tab | |
| 174 | + | 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode | |
| 175 | + | 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout | |
| 176 | + | ||
| 177 | + | Tab mode provides visual feedback with an orange border indicator. | |
| 178 | + | ||
| 179 | + | ## Pane Management | |
| 180 | + | ||
| 181 | + | Split your browser window into multiple panes, each running independent web sessions. | |
| 163 | 182 | ||
| 164 | 183 | ### How it Works | |
| 165 | 184 | 1. **Enter Pane Mode**: Press `Ctrl+P` to enter pane mode (modal interface with timeout) | |
| @@ -172,74 +191,13 @@ Dumber features a Zellij-inspired pane management system that allows you to spli | |||
| 172 | 191 | 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes | |
| 173 | 192 | 4. **Close**: Press `X` in pane mode to close the current pane | |
| 174 | 193 | ||
| 175 | - | ### Configuration | |
| 176 | - | The pane system is fully configurable via `config.json`: | |
| 177 | - | ```json | |
| 178 | - | "workspace": { | |
| 179 | - | "enable_zellij_controls": true, | |
| 180 | - | "pane_mode": { | |
| 181 | - | "activation_shortcut": "ctrl+p", | |
| 182 | - | "timeout_ms": 3000, | |
| 183 | - | "action_bindings": { | |
| 184 | - | "arrowright": "split-right", | |
| 185 | - | "r": "split-right", | |
| 186 | - | "x": "close-pane", | |
| 187 | - | // ... full key mappings | |
| 188 | - | } | |
| 189 | - | }, | |
| 190 | - | "popups": { | |
| 191 | - | "placement": "right", | |
| 192 | - | "open_in_new_pane": true | |
| 193 | - | }, | |
| 194 | - | "styling": { | |
| 195 | - | "border_width": 2, | |
| 196 | - | "border_color": "@theme_selected_bg_color", | |
| 197 | - | "transition_duration": 120 | |
| 198 | - | } | |
| 199 | - | } | |
| 200 | - | ``` | |
| 201 | - | ||
| 202 | 194 | ## Configuration | |
| 203 | 195 | ||
| 204 | - | Dumber follows the XDG Base Directory spec: | |
| 205 | - | - Config: `~/.config/dumber/config.json` | |
| 206 | - | - Data: `~/.local/share/dumber` | |
| 207 | - | - State: `~/.local/state/dumber` (default DB lives here) | |
| 208 | - | ||
| 209 | - | A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible. | |
| 210 | - | ||
| 211 | - | ### How Configuration Works | |
| 212 | - | ||
| 213 | - | - **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too) | |
| 214 | - | - **Format**: JSON/YAML/TOML - auto-detected | |
| 215 | - | - **JSON Schema**: Auto-generated for IDE autocompletion and validation | |
| 216 | - | - **Live reload**: Changes are automatically applied without restarting | |
| 217 | - | - **Environment variables**: Override any setting with `DUMB_BROWSER_*` or `DUMBER_*` prefix | |
| 218 | - | - **Default values**: Sensible defaults for all settings | |
| 219 | - | ||
| 220 | - | ### Quick Examples | |
| 221 | - | ||
| 222 | - | ```json | |
| 223 | - | { | |
| 224 | - | "default_zoom": 1.2, | |
| 225 | - | "rendering_mode": "gpu", | |
| 226 | - | "search_shortcuts": { | |
| 227 | - | "g": { "url": "https://google.com/search?q=%s", "description": "Google" } | |
| 228 | - | }, | |
| 229 | - | "workspace": { | |
| 230 | - | "enable_zellij_controls": true, | |
| 231 | - | "popups": { "behavior": "split" } | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | ``` | |
| 196 | + | Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML) | |
| 235 | 197 | ||
| 236 | - | Environment variable override: | |
| 237 | - | ```bash | |
| 238 | - | DUMBER_RENDERING_MODE=cpu dumber | |
| 239 | - | DUMB_BROWSER_LOGGING_LEVEL=debug dumber | |
| 240 | - | ``` | |
| 198 | + | A default config is created on first run. Changes are applied automatically (hot reload). Override any setting with `DUMB_BROWSER_*` or `DUMBER_*` environment variables. | |
| 241 | 199 | ||
| 242 | - | **📖 Complete documentation**: See [docs/CONFIG.md](docs/CONFIG.md) for all available settings, defaults, and valid values. | |
| 200 | + | **Full documentation**: [docs/CONFIG.md](docs/CONFIG.md) - Complete reference for all settings, workspace controls, shortcuts, styling, and more. | |
| 243 | 201 | ||
| 244 | 202 | ||
| 245 | 203 | ## Building With WebKitGTK 6 (GTK4) | |
| @@ -279,10 +237,10 @@ I will gladly accept contributions, especially related to UI/UX enhancements. Fe | |||
| 279 | 237 | ## Roadmap | |
| 280 | 238 | - ✅ WebKitGTK 6 (GTK4) migration (GPU/Vulkan path complete) | |
| 281 | 239 | - ✅ GPU rendering (Vulkan via GTK4 renderer) with graceful CPU fallback | |
| 282 | - | - ✅ Zellij-inspired pane management with binary tree layout and keyboard-driven workflow | |
| 240 | + | - ✅ Modal pane management with binary tree layout and keyboard-driven workflow | |
| 241 | + | - ✅ Tab management system with modal controls | |
| 283 | 242 | - 🚧 UBlock-based content filtering (early stage - network blocking works, cosmetic filtering needs work) | |
| 284 | 243 | - Full Vim-style motion and keyboard navigation across pages and UI | |
| 285 | - | - Tab management system (browser tabs in addition to panes) | |
| 286 | 244 | - Performance work: faster startup, lower memory, snappier UI | |
| 287 | 245 | - Other shiny things I'm not yet aware of | |
| 288 | 246 | ||
M assets/gui_embed.go +6 -0
| @@ -12,3 +12,9 @@ var GUIScript string | |||
| 12 | 12 | ||
| 13 | 13 | //go:embed gui/color-scheme.js | |
| 14 | 14 | var ColorSchemeScript string | |
| 15 | + | ||
| 16 | + | // GUI component styles extracted from Svelte components | |
| 17 | + | // These are injected into the isolated world using UserStyleSheet | |
| 18 | + | // | |
| 19 | + | //go:embed gui/gui.min.css | |
| 20 | + | var ComponentStyles string | |
M docs/CONFIG.md +109 -101
| @@ -1,23 +1,19 @@ | |||
| 1 | 1 | # Configuration Reference | |
| 2 | 2 | ||
| 3 | - | **Primary format**: JSON | |
| 4 | - | **Location**: `~/.config/dumber/config.json` | |
| 5 | - | **Also supports**: YAML (`.yaml`) and TOML (`.toml`) | |
| 6 | - | **JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated) | |
| 3 | + | **Primary format**: TOML (recommended) | |
| 4 | + | **Location**: `~/.config/dumber/config.toml` | |
| 5 | + | **Also supports**: JSON (`.json`) and YAML (`.yaml`) | |
| 6 | + | **JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated for JSON format) | |
| 7 | 7 | ||
| 8 | - | ## IDE Integration | |
| 8 | + | ## Why TOML? | |
| 9 | 9 | ||
| 10 | - | For autocompletion and validation in VS Code or other editors, add this to the top of your `config.json`: | |
| 10 | + | TOML is the recommended format for dumber configuration because: | |
| 11 | + | - **Clear structure**: Explicit sections make complex configs easy to read and modify | |
| 12 | + | - **No ambiguity**: No whitespace sensitivity or type coercion issues | |
| 13 | + | - **Comments supported**: Add documentation directly in your config | |
| 14 | + | - **Industry standard**: Used by Cargo, Hugo, and many modern tools | |
| 11 | 15 | ||
| 12 | - | ```json | |
| 13 | - | { | |
| 14 | - | "$schema": "./config.schema.json", | |
| 15 | - | "default_zoom": 1.2, | |
| 16 | - | ... | |
| 17 | - | } | |
| 18 | - | ``` | |
| 19 | - | ||
| 20 | - | The schema is automatically generated when you first create a config file. | |
| 16 | + | You can still use JSON or YAML if you prefer - dumber automatically detects the format. | |
| 21 | 17 | ||
| 22 | 18 | ## Database | |
| 23 | 19 | ||
| @@ -33,30 +29,41 @@ The schema is automatically generated when you first create a config file. | |||
| 33 | 29 | | `history.retention_period_days` | int | `365` | > 0 | Days to keep history (1 year) | | |
| 34 | 30 | | `history.cleanup_interval_days` | int | `1` | > 0 | How often to run cleanup | | |
| 35 | 31 | ||
| 36 | - | ## Search Shortcuts | |
| 32 | + | ## Search Configuration | |
| 37 | 33 | ||
| 38 | 34 | | Key | Type | Default | Description | | |
| 39 | 35 | |-----|------|---------|-------------| | |
| 36 | + | | `default_search_engine` | string | `"https://duckduckgo.com/?q=%s"` | Default search engine URL template (must contain `%s` placeholder) | | |
| 40 | 37 | | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs | | |
| 41 | 38 | ||
| 39 | + | **Example:** | |
| 40 | + | ```toml | |
| 41 | + | # Default search engine - used when no shortcut is specified | |
| 42 | + | default_search_engine = "https://duckduckgo.com/?q=%s" | |
| 43 | + | ||
| 44 | + | # Alternative search engines: | |
| 45 | + | # default_search_engine = "https://www.google.com/search?q=%s" | |
| 46 | + | # default_search_engine = "https://www.startpage.com/search?q=%s" | |
| 47 | + | # default_search_engine = "https://search.brave.com/search?q=%s" | |
| 48 | + | ``` | |
| 49 | + | ||
| 42 | 50 | **Default shortcuts:** | |
| 43 | - | ```json | |
| 44 | - | { | |
| 45 | - | "search_shortcuts": { | |
| 46 | - | "g": { | |
| 47 | - | "url": "https://google.com/search?q=%s", | |
| 48 | - | "description": "Google Search" | |
| 49 | - | }, | |
| 50 | - | "gh": { | |
| 51 | - | "url": "https://github.com/search?q=%s", | |
| 52 | - | "description": "GitHub" | |
| 53 | - | }, | |
| 54 | - | "yt": { | |
| 55 | - | "url": "https://youtube.com/results?search_query=%s", | |
| 56 | - | "description": "YouTube" | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | } | |
| 51 | + | ```toml | |
| 52 | + | [search_shortcuts.ddg] | |
| 53 | + | url = "https://duckduckgo.com/?q=%s" | |
| 54 | + | description = "DuckDuckGo search" | |
| 55 | + | ||
| 56 | + | [search_shortcuts.g] | |
| 57 | + | url = "https://google.com/search?q=%s" | |
| 58 | + | description = "Google Search" | |
| 59 | + | ||
| 60 | + | [search_shortcuts.gh] | |
| 61 | + | url = "https://github.com/search?q=%s" | |
| 62 | + | description = "GitHub" | |
| 63 | + | ||
| 64 | + | [search_shortcuts.yt] | |
| 65 | + | url = "https://youtube.com/results?search_query=%s" | |
| 66 | + | description = "YouTube" | |
| 60 | 67 | ``` | |
| 61 | 68 | ||
| 62 | 69 | ## Dmenu / Launcher | |
| @@ -105,37 +112,27 @@ The schema is automatically generated when you first create a config file. | |||
| 105 | 112 | ### Color Palettes | |
| 106 | 113 | ||
| 107 | 114 | **Light palette:** | |
| 108 | - | ```json | |
| 109 | - | { | |
| 110 | - | "appearance": { | |
| 111 | - | "light_palette": { | |
| 112 | - | "background": "#f8f8f8", | |
| 113 | - | "surface": "#f2f2f2", | |
| 114 | - | "surface_variant": "#ececec", | |
| 115 | - | "text": "#1a1a1a", | |
| 116 | - | "muted": "#6e6e6e", | |
| 117 | - | "accent": "#404040", | |
| 118 | - | "border": "#d2d2d2" | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | } | |
| 115 | + | ```toml | |
| 116 | + | [appearance.light_palette] | |
| 117 | + | background = "#f8f8f8" | |
| 118 | + | surface = "#f2f2f2" | |
| 119 | + | surface_variant = "#ececec" | |
| 120 | + | text = "#1a1a1a" | |
| 121 | + | muted = "#6e6e6e" | |
| 122 | + | accent = "#404040" | |
| 123 | + | border = "#d2d2d2" | |
| 122 | 124 | ``` | |
| 123 | 125 | ||
| 124 | 126 | **Dark palette:** | |
| 125 | - | ```json | |
| 126 | - | { | |
| 127 | - | "appearance": { | |
| 128 | - | "dark_palette": { | |
| 129 | - | "background": "#0e0e0e", | |
| 130 | - | "surface": "#1a1a1a", | |
| 131 | - | "surface_variant": "#141414", | |
| 132 | - | "text": "#e4e4e4", | |
| 133 | - | "muted": "#848484", | |
| 134 | - | "accent": "#a8a8a8", | |
| 135 | - | "border": "#363636" | |
| 136 | - | } | |
| 137 | - | } | |
| 138 | - | } | |
| 127 | + | ```toml | |
| 128 | + | [appearance.dark_palette] | |
| 129 | + | background = "#0e0e0e" | |
| 130 | + | surface = "#1a1a1a" | |
| 131 | + | surface_variant = "#141414" | |
| 132 | + | text = "#e4e4e4" | |
| 133 | + | muted = "#848484" | |
| 134 | + | accent = "#a8a8a8" | |
| 135 | + | border = "#363636" | |
| 139 | 136 | ``` | |
| 140 | 137 | ||
| 141 | 138 | ## Video Acceleration | |
| @@ -191,41 +188,50 @@ The schema is automatically generated when you first create a config file. | |||
| 191 | 188 | ||
| 192 | 189 | ## Workspace Configuration | |
| 193 | 190 | ||
| 194 | - | | Key | Type | Default | Description | | |
| 195 | - | |-----|------|---------|-------------| | |
| 196 | - | | `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings | | |
| 197 | - | ||
| 198 | 191 | ### Pane Mode | |
| 199 | 192 | ||
| 200 | 193 | | Key | Type | Default | Description | | |
| 201 | 194 | |-----|------|---------|-------------| | |
| 202 | 195 | | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key | | |
| 203 | 196 | | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) | | |
| 204 | - | | `workspace.pane_mode.action_bindings` | map | See below | Pane mode key bindings | | |
| 205 | - | ||
| 206 | - | **Default pane mode bindings:** | |
| 207 | - | ```json | |
| 208 | - | { | |
| 209 | - | "workspace": { | |
| 210 | - | "pane_mode": { | |
| 211 | - | "action_bindings": { | |
| 212 | - | "arrowright": "split-right", | |
| 213 | - | "arrowleft": "split-left", | |
| 214 | - | "arrowup": "split-up", | |
| 215 | - | "arrowdown": "split-down", | |
| 216 | - | "r": "split-right", | |
| 217 | - | "l": "split-left", | |
| 218 | - | "u": "split-up", | |
| 219 | - | "d": "split-down", | |
| 220 | - | "x": "close-pane", | |
| 221 | - | "enter": "confirm", | |
| 222 | - | "escape": "cancel" | |
| 223 | - | } | |
| 224 | - | } | |
| 225 | - | } | |
| 226 | - | } | |
| 197 | + | | `workspace.pane_mode.actions` | map | See below | Action→keys mappings | | |
| 198 | + | ||
| 199 | + | **Default pane mode actions:** | |
| 200 | + | ```toml | |
| 201 | + | [workspace.pane_mode.actions] | |
| 202 | + | split-right = ["arrowright", "r"] | |
| 203 | + | split-left = ["arrowleft", "l"] | |
| 204 | + | split-up = ["arrowup", "u"] | |
| 205 | + | split-down = ["arrowdown", "d"] | |
| 206 | + | close-pane = ["x"] | |
| 207 | + | confirm = ["enter"] | |
| 208 | + | cancel = ["escape"] | |
| 227 | 209 | ``` | |
| 228 | 210 | ||
| 211 | + | > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation. | |
| 212 | + | ||
| 213 | + | ### Tab Mode | |
| 214 | + | ||
| 215 | + | | Key | Type | Default | Description | | |
| 216 | + | |-----|------|---------|-------------| | |
| 217 | + | | `workspace.tab_mode.activation_shortcut` | string | `"ctrl+t"` | Tab mode activation key | | |
| 218 | + | | `workspace.tab_mode.timeout_ms` | int | `3000` | Tab mode timeout (ms) | | |
| 219 | + | | `workspace.tab_mode.actions` | map | See below | Action→keys mappings | | |
| 220 | + | ||
| 221 | + | **Default tab mode actions:** | |
| 222 | + | ```toml | |
| 223 | + | [workspace.tab_mode.actions] | |
| 224 | + | new-tab = ["n", "c"] | |
| 225 | + | close-tab = ["x"] | |
| 226 | + | next-tab = ["l", "tab"] | |
| 227 | + | previous-tab = ["h", "shift+tab"] | |
| 228 | + | rename-tab = ["r"] | |
| 229 | + | confirm = ["enter"] | |
| 230 | + | cancel = ["escape"] | |
| 231 | + | ``` | |
| 232 | + | ||
| 233 | + | > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation. | |
| 234 | + | ||
| 229 | 235 | ### Tab Shortcuts | |
| 230 | 236 | ||
| 231 | 237 | | Key | Type | Default | Description | | |
| @@ -251,11 +257,15 @@ The schema is automatically generated when you first create a config file. | |||
| 251 | 257 | ||
| 252 | 258 | | Key | Type | Default | Description | | |
| 253 | 259 | |-----|------|---------|-------------| | |
| 254 | - | | `workspace.styling.border_width` | int | `2` | Border width in pixels | | |
| 255 | - | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Border color (CSS or theme var) | | |
| 256 | - | | `workspace.styling.pane_mode_border_color` | string | `"#FFA500"` | Pane mode border color (orange) | | |
| 257 | - | | `workspace.styling.transition_duration` | int | `120` | Transition duration (ms) | | |
| 258 | - | | `workspace.styling.border_radius` | int | `0` | Border radius in pixels | | |
| 260 | + | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) | | |
| 261 | + | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | |
| 262 | + | | `workspace.styling.inactive_border_width` | int | `1` | Inactive pane border width (px) | | |
| 263 | + | | `workspace.styling.inactive_border_color` | string | `"@theme_unfocused_bg_color"` | Inactive pane border color | | |
| 264 | + | | `workspace.styling.show_stacked_title_border` | bool | `false` | Show separator on stacked pane titles | | |
| 265 | + | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | |
| 266 | + | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | |
| 267 | + | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | |
| 268 | + | | `workspace.styling.border_radius` | int | `0` | Border radius (px) | | |
| 259 | 269 | ||
| 260 | 270 | ## Content Filtering | |
| 261 | 271 | ||
| @@ -264,14 +274,12 @@ The schema is automatically generated when you first create a config file. | |||
| 264 | 274 | | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking | | |
| 265 | 275 | ||
| 266 | 276 | **Default whitelist:** | |
| 267 | - | ```json | |
| 268 | - | { | |
| 269 | - | "content_filtering_whitelist": [ | |
| 270 | - | "twitch.tv", | |
| 271 | - | "passport.twitch.tv", | |
| 272 | - | "gql.twitch.tv" | |
| 273 | - | ] | |
| 274 | - | } | |
| 277 | + | ```toml | |
| 278 | + | content_filtering_whitelist = [ | |
| 279 | + | "twitch.tv", | |
| 280 | + | "passport.twitch.tv", | |
| 281 | + | "gql.twitch.tv" | |
| 282 | + | ] | |
| 275 | 283 | ``` | |
| 276 | 284 | ||
| 277 | 285 | ## Environment Variables | |
M gui/package-lock.json +385 -453 generated file: collapsed
M gui/src/components/omnibox/Omnibox.svelte +76 -40
| @@ -178,33 +178,49 @@ | |||
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | const surfaceMix = isDarkMode ? '55%' : '88%'; | |
| 181 | - | const inputMix = isDarkMode ? '52%' : '90%'; | |
| 182 | - | const borderMix = isDarkMode ? '62%' : '85%'; | |
| 183 | 181 | const blurMix = isDarkMode ? '22%' : '12%'; | |
| 184 | 182 | ||
| 185 | 183 | const applyFade = faded && mode === 'find'; | |
| 186 | 184 | ||
| 185 | + | // GTK4-style: Container is slightly raised (use surface) | |
| 187 | 186 | const containerBackground = applyFade | |
| 188 | 187 | ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)` | |
| 189 | 188 | : 'var(--dynamic-surface)'; | |
| 189 | + | ||
| 190 | + | // GTK4-style: Input is recessed (darker background) | |
| 190 | 191 | const inputBackground = applyFade | |
| 191 | - | ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)` | |
| 192 | - | : 'var(--dynamic-bg)'; | |
| 193 | - | const borderColor = applyFade | |
| 194 | - | ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)` | |
| 192 | + | ? isDarkMode | |
| 193 | + | ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)` | |
| 194 | + | : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)` | |
| 195 | + | : isDarkMode | |
| 196 | + | ? 'color-mix(in srgb, var(--dynamic-bg) 85%, black 15%)' | |
| 197 | + | : 'color-mix(in srgb, var(--dynamic-bg) 95%, black 5%)'; | |
| 198 | + | ||
| 199 | + | // GTK4-style: Use palette border directly, slightly darker for input | |
| 200 | + | const containerBorderColor = applyFade | |
| 201 | + | ? `color-mix(in srgb, var(--dynamic-border) ${surfaceMix}, transparent)` | |
| 195 | 202 | : 'var(--dynamic-border)'; | |
| 203 | + | ||
| 204 | + | const inputBorderColor = applyFade | |
| 205 | + | ? isDarkMode | |
| 206 | + | ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 80%, black 20%) ${surfaceMix}, transparent)` | |
| 207 | + | : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 85%, black 15%) ${surfaceMix}, transparent)` | |
| 208 | + | : isDarkMode | |
| 209 | + | ? 'color-mix(in srgb, var(--dynamic-border) 80%, black 20%)' | |
| 210 | + | : 'color-mix(in srgb, var(--dynamic-border) 85%, black 15%)'; | |
| 211 | + | ||
| 196 | 212 | const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`; | |
| 197 | 213 | ||
| 198 | 214 | boxElement.style.background = containerBackground; | |
| 199 | 215 | boxElement.style.color = 'var(--dynamic-text)'; | |
| 200 | - | boxElement.style.setProperty('--dumber-border-color', borderColor); | |
| 216 | + | boxElement.style.setProperty('--dumber-border-color', containerBorderColor); | |
| 201 | 217 | // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette | |
| 202 | - | boxElement.style.setProperty('--dumber-omnibox-surface', 'color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%)'); | |
| 218 | + | boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground); | |
| 203 | 219 | ||
| 204 | 220 | inputElement.style.background = inputBackground; | |
| 205 | 221 | inputElement.style.color = 'var(--dynamic-text)'; | |
| 206 | 222 | inputElement.style.setProperty('--dumber-input-bg', inputBackground); | |
| 207 | - | inputElement.style.setProperty('--dumber-input-border-base', borderColor); | |
| 223 | + | inputElement.style.setProperty('--dumber-input-border-base', inputBorderColor); | |
| 208 | 224 | inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)'); | |
| 209 | 225 | ||
| 210 | 226 | blurLayerElement.style.setProperty('--dumber-blur-color', blurTint); | |
| @@ -250,7 +266,7 @@ | |||
| 250 | 266 | ||
| 251 | 267 | ||
| 252 | 268 | ||
| 253 | - | onMount(() => { | |
| 269 | + | onMount(async () => { | |
| 254 | 270 | syncTheme(); | |
| 255 | 271 | observeThemeChanges(); | |
| 256 | 272 | ||
| @@ -265,6 +281,17 @@ | |||
| 265 | 281 | // Add global click listener | |
| 266 | 282 | document.addEventListener('click', handleGlobalClick, true); | |
| 267 | 283 | ||
| 284 | + | // Load search shortcuts and favorites on mount | |
| 285 | + | try { | |
| 286 | + | await Promise.all([ | |
| 287 | + | omniboxBridge.fetchSearchShortcuts(), | |
| 288 | + | omniboxBridge.fetchFavorites() | |
| 289 | + | ]); | |
| 290 | + | console.log('✅ Search shortcuts and favorites loaded on mount'); | |
| 291 | + | } catch (error) { | |
| 292 | + | console.warn('Failed to load initial data:', error); | |
| 293 | + | } | |
| 294 | + | ||
| 268 | 295 | try { | |
| 269 | 296 | // Set up global API for Go bridge | |
| 270 | 297 | window.__dumber_omnibox = { | |
| @@ -399,17 +426,18 @@ | |||
| 399 | 426 | margin-right: 0 !important; | |
| 400 | 427 | width: {responsiveStyles.width}; | |
| 401 | 428 | padding: {responsiveStyles.padding}; | |
| 402 | - | font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', Menlo, monospace; | |
| 429 | + | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 403 | 430 | pointer-events: auto !important; | |
| 404 | 431 | box-sizing: border-box !important; | |
| 405 | - | background: var(--dumber-omnibox-surface, color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%)); | |
| 432 | + | background: var(--dumber-omnibox-surface, var(--dynamic-surface)); | |
| 406 | 433 | color: var(--dynamic-text); | |
| 407 | 434 | border: 1px solid var(--dumber-border-color, var(--dynamic-border)); | |
| 435 | + | border-radius: 3px; | |
| 408 | 436 | box-shadow: | |
| 409 | - | 0 20px 60px rgba(0, 0, 0, 0.45), | |
| 410 | - | 0 10px 30px rgba(0, 0, 0, 0.35), | |
| 411 | - | 0 5px 15px rgba(0, 0, 0, 0.25), | |
| 412 | - | inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent); | |
| 437 | + | 0 4px 12px rgba(0, 0, 0, 0.2), | |
| 438 | + | 0 2px 6px rgba(0, 0, 0, 0.15), | |
| 439 | + | 0 1px 3px rgba(0, 0, 0, 0.1), | |
| 440 | + | inset 0 1px 0 rgba(255, 255, 255, 0.04); | |
| 413 | 441 | --dumber-border-color: var(--dynamic-border);" | |
| 414 | 442 | onmousedown={handleBoxClick} | |
| 415 | 443 | onmouseenter={handleMouseEnter} | |
| @@ -443,54 +471,62 @@ | |||
| 443 | 471 | ||
| 444 | 472 | <style> | |
| 445 | 473 | .dumber-omnibox-container { | |
| 446 | - | background: var(--dumber-omnibox-surface, color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%)); | |
| 474 | + | background: var(--dumber-omnibox-surface, var(--dynamic-surface)); | |
| 447 | 475 | color: var(--dynamic-text); | |
| 448 | 476 | border: 1px solid var(--dumber-border-color, var(--dynamic-border)); | |
| 449 | 477 | transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease; | |
| 450 | - | border-radius: 0; | |
| 478 | + | border-radius: 3px; | |
| 451 | 479 | box-shadow: | |
| 452 | - | 0 20px 60px rgba(0, 0, 0, 0.45), | |
| 453 | - | 0 10px 30px rgba(0, 0, 0, 0.35), | |
| 454 | - | 0 5px 15px rgba(0, 0, 0, 0.25), | |
| 455 | - | inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent); | |
| 480 | + | 0 4px 12px rgba(0, 0, 0, 0.2), | |
| 481 | + | 0 2px 6px rgba(0, 0, 0, 0.15), | |
| 482 | + | 0 1px 3px rgba(0, 0, 0, 0.1), | |
| 483 | + | inset 0 1px 0 rgba(255, 255, 255, 0.04); | |
| 456 | 484 | } | |
| 457 | 485 | ||
| 458 | 486 | .dumber-omnibox-container:focus-within { | |
| 459 | - | border-color: color-mix(in srgb, var(--dumber-border-color, var(--dynamic-border)) 55%, var(--dynamic-text) 45%); | |
| 487 | + | border-color: var(--dynamic-accent); | |
| 460 | 488 | box-shadow: | |
| 461 | - | 0 25px 75px rgba(0, 0, 0, 0.5), | |
| 462 | - | 0 15px 40px rgba(0, 0, 0, 0.4), | |
| 463 | - | 0 8px 20px rgba(0, 0, 0, 0.3), | |
| 464 | - | inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 28%, transparent); | |
| 489 | + | 0 6px 16px rgba(0, 0, 0, 0.24), | |
| 490 | + | 0 3px 8px rgba(0, 0, 0, 0.18), | |
| 491 | + | 0 2px 4px rgba(0, 0, 0, 0.12), | |
| 492 | + | inset 0 1px 0 rgba(255, 255, 255, 0.06), | |
| 493 | + | 0 0 0 1px var(--dynamic-accent); | |
| 465 | 494 | } | |
| 466 | 495 | ||
| 467 | 496 | :global(.dumber-omnibox-container input) { | |
| 468 | - | background: var(--dumber-input-bg, color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%)); | |
| 497 | + | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 469 | 498 | color: var(--dynamic-text); | |
| 470 | 499 | border: 1px solid var(--dumber-input-border-color, var(--dynamic-border)); | |
| 471 | - | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; | |
| 500 | + | border-radius: 2px; | |
| 501 | + | box-shadow: | |
| 502 | + | inset 0 1px 2px rgba(0, 0, 0, 0.15), | |
| 503 | + | inset 0 0 0 1px rgba(0, 0, 0, 0.03); | |
| 504 | + | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease; | |
| 472 | 505 | font-family: | |
| 473 | - | "JetBrains Mono", | |
| 474 | - | "Fira Code", | |
| 475 | - | "SFMono-Regular", | |
| 476 | - | Menlo, | |
| 477 | - | monospace; | |
| 506 | + | "Fira Sans", | |
| 507 | + | system-ui, | |
| 508 | + | -apple-system, | |
| 509 | + | "Segoe UI", | |
| 510 | + | "Ubuntu", | |
| 511 | + | "Cantarell", | |
| 512 | + | sans-serif; | |
| 478 | 513 | font-size: inherit; | |
| 479 | - | letter-spacing: 0.05em; | |
| 514 | + | letter-spacing: normal; | |
| 480 | 515 | text-transform: none; | |
| 481 | 516 | } | |
| 482 | 517 | ||
| 483 | 518 | :global(.dumber-omnibox-container input::placeholder) { | |
| 484 | 519 | color: var(--dynamic-muted); | |
| 485 | - | letter-spacing: 0.08em; | |
| 486 | - | text-transform: uppercase; | |
| 520 | + | letter-spacing: normal; | |
| 521 | + | text-transform: none; | |
| 487 | 522 | } | |
| 488 | 523 | ||
| 489 | 524 | /* Blur layer styles: use `backdrop-filter` but animate opacity only. */ | |
| 525 | + | /* GTK4-style: Minimal blur for faded state */ | |
| 490 | 526 | .dumber-omnibox-blur-layer { | |
| 491 | - | backdrop-filter: blur(1.5px) saturate(105%); | |
| 492 | - | -webkit-backdrop-filter: blur(1.5px) saturate(105%); | |
| 493 | - | background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 12%, transparent)); | |
| 527 | + | backdrop-filter: blur(0.5px) saturate(102%); | |
| 528 | + | -webkit-backdrop-filter: blur(0.5px) saturate(102%); | |
| 529 | + | background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent)); | |
| 494 | 530 | transition: opacity 140ms ease; | |
| 495 | 531 | will-change: opacity; | |
| 496 | 532 | z-index: 0; | |
M gui/src/components/omnibox/OmniboxInput.svelte +126 -8
| @@ -10,9 +10,11 @@ | |||
| 10 | 10 | ||
| 11 | 11 | // Reactive state | |
| 12 | 12 | let mode = $derived(omniboxStore.mode); | |
| 13 | + | let viewMode = $derived(omniboxStore.viewMode); | |
| 13 | 14 | let inputValue = $derived(omniboxStore.inputValue); | |
| 14 | 15 | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 15 | 16 | let suggestions = $derived(omniboxStore.suggestions); | |
| 17 | + | let favorites = $derived(omniboxStore.favorites); | |
| 16 | 18 | let matches = $derived(omniboxStore.matches); | |
| 17 | 19 | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | |
| 18 | 20 | ||
| @@ -117,8 +119,11 @@ | |||
| 117 | 119 | omniboxStore.setFaded(false); | |
| 118 | 120 | ||
| 119 | 121 | if (mode === 'omnibox') { | |
| 120 | - | // Debounced search for omnibox | |
| 121 | - | debouncedQuery(value); | |
| 122 | + | // Only query backend if we're in history view | |
| 123 | + | // In favorites view, filtering happens locally via computed state | |
| 124 | + | if (viewMode === 'history') { | |
| 125 | + | debouncedQuery(value); | |
| 126 | + | } | |
| 122 | 127 | } else if (mode === 'find') { | |
| 123 | 128 | // Immediate find for search | |
| 124 | 129 | findInPage(value); | |
| @@ -131,7 +136,19 @@ | |||
| 131 | 136 | case 'Escape': | |
| 132 | 137 | event.preventDefault(); | |
| 133 | 138 | event.stopPropagation(); | |
| 134 | - | omniboxStore.close(); | |
| 139 | + | ||
| 140 | + | if (inputValue && inputValue.trim() !== '') { | |
| 141 | + | // Input has text - clear it but keep omnibox open | |
| 142 | + | omniboxStore.setInputValue(''); | |
| 143 | + | ||
| 144 | + | // In find mode, clear the search highlights | |
| 145 | + | if (mode === 'find') { | |
| 146 | + | findInPage(''); | |
| 147 | + | } | |
| 148 | + | } else { | |
| 149 | + | // Input is empty - close omnibox immediately | |
| 150 | + | omniboxStore.close(); | |
| 151 | + | } | |
| 135 | 152 | break; | |
| 136 | 153 | ||
| 137 | 154 | case 'Enter': | |
| @@ -140,6 +157,26 @@ | |||
| 140 | 157 | handleEnterKey(event); | |
| 141 | 158 | break; | |
| 142 | 159 | ||
| 160 | + | case 'Tab': | |
| 161 | + | // Only handle Tab in omnibox mode (not in find mode) | |
| 162 | + | if (mode === 'omnibox') { | |
| 163 | + | event.preventDefault(); | |
| 164 | + | event.stopPropagation(); | |
| 165 | + | // Toggle between history and favorites views | |
| 166 | + | const newViewMode = viewMode === 'history' ? 'favorites' : 'history'; | |
| 167 | + | omniboxStore.setViewMode(newViewMode); | |
| 168 | + | } | |
| 169 | + | break; | |
| 170 | + | ||
| 171 | + | case ' ': | |
| 172 | + | // Only handle Space in omnibox mode when an item is selected | |
| 173 | + | if (mode === 'omnibox' && selectedIndex >= 0) { | |
| 174 | + | event.preventDefault(); | |
| 175 | + | event.stopPropagation(); | |
| 176 | + | handleSpaceKey(); | |
| 177 | + | } | |
| 178 | + | break; | |
| 179 | + | ||
| 143 | 180 | case 'ArrowDown': | |
| 144 | 181 | case 'ArrowUp': | |
| 145 | 182 | event.preventDefault(); | |
| @@ -148,6 +185,27 @@ | |||
| 148 | 185 | break; | |
| 149 | 186 | ||
| 150 | 187 | default: | |
| 188 | + | // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results | |
| 189 | + | // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.) | |
| 190 | + | if (event.ctrlKey && mode === 'omnibox') { | |
| 191 | + | const code = event.code; | |
| 192 | + | let targetIndex = -1; | |
| 193 | + | ||
| 194 | + | // Map physical keys: Digit1->0, Digit2->1, ..., Digit9->8, Digit0->9 | |
| 195 | + | if (code >= 'Digit1' && code <= 'Digit9') { | |
| 196 | + | targetIndex = parseInt(code.substring(5), 10) - 1; | |
| 197 | + | } else if (code === 'Digit0') { | |
| 198 | + | targetIndex = 9; | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | if (targetIndex >= 0) { | |
| 202 | + | event.preventDefault(); | |
| 203 | + | event.stopPropagation(); | |
| 204 | + | handleNumberKey(targetIndex); | |
| 205 | + | return; | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 151 | 209 | // For normal typing keys, only stop propagation to prevent page handlers | |
| 152 | 210 | // but don't prevent default so typing still works in the input | |
| 153 | 211 | event.stopPropagation(); | |
| @@ -160,8 +218,10 @@ | |||
| 160 | 218 | // Handle Enter key based on mode | |
| 161 | 219 | function handleEnterKey(event: KeyboardEvent) { | |
| 162 | 220 | if (mode === 'omnibox') { | |
| 163 | - | const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null; | |
| 164 | - | const url = selectedSuggestion?.url || inputValue || ''; | |
| 221 | + | // Get selected item from current view (history or favorites) | |
| 222 | + | const currentList = viewMode === 'history' ? suggestions : favorites; | |
| 223 | + | const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null; | |
| 224 | + | const url = selectedItem?.url || inputValue || ''; | |
| 165 | 225 | ||
| 166 | 226 | if (url) { | |
| 167 | 227 | omniboxBridge.navigate(url); | |
| @@ -182,9 +242,57 @@ | |||
| 182 | 242 | } | |
| 183 | 243 | } | |
| 184 | 244 | ||
| 245 | + | // Handle Space key to toggle favorite | |
| 246 | + | function handleSpaceKey() { | |
| 247 | + | if (mode !== 'omnibox' || selectedIndex < 0) return; | |
| 248 | + | ||
| 249 | + | // Get the selected item based on current view mode | |
| 250 | + | const item = viewMode === 'history' | |
| 251 | + | ? suggestions[selectedIndex] | |
| 252 | + | : favorites[selectedIndex]; | |
| 253 | + | ||
| 254 | + | if (!item || !item.url) return; | |
| 255 | + | ||
| 256 | + | // Extract title and favicon | |
| 257 | + | const title = 'title' in item ? item.title : ''; | |
| 258 | + | const faviconURL = item.favicon_url || item.favicon || ''; | |
| 259 | + | ||
| 260 | + | // Toggle the favorite status | |
| 261 | + | omniboxBridge.toggleFavorite(item.url, title, faviconURL); | |
| 262 | + | ||
| 263 | + | // Refresh the favorites list after toggling | |
| 264 | + | setTimeout(() => { | |
| 265 | + | omniboxBridge.fetchFavorites(); | |
| 266 | + | }, 100); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Handle Ctrl+Number key for quick navigation | |
| 270 | + | function handleNumberKey(targetIndex: number) { | |
| 271 | + | if (mode !== 'omnibox') return; | |
| 272 | + | ||
| 273 | + | // Get the current list based on view mode | |
| 274 | + | const currentList = viewMode === 'history' ? suggestions : favorites; | |
| 275 | + | ||
| 276 | + | // Check if the target index is valid | |
| 277 | + | if (targetIndex >= currentList.length) return; | |
| 278 | + | ||
| 279 | + | const item = currentList[targetIndex]; | |
| 280 | + | if (!item || !item.url) return; | |
| 281 | + | ||
| 282 | + | // Navigate to the item and close omnibox | |
| 283 | + | omniboxBridge.navigate(item.url); | |
| 284 | + | omniboxStore.close(); | |
| 285 | + | } | |
| 286 | + | ||
| 185 | 287 | // Handle arrow key navigation | |
| 186 | 288 | function handleArrowKeys(key: string) { | |
| 187 | - | const totalItems = mode === 'omnibox' ? suggestions.length : matches.length; | |
| 289 | + | let totalItems: number; | |
| 290 | + | if (mode === 'omnibox') { | |
| 291 | + | // Get count from current view (history or favorites) | |
| 292 | + | totalItems = viewMode === 'history' ? suggestions.length : favorites.length; | |
| 293 | + | } else { | |
| 294 | + | totalItems = matches.length; | |
| 295 | + | } | |
| 188 | 296 | ||
| 189 | 297 | if (totalItems > 0) { | |
| 190 | 298 | if (key === 'ArrowDown') { | |
| @@ -325,16 +433,26 @@ | |||
| 325 | 433 | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 326 | 434 | color: var(--dynamic-text); | |
| 327 | 435 | border: 1px solid var(--dumber-input-border-color, var(--dynamic-border)); | |
| 328 | - | border-radius: 0; | |
| 329 | - | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; | |
| 436 | + | border-radius: 2px; | |
| 437 | + | box-shadow: | |
| 438 | + | inset 0 1px 2px rgba(0, 0, 0, 0.15), | |
| 439 | + | inset 0 0 0 1px rgba(0, 0, 0, 0.03); | |
| 440 | + | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease; | |
| 441 | + | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 442 | + | letter-spacing: normal; | |
| 330 | 443 | } | |
| 331 | 444 | ||
| 332 | 445 | .omnibox-input-field::placeholder { | |
| 333 | 446 | color: var(--dynamic-muted); | |
| 447 | + | letter-spacing: normal; | |
| 334 | 448 | } | |
| 335 | 449 | ||
| 336 | 450 | .omnibox-input-field:focus { | |
| 337 | 451 | color: var(--dynamic-text); | |
| 452 | + | border-color: var(--dynamic-accent); | |
| 453 | + | box-shadow: | |
| 454 | + | inset 0 1px 2px rgba(0, 0, 0, 0.15), | |
| 455 | + | 0 0 0 1px var(--dynamic-accent); | |
| 338 | 456 | } | |
| 339 | 457 | ||
| 340 | 458 | .omnibox-prefix-badge { | |
M gui/src/components/omnibox/OmniboxSuggestions.svelte +206 -33
| @@ -9,14 +9,43 @@ | |||
| 9 | 9 | ||
| 10 | 10 | // Reactive state | |
| 11 | 11 | let suggestions = $derived(omniboxStore.suggestions); | |
| 12 | + | let favorites = $derived(omniboxStore.favorites); | |
| 13 | + | let viewMode = $derived(omniboxStore.viewMode); | |
| 12 | 14 | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 15 | + | let inputValue = $derived(omniboxStore.inputValue); | |
| 13 | 16 | let hasContent = $state(false); | |
| 14 | 17 | ||
| 15 | - | // Update hasContent when suggestions change | |
| 18 | + | // Debug effect to log favorites changes | |
| 16 | 19 | $effect(() => { | |
| 17 | - | hasContent = omniboxStore.mode === 'omnibox' && suggestions.length > 0; | |
| 20 | + | console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length); | |
| 18 | 21 | }); | |
| 19 | 22 | ||
| 23 | + | // Computed: Get current list based on view mode with local filtering for favorites | |
| 24 | + | let currentList = $derived.by(() => { | |
| 25 | + | const list = viewMode === 'history' ? suggestions : favorites; | |
| 26 | + | ||
| 27 | + | // In favorites view with input, filter locally | |
| 28 | + | if (viewMode === 'favorites' && inputValue && inputValue.trim()) { | |
| 29 | + | const query = inputValue.toLowerCase().trim(); | |
| 30 | + | return list.filter(fav => | |
| 31 | + | fav.url.toLowerCase().includes(query) || | |
| 32 | + | (fav.title && fav.title.toLowerCase().includes(query)) | |
| 33 | + | ); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | return list; | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | // Update hasContent - always show in omnibox mode to display header | |
| 40 | + | $effect(() => { | |
| 41 | + | hasContent = omniboxStore.mode === 'omnibox'; | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | // Check if a URL is in favorites | |
| 45 | + | function isFavorited(url: string): boolean { | |
| 46 | + | return favorites.some(fav => fav.url === url); | |
| 47 | + | } | |
| 48 | + | ||
| 20 | 49 | // Handle suggestion item mouse enter | |
| 21 | 50 | function handleItemMouseEnter(index: number) { | |
| 22 | 51 | omniboxStore.setSelectedIndex(index); | |
| @@ -90,30 +119,62 @@ | |||
| 90 | 119 | role="listbox" | |
| 91 | 120 | aria-label="Search suggestions" | |
| 92 | 121 | > | |
| 93 | - | {#each suggestions as suggestion, index (suggestion.url)} | |
| 94 | - | {@const { domain, path } = parseUrl(suggestion.url)} | |
| 95 | - | {@const isSelected = index === selectedIndex} | |
| 96 | - | ||
| 97 | - | <div | |
| 98 | - | id="omnibox-item-{index}" | |
| 99 | - | class={isSelected ? 'suggestion-item selected' : 'suggestion-item'} | |
| 100 | - | role="option" | |
| 101 | - | tabindex={isSelected ? 0 : -1} | |
| 102 | - | aria-selected={isSelected} | |
| 103 | - | onmouseenter={() => handleItemMouseEnter(index)} | |
| 104 | - | onclick={() => handleItemClick(suggestion)} | |
| 105 | - | onkeydown={(e) => handleItemKeyDown(e, suggestion)} | |
| 106 | - | > | |
| 122 | + | <!-- View mode indicator --> | |
| 123 | + | <div class="view-mode-header"> | |
| 124 | + | <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}> | |
| 125 | + | History ({suggestions.length}) | |
| 126 | + | </span> | |
| 127 | + | <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}> | |
| 128 | + | Favorites ({favorites.length}) | |
| 129 | + | </span> | |
| 130 | + | <span class="view-hint">Tab to switch</span> | |
| 131 | + | </div> | |
| 132 | + | ||
| 133 | + | {#if currentList.length === 0} | |
| 134 | + | <!-- Empty state --> | |
| 135 | + | {#if viewMode === 'favorites'} | |
| 136 | + | <!-- Always show empty state for favorites --> | |
| 137 | + | <div class="empty-state"> | |
| 138 | + | <span class="empty-icon">⭐</span> | |
| 139 | + | <span class="empty-text">No favorites yet</span> | |
| 140 | + | <span class="empty-hint">Press Space on any item in History to add it here</span> | |
| 141 | + | </div> | |
| 142 | + | {:else if inputValue && inputValue.trim()} | |
| 143 | + | <!-- Only show "No results found" for history when user has typed something --> | |
| 144 | + | <div class="empty-state"> | |
| 145 | + | <span class="empty-icon">🔍</span> | |
| 146 | + | <span class="empty-text">No results found</span> | |
| 147 | + | </div> | |
| 148 | + | {/if} | |
| 149 | + | {:else} | |
| 150 | + | {#each currentList as item, index (item.url)} | |
| 151 | + | {@const { domain, path } = parseUrl(item.url)} | |
| 152 | + | {@const isSelected = index === selectedIndex} | |
| 153 | + | {@const favicon = item.favicon || item.favicon_url || ''} | |
| 154 | + | {@const isFav = viewMode === 'history' && isFavorited(item.url)} | |
| 155 | + | {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null} | |
| 156 | + | ||
| 157 | + | <div | |
| 158 | + | id="omnibox-item-{index}" | |
| 159 | + | class:suggestion-item={true} | |
| 160 | + | class:selected={isSelected} | |
| 161 | + | class:favorited={isFav} | |
| 162 | + | role="option" | |
| 163 | + | tabindex={isSelected ? 0 : -1} | |
| 164 | + | aria-selected={isSelected} | |
| 165 | + | onmouseenter={() => handleItemMouseEnter(index)} | |
| 166 | + | onclick={() => handleItemClick(item)} | |
| 167 | + | onkeydown={(e) => handleItemKeyDown(e, item)} | |
| 168 | + | > | |
| 107 | 169 | <!-- Favicon chip --> | |
| 108 | - | {#if suggestion.favicon} | |
| 170 | + | {#if favicon} | |
| 109 | 171 | <div class="suggestion-favicon"> | |
| 110 | 172 | <img | |
| 111 | - | src={suggestion.favicon} | |
| 173 | + | src={favicon} | |
| 112 | 174 | alt="" | |
| 113 | 175 | class="suggestion-favicon-img" | |
| 114 | 176 | loading="lazy" | |
| 115 | 177 | onerror={handleFaviconError} | |
| 116 | - | style="image-rendering: -webkit-optimize-contrast;" | |
| 117 | 178 | /> | |
| 118 | 179 | </div> | |
| 119 | 180 | {/if} | |
| @@ -135,8 +196,16 @@ | |||
| 135 | 196 | {path || '/'} | |
| 136 | 197 | </span> | |
| 137 | 198 | </div> | |
| 199 | + | ||
| 200 | + | <!-- Keyboard shortcut hint (Ctrl+1-9, Ctrl+0 for 10th) --> | |
| 201 | + | {#if shortcutNumber !== null} | |
| 202 | + | <div class="suggestion-shortcut-hint" aria-hidden="true"> | |
| 203 | + | {shortcutNumber} | |
| 204 | + | </div> | |
| 205 | + | {/if} | |
| 138 | 206 | </div> | |
| 139 | 207 | {/each} | |
| 208 | + | {/if} | |
| 140 | 209 | </div> | |
| 141 | 210 | {/if} | |
| 142 | 211 | ||
| @@ -144,36 +213,117 @@ | |||
| 144 | 213 | ||
| 145 | 214 | <style> | |
| 146 | 215 | .suggestion-list { | |
| 147 | - | margin-top: 0.75rem; | |
| 216 | + | margin-top: 0.5rem; | |
| 148 | 217 | max-height: 50vh; | |
| 149 | 218 | overflow-y: auto; | |
| 150 | 219 | border-top: 1px solid var(--dynamic-border); | |
| 151 | - | background: color-mix(in srgb, var(--dynamic-bg) 94%, var(--dynamic-surface) 6%); | |
| 220 | + | background: var(--dynamic-bg); | |
| 221 | + | border-radius: 0 0 2px 2px; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | .view-mode-header { | |
| 225 | + | display: flex; | |
| 226 | + | gap: 0.5rem; | |
| 227 | + | align-items: center; | |
| 228 | + | padding: 0.5rem 0.85rem; | |
| 229 | + | border-bottom: 1px solid var(--dynamic-border); | |
| 230 | + | background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-text)); | |
| 231 | + | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | .view-tab { | |
| 235 | + | padding: 0.25rem 0.75rem; | |
| 236 | + | border-radius: 2px; | |
| 237 | + | font-size: 0.85rem; | |
| 238 | + | font-weight: 500; | |
| 239 | + | color: var(--dynamic-muted); | |
| 240 | + | transition: all 100ms ease; | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | .view-tab.active { | |
| 244 | + | color: var(--dynamic-accent); | |
| 245 | + | background: color-mix(in srgb, var(--dynamic-accent) 15%, transparent); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | .view-hint { | |
| 249 | + | margin-left: auto; | |
| 250 | + | font-size: 0.75rem; | |
| 251 | + | color: var(--dynamic-muted); | |
| 252 | + | opacity: 0.7; | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | .empty-state { | |
| 256 | + | display: flex; | |
| 257 | + | flex-direction: column; | |
| 258 | + | align-items: center; | |
| 259 | + | justify-content: center; | |
| 260 | + | padding: 2rem 1rem; | |
| 261 | + | gap: 0.5rem; | |
| 262 | + | color: var(--dynamic-muted); | |
| 263 | + | text-align: center; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | .empty-icon { | |
| 267 | + | font-size: 2rem; | |
| 268 | + | opacity: 0.5; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | .empty-text { | |
| 272 | + | font-size: 0.95rem; | |
| 273 | + | font-weight: 500; | |
| 274 | + | color: var(--dynamic-text); | |
| 275 | + | opacity: 0.7; | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | .empty-hint { | |
| 279 | + | font-size: 0.8rem; | |
| 280 | + | opacity: 0.6; | |
| 281 | + | max-width: 300px; | |
| 152 | 282 | } | |
| 153 | 283 | ||
| 154 | 284 | .suggestion-item { | |
| 155 | 285 | display: flex; | |
| 156 | 286 | gap: 0.75rem; | |
| 157 | 287 | align-items: center; | |
| 158 | - | padding: 0.65rem 0.85rem; | |
| 159 | - | border-bottom: 1px dashed var(--dynamic-border); | |
| 288 | + | padding: 0.85rem 0.85rem; | |
| 289 | + | border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent); | |
| 160 | 290 | cursor: pointer; | |
| 161 | - | transition: background-color 120ms ease, color 120ms ease; | |
| 162 | - | letter-spacing: 0.04em; | |
| 291 | + | transition: background-color 100ms ease, border-left-color 100ms ease; | |
| 292 | + | letter-spacing: normal; | |
| 293 | + | border-left: 3px solid transparent; | |
| 294 | + | position: relative; | |
| 295 | + | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 163 | 296 | } | |
| 164 | 297 | ||
| 165 | 298 | .suggestion-item:last-child { | |
| 166 | 299 | border-bottom: none; | |
| 167 | 300 | } | |
| 168 | 301 | ||
| 302 | + | /* Favorited items get a permanent yellow left border */ | |
| 303 | + | .suggestion-item.favorited { | |
| 304 | + | border-left-color: #f59e0b; | |
| 305 | + | } | |
| 306 | + | ||
| 169 | 307 | .suggestion-item.selected, | |
| 170 | 308 | .suggestion-item:hover, | |
| 171 | 309 | .suggestion-item:focus-visible { | |
| 172 | - | background: color-mix(in srgb, var(--dynamic-bg) 60%, var(--dynamic-surface) 40%); | |
| 310 | + | background: color-mix(in srgb, var(--dynamic-accent) 15%, var(--dynamic-bg)); | |
| 311 | + | border-left-color: var(--dynamic-accent); | |
| 173 | 312 | color: var(--dynamic-text); | |
| 174 | 313 | outline: none; | |
| 175 | 314 | } | |
| 176 | 315 | ||
| 316 | + | /* Keep yellow border even when selected/hover for favorited items */ | |
| 317 | + | .suggestion-item.favorited.selected, | |
| 318 | + | .suggestion-item.favorited:hover, | |
| 319 | + | .suggestion-item.favorited:focus-visible { | |
| 320 | + | border-left-color: #f59e0b; | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | .suggestion-item.selected { | |
| 324 | + | font-weight: 500; | |
| 325 | + | } | |
| 326 | + | ||
| 177 | 327 | .suggestion-item .suggestion-domain { | |
| 178 | 328 | color: var(--dynamic-text); | |
| 179 | 329 | opacity: 0.95; | |
| @@ -188,24 +338,23 @@ | |||
| 188 | 338 | } | |
| 189 | 339 | ||
| 190 | 340 | .suggestion-favicon { | |
| 191 | - | width: 20px; | |
| 192 | - | height: 20px; | |
| 341 | + | width: 24px; | |
| 342 | + | height: 24px; | |
| 193 | 343 | border: none; | |
| 194 | 344 | border-radius: 3px; | |
| 195 | 345 | display: flex; | |
| 196 | 346 | align-items: center; | |
| 197 | 347 | justify-content: center; | |
| 198 | - | background: var(--dynamic-bg); | |
| 348 | + | background: transparent; | |
| 199 | 349 | flex-shrink: 0; | |
| 200 | 350 | overflow: hidden; | |
| 201 | 351 | } | |
| 202 | 352 | ||
| 203 | 353 | .suggestion-favicon-img { | |
| 204 | - | width: 16px; | |
| 205 | - | height: 16px; | |
| 354 | + | width: 24px; | |
| 355 | + | height: 24px; | |
| 206 | 356 | object-fit: contain; | |
| 207 | - | image-rendering: -webkit-optimize-contrast; | |
| 208 | - | -ms-interpolation-mode: nearest-neighbor; | |
| 357 | + | image-rendering: auto; | |
| 209 | 358 | } | |
| 210 | 359 | ||
| 211 | 360 | .suggestion-text { | |
| @@ -220,4 +369,28 @@ | |||
| 220 | 369 | mask-image: linear-gradient(90deg, black 85%, transparent 100%); | |
| 221 | 370 | -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%); | |
| 222 | 371 | } | |
| 372 | + | ||
| 373 | + | .suggestion-shortcut-hint { | |
| 374 | + | position: absolute; | |
| 375 | + | right: 0.85rem; | |
| 376 | + | top: 50%; | |
| 377 | + | transform: translateY(-50%); | |
| 378 | + | font-size: 0.75rem; | |
| 379 | + | font-weight: 600; | |
| 380 | + | color: var(--dynamic-muted); | |
| 381 | + | opacity: 0.6; | |
| 382 | + | background: var(--dynamic-bg); | |
| 383 | + | padding: 0.25rem 0.5rem; | |
| 384 | + | border-radius: 3px; | |
| 385 | + | border: 1px solid var(--dynamic-border); | |
| 386 | + | z-index: 10; | |
| 387 | + | pointer-events: none; | |
| 388 | + | transition: opacity 100ms ease, color 100ms ease; | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | .suggestion-item.selected .suggestion-shortcut-hint, | |
| 392 | + | .suggestion-item:hover .suggestion-shortcut-hint { | |
| 393 | + | opacity: 0.9; | |
| 394 | + | color: var(--dynamic-accent); | |
| 395 | + | } | |
| 223 | 396 | </style> | |
M gui/src/components/omnibox/messaging.ts +76 -26
| @@ -4,36 +4,29 @@ | |||
| 4 | 4 | * TypeScript interface for Go-JavaScript communication | |
| 5 | 5 | */ | |
| 6 | 6 | ||
| 7 | - | import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, SearchShortcut } from "./types"; | |
| 7 | + | import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, Favorite, SearchShortcut } from "./types"; | |
| 8 | 8 | import { omniboxStore } from "./stores.svelte.ts"; | |
| 9 | 9 | ||
| 10 | 10 | export class OmniboxBridge implements OmniboxMessageBridge { | |
| 11 | - | // Forward messages to the native WebKit message handler when available | |
| 12 | - | // Fallback: for navigate, perform direct location change | |
| 11 | + | // Forward messages via CustomEvent to main world bridge | |
| 12 | + | // The main world will forward to webkit.messageHandlers.dumber | |
| 13 | 13 | postMessage(msg: OmniboxMessage): void { | |
| 14 | - | console.log("📤 [DEBUG] Posting message to backend:", msg); | |
| 15 | - | // NOTE: Do NOT detach postMessage from its receiver. In WKWebView, | |
| 16 | - | // postMessage must be called on the UserMessageHandler instance. | |
| 17 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 18 | - | if (bridge && typeof bridge.postMessage === "function") { | |
| 19 | - | try { | |
| 20 | - | console.log("📱 [DEBUG] Using webkit message handler"); | |
| 21 | - | bridge.postMessage(JSON.stringify(msg)); | |
| 22 | - | return; | |
| 23 | - | } catch (e) { | |
| 24 | - | console.warn( | |
| 25 | - | "postMessage to native handler failed, using fallback:", | |
| 26 | - | e, | |
| 27 | - | ); | |
| 28 | - | } | |
| 29 | - | } | |
| 30 | - | console.log("⚠️ [DEBUG] No webkit bridge available"); | |
| 31 | - | // Fallback navigation if no native bridge is available | |
| 32 | - | if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) { | |
| 33 | - | try { | |
| 34 | - | window.location.href = msg.url; | |
| 35 | - | } catch (e) { | |
| 36 | - | console.error("Fallback navigation failed:", e); | |
| 14 | + | console.log("📤 [DEBUG] Posting message to backend via bridge:", msg); | |
| 15 | + | try { | |
| 16 | + | // Dispatch CustomEvent that main-world bridge will listen to | |
| 17 | + | document.dispatchEvent(new CustomEvent("dumber:isolated-message", { | |
| 18 | + | detail: { payload: msg } | |
| 19 | + | })); | |
| 20 | + | console.log("✅ [DEBUG] Dispatched isolated message event"); | |
| 21 | + | } catch (e) { | |
| 22 | + | console.error("❌ [DEBUG] Failed to dispatch message:", e); | |
| 23 | + | // Fallback navigation if dispatch fails | |
| 24 | + | if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) { | |
| 25 | + | try { | |
| 26 | + | window.location.href = msg.url; | |
| 27 | + | } catch (navError) { | |
| 28 | + | console.error("Fallback navigation failed:", navError); | |
| 29 | + | } | |
| 37 | 30 | } | |
| 38 | 31 | } | |
| 39 | 32 | } | |
| @@ -125,6 +118,63 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||
| 125 | 118 | }); | |
| 126 | 119 | } | |
| 127 | 120 | ||
| 121 | + | /** | |
| 122 | + | * Update favorites from Go backend | |
| 123 | + | */ | |
| 124 | + | setFavorites(favorites: Favorite[]): void { | |
| 125 | + | console.log("📝 [DEBUG] Received favorites from backend:", favorites); | |
| 126 | + | omniboxStore.updateFavorites(favorites); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /** | |
| 130 | + | * Fetch favorites from backend via messaging bridge | |
| 131 | + | */ | |
| 132 | + | async fetchFavorites(): Promise<void> { | |
| 133 | + | return new Promise((resolve, reject) => { | |
| 134 | + | // Set up one-time event listener for favorites response | |
| 135 | + | const handleFavorites = ((event: CustomEvent) => { | |
| 136 | + | try { | |
| 137 | + | const { favorites } = event.detail; | |
| 138 | + | if (!Array.isArray(favorites)) { | |
| 139 | + | reject(new Error("Invalid favorites data")); | |
| 140 | + | return; | |
| 141 | + | } | |
| 142 | + | console.log("📝 [DEBUG] Received favorites via CustomEvent:", favorites); | |
| 143 | + | this.setFavorites(favorites as Favorite[]); | |
| 144 | + | ||
| 145 | + | // Clean up event listener | |
| 146 | + | document.removeEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 147 | + | ||
| 148 | + | resolve(); | |
| 149 | + | } catch (error) { | |
| 150 | + | console.error("Failed to process favorites:", error); | |
| 151 | + | document.removeEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 152 | + | reject(error); | |
| 153 | + | } | |
| 154 | + | }) as EventListener; | |
| 155 | + | ||
| 156 | + | // Listen for favorites event from main world bridge | |
| 157 | + | document.addEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 158 | + | ||
| 159 | + | // Send message to Go backend via CustomEvent bridge (like other omnibox messages) | |
| 160 | + | this.postMessage({ type: "get_favorites" }); | |
| 161 | + | ||
| 162 | + | // Timeout after 5 seconds | |
| 163 | + | setTimeout(() => { | |
| 164 | + | document.removeEventListener("dumber:favorites", handleFavorites as EventListener); | |
| 165 | + | reject(new Error("Favorites fetch timeout")); | |
| 166 | + | }, 5000); | |
| 167 | + | }); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** | |
| 171 | + | * Toggle favorite status for a URL | |
| 172 | + | */ | |
| 173 | + | toggleFavorite(url: string, title: string, faviconURL: string): void { | |
| 174 | + | console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL }); | |
| 175 | + | this.postMessage({ type: "toggle_favorite", url, title, faviconURL }); | |
| 176 | + | } | |
| 177 | + | ||
| 128 | 178 | // Suggestions are returned via setSuggestions() when native handler responds | |
| 129 | 179 | } | |
| 130 | 180 | ||
M gui/src/components/omnibox/stores.svelte.ts +61 -7
| @@ -6,7 +6,9 @@ | |||
| 6 | 6 | ||
| 7 | 7 | import type { | |
| 8 | 8 | OmniboxMode, | |
| 9 | + | ViewMode, | |
| 9 | 10 | Suggestion, | |
| 11 | + | Favorite, | |
| 10 | 12 | FindMatch, | |
| 11 | 13 | HighlightNode, | |
| 12 | 14 | OmniboxConfig, | |
| @@ -20,10 +22,25 @@ const DEFAULT_CONFIG: Required<OmniboxConfig> = { | |||
| 20 | 22 | defaultLimit: 10, | |
| 21 | 23 | }; | |
| 22 | 24 | ||
| 25 | + | // Load viewMode from localStorage or default to "history" | |
| 26 | + | function loadViewMode(): ViewMode { | |
| 27 | + | try { | |
| 28 | + | const stored = localStorage.getItem("dumber_omnibox_viewMode"); | |
| 29 | + | if (stored === "favorites" || stored === "history") { | |
| 30 | + | return stored; | |
| 31 | + | } | |
| 32 | + | } catch (e) { | |
| 33 | + | console.warn("Failed to load viewMode from localStorage:", e); | |
| 34 | + | } | |
| 35 | + | return "history"; | |
| 36 | + | } | |
| 37 | + | ||
| 23 | 38 | // Global state using Svelte 5 runes | |
| 24 | 39 | let visible = $state(false); | |
| 25 | 40 | let mode = $state<OmniboxMode>("omnibox"); | |
| 41 | + | let viewMode = $state<ViewMode>(loadViewMode()); | |
| 26 | 42 | let suggestions = $state<Suggestion[]>([]); | |
| 43 | + | let favorites = $state<Favorite[]>([]); | |
| 27 | 44 | let matches = $state<FindMatch[]>([]); | |
| 28 | 45 | let selectedIndex = $state(-1); | |
| 29 | 46 | let activeIndex = $state(-1); | |
| @@ -45,9 +62,15 @@ export const omniboxStore = { | |||
| 45 | 62 | get mode() { | |
| 46 | 63 | return mode; | |
| 47 | 64 | }, | |
| 65 | + | get viewMode() { | |
| 66 | + | return viewMode; | |
| 67 | + | }, | |
| 48 | 68 | get suggestions() { | |
| 49 | 69 | return suggestions; | |
| 50 | 70 | }, | |
| 71 | + | get favorites() { | |
| 72 | + | return favorites; | |
| 73 | + | }, | |
| 51 | 74 | get matches() { | |
| 52 | 75 | return matches; | |
| 53 | 76 | }, | |
| @@ -78,19 +101,28 @@ export const omniboxStore = { | |||
| 78 | 101 | ||
| 79 | 102 | // Computed getters | |
| 80 | 103 | get hasContent() { | |
| 81 | - | return ( | |
| 82 | - | (mode === "omnibox" && suggestions.length > 0) || | |
| 83 | - | (mode === "find" && matches.length > 0) | |
| 84 | - | ); | |
| 104 | + | if (mode === "find") { | |
| 105 | + | return matches.length > 0; | |
| 106 | + | } | |
| 107 | + | // In omnibox mode, check the current view | |
| 108 | + | return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0; | |
| 85 | 109 | }, | |
| 86 | 110 | get totalItems() { | |
| 87 | - | return mode === "omnibox" ? suggestions.length : matches.length; | |
| 111 | + | if (mode === "find") { | |
| 112 | + | return matches.length; | |
| 113 | + | } | |
| 114 | + | // In omnibox mode, return count based on current view | |
| 115 | + | return viewMode === "history" ? suggestions.length : favorites.length; | |
| 88 | 116 | }, | |
| 89 | 117 | get selectedItem() { | |
| 90 | 118 | if (selectedIndex < 0) return null; | |
| 91 | - | return mode === "omnibox" | |
| 119 | + | if (mode === "find") { | |
| 120 | + | return matches[selectedIndex]; | |
| 121 | + | } | |
| 122 | + | // In omnibox mode, return item from current view | |
| 123 | + | return viewMode === "history" | |
| 92 | 124 | ? suggestions[selectedIndex] | |
| 93 | - | : matches[selectedIndex]; | |
| 125 | + | : favorites[selectedIndex]; | |
| 94 | 126 | }, | |
| 95 | 127 | ||
| 96 | 128 | // Actions | |
| @@ -242,10 +274,32 @@ export const omniboxStore = { | |||
| 242 | 274 | searchShortcuts = shortcuts; | |
| 243 | 275 | }, | |
| 244 | 276 | ||
| 277 | + | setViewMode(newViewMode: ViewMode) { | |
| 278 | + | viewMode = newViewMode; | |
| 279 | + | selectedIndex = -1; // Reset selection when switching views | |
| 280 | + | ||
| 281 | + | // Persist to localStorage | |
| 282 | + | try { | |
| 283 | + | localStorage.setItem("dumber_omnibox_viewMode", newViewMode); | |
| 284 | + | } catch (e) { | |
| 285 | + | console.warn("Failed to save viewMode to localStorage:", e); | |
| 286 | + | } | |
| 287 | + | }, | |
| 288 | + | ||
| 289 | + | updateFavorites(newFavorites: Favorite[]) { | |
| 290 | + | favorites = Array.isArray(newFavorites) ? newFavorites : []; | |
| 291 | + | // Reset selection if we're in favorites view | |
| 292 | + | if (viewMode === "favorites") { | |
| 293 | + | selectedIndex = -1; | |
| 294 | + | } | |
| 295 | + | }, | |
| 296 | + | ||
| 245 | 297 | reset() { | |
| 246 | 298 | visible = false; | |
| 247 | 299 | mode = "omnibox"; | |
| 300 | + | // Keep viewMode persistent - it's user preference | |
| 248 | 301 | suggestions = []; | |
| 302 | + | favorites = []; | |
| 249 | 303 | matches = []; | |
| 250 | 304 | selectedIndex = -1; | |
| 251 | 305 | activeIndex = -1; | |
M gui/src/components/omnibox/types.ts +14 -1
| @@ -9,16 +9,26 @@ export interface Suggestion { | |||
| 9 | 9 | favicon?: string; | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | + | export interface Favorite { | |
| 13 | + | id: number; | |
| 14 | + | url: string; | |
| 15 | + | title: string; | |
| 16 | + | favicon_url: string; | |
| 17 | + | position: number; | |
| 18 | + | } | |
| 19 | + | ||
| 12 | 20 | export interface SearchShortcut { | |
| 13 | 21 | url: string; | |
| 14 | 22 | description: string; | |
| 15 | 23 | } | |
| 16 | 24 | ||
| 17 | 25 | export interface OmniboxMessage { | |
| 18 | - | type: "navigate" | "query" | "get_search_shortcuts"; | |
| 26 | + | type: "navigate" | "query" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; | |
| 19 | 27 | url?: string; | |
| 20 | 28 | q?: string; | |
| 21 | 29 | limit?: number; | |
| 30 | + | title?: string; | |
| 31 | + | faviconURL?: string; | |
| 22 | 32 | } | |
| 23 | 33 | ||
| 24 | 34 | export interface FindMatch { | |
| @@ -32,6 +42,7 @@ export interface HighlightNode { | |||
| 32 | 42 | } | |
| 33 | 43 | ||
| 34 | 44 | export type OmniboxMode = "omnibox" | "find"; | |
| 45 | + | export type ViewMode = "history" | "favorites"; | |
| 35 | 46 | ||
| 36 | 47 | export interface OmniboxConfig { | |
| 37 | 48 | maxMatches?: number; | |
| @@ -42,7 +53,9 @@ export interface OmniboxConfig { | |||
| 42 | 53 | export interface OmniboxState { | |
| 43 | 54 | visible: boolean; | |
| 44 | 55 | mode: OmniboxMode; | |
| 56 | + | viewMode: ViewMode; | |
| 45 | 57 | suggestions: Suggestion[]; | |
| 58 | + | favorites: Favorite[]; | |
| 46 | 59 | matches: FindMatch[]; | |
| 47 | 60 | selectedIndex: number; | |
| 48 | 61 | activeIndex: number; | |
M gui/src/components/toast/ToastContainer.svelte +17 -13
| @@ -84,19 +84,23 @@ | |||
| 84 | 84 | }, 1500); | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | // Expose API to global window for Go integration immediately | |
| 88 | - | // This must happen outside onMount to ensure functions are available immediately | |
| 89 | - | if (typeof window !== 'undefined') { | |
| 90 | - | window.__dumber_showToast = addToast; | |
| 91 | - | window.__dumber_dismissToast = dismissToast; | |
| 92 | - | window.__dumber_clearToasts = clearAllToasts; | |
| 93 | - | window.__dumber_showZoomToast = showZoomToast; | |
| 94 | - | console.log('✅ Toast functions exposed to window:', { | |
| 95 | - | showToast: typeof window.__dumber_showToast, | |
| 96 | - | dismissToast: typeof window.__dumber_dismissToast, | |
| 97 | - | clearToasts: typeof window.__dumber_clearToasts, | |
| 98 | - | showZoomToast: typeof window.__dumber_showZoomToast | |
| 99 | - | }); | |
| 87 | + | // Listen for toast events from main world bridge | |
| 88 | + | if (typeof document !== 'undefined') { | |
| 89 | + | // Listen for general toast requests | |
| 90 | + | document.addEventListener('dumber:toast', ((event: CustomEvent) => { | |
| 91 | + | const { message, duration, type } = event.detail; | |
| 92 | + | console.log('[ToastContainer] Received toast event:', { message, duration, type }); | |
| 93 | + | addToast(message, duration, type); | |
| 94 | + | }) as EventListener); | |
| 95 | + | ||
| 96 | + | // Listen for zoom toast requests | |
| 97 | + | document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => { | |
| 98 | + | const { level } = event.detail; | |
| 99 | + | console.log('[ToastContainer] Received zoom toast event:', level); | |
| 100 | + | showZoomToast(level); | |
| 101 | + | }) as EventListener); | |
| 102 | + | ||
| 103 | + | console.log('✅ Toast event listeners set up for CustomEvents'); | |
| 100 | 104 | } | |
| 101 | 105 | ||
| 102 | 106 | // Effect that runs when toasts array changes | |
M gui/src/injected/bootstrap.ts +7 -15
| @@ -181,22 +181,14 @@ export function bootstrapGUI(): void { | |||
| 181 | 181 | console.log("[GUI] DOM ready, starting initialization"); | |
| 182 | 182 | let toastInitialized = false; | |
| 183 | 183 | ||
| 184 | - | if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") { | |
| 185 | - | console.log("[GUI] Toast already initialized, skipping"); | |
| 184 | + | try { | |
| 185 | + | console.log("[GUI] Attempting toast initialization"); | |
| 186 | + | await initializeToast(); | |
| 186 | 187 | toastInitialized = true; | |
| 187 | - | } else { | |
| 188 | - | try { | |
| 189 | - | console.log("[GUI] Attempting toast initialization"); | |
| 190 | - | await initializeToast(); | |
| 191 | - | if (typeof window.__dumber_showToast !== "function") { | |
| 192 | - | throw new Error("Toast functions not exposed after initialization"); | |
| 193 | - | } | |
| 194 | - | toastInitialized = true; | |
| 195 | - | console.log("[GUI] Toast initialized successfully"); | |
| 196 | - | } catch (e) { | |
| 197 | - | console.error("❌ Failed to initialize Svelte toast system:", e); | |
| 198 | - | throw e; | |
| 199 | - | } | |
| 188 | + | console.log("[GUI] Toast initialized successfully"); | |
| 189 | + | } catch (e) { | |
| 190 | + | console.error("❌ Failed to initialize Svelte toast system:", e); | |
| 191 | + | throw e; | |
| 200 | 192 | } | |
| 201 | 193 | ||
| 202 | 194 | try { | |
M gui/src/injected/main-world.ts +104 -21
| @@ -32,6 +32,7 @@ declare global { | |||
| 32 | 32 | ) => number | void; | |
| 33 | 33 | __dumber_showZoomToast?: (level: number) => void; | |
| 34 | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | |
| 35 | + | __dumber_favorites?: (favorites: unknown[]) => void; | |
| 35 | 36 | __dumber_webview_id?: string | number; | |
| 36 | 37 | __dumber_is_active?: boolean; | |
| 37 | 38 | __dumber_teardown?: () => void; | |
| @@ -292,46 +293,104 @@ interface DumberAPI { | |||
| 292 | 293 | ); | |
| 293 | 294 | } | |
| 294 | 295 | ||
| 295 | - | // Omnibox suggestions bridge | |
| 296 | - | const omniboxQueue: Suggestion[] = []; | |
| 297 | - | let omniboxReady = false; | |
| 298 | - | ||
| 299 | - | const omniboxDispatch = (suggestions: Suggestion[]) => { | |
| 296 | + | /** | |
| 297 | + | * ============================================================================ | |
| 298 | + | * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM | |
| 299 | + | * ============================================================================ | |
| 300 | + | * | |
| 301 | + | * Architecture Overview: | |
| 302 | + | * ---------------------- | |
| 303 | + | * WebKit runs scripts in different "worlds" for security: | |
| 304 | + | * | |
| 305 | + | * - MAIN WORLD: Where page scripts run | |
| 306 | + | * - Can access window.webkit.messageHandlers (backend communication) | |
| 307 | + | * - Can be accessed/modified by page JavaScript | |
| 308 | + | * - Security risk: page can interfere with our code | |
| 309 | + | * | |
| 310 | + | * - ISOLATED WORLD: Where our UI components run (Svelte) | |
| 311 | + | * - Cannot access window.webkit.messageHandlers directly | |
| 312 | + | * - Protected from page JavaScript interference | |
| 313 | + | * - Security benefit: page cannot read/modify our UI state | |
| 314 | + | * | |
| 315 | + | * Communication Patterns: | |
| 316 | + | * ----------------------- | |
| 317 | + | * | |
| 318 | + | * 1. WINDOW FUNCTIONS (require bridge): | |
| 319 | + | * Backend → window.__dumber_xxx() in main world | |
| 320 | + | * Main world → CustomEvent to isolated world | |
| 321 | + | * Example: __dumber_omnibox_suggestions(), __dumber_favorites() | |
| 322 | + | * | |
| 323 | + | * 2. CUSTOMEVENT DISPATCH (no bridge needed): | |
| 324 | + | * Backend → document.dispatchEvent() directly | |
| 325 | + | * Both worlds can listen (shared DOM) | |
| 326 | + | * Example: dumber:ui:shortcut (keyboard shortcuts) | |
| 327 | + | * | |
| 328 | + | * 3. ISOLATED TO BACKEND (reverse bridge): | |
| 329 | + | * Isolated world → CustomEvent "dumber:isolated-message" | |
| 330 | + | * Main world listener → window.webkit.messageHandlers.dumber | |
| 331 | + | * Example: navigation, omnibox queries | |
| 332 | + | * | |
| 333 | + | * ============================================================================ | |
| 334 | + | */ | |
| 335 | + | ||
| 336 | + | /** | |
| 337 | + | * Unified dispatcher for forwarding main-world window calls to isolated world | |
| 338 | + | * Only use this for window function bridges, NOT for events backend already dispatches | |
| 339 | + | */ | |
| 340 | + | const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => { | |
| 300 | 341 | try { | |
| 301 | - | document.dispatchEvent( | |
| 302 | - | new CustomEvent("dumber:omnibox:suggestions", { | |
| 303 | - | detail: { suggestions }, | |
| 304 | - | }), | |
| 305 | - | ); | |
| 306 | - | // Legacy compatibility | |
| 307 | - | document.dispatchEvent( | |
| 308 | - | new CustomEvent("dumber:omnibox-suggestions", { | |
| 309 | - | detail: { suggestions }, | |
| 310 | - | }), | |
| 311 | - | ); | |
| 342 | + | document.dispatchEvent(new CustomEvent(eventName, { detail })); | |
| 312 | 343 | } catch (err) { | |
| 313 | - | console.error("[dumber] Omnibox dispatch error", err); | |
| 344 | + | console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err); | |
| 314 | 345 | } | |
| 315 | 346 | }; | |
| 316 | 347 | ||
| 348 | + | // ============================================================================ | |
| 349 | + | // WINDOW FUNCTION BRIDGES | |
| 350 | + | // These convert window.__dumber_xxx() calls to CustomEvents for isolated world | |
| 351 | + | // ============================================================================ | |
| 352 | + | ||
| 353 | + | // Omnibox suggestions bridge (queues suggestions until component is ready) | |
| 354 | + | const omniboxQueue: Suggestion[] = []; | |
| 355 | + | let omniboxReady = false; | |
| 356 | + | ||
| 317 | 357 | window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => { | |
| 318 | 358 | if (omniboxReady) { | |
| 319 | - | omniboxDispatch(suggestions); | |
| 359 | + | bridgeDispatch("dumber:omnibox:suggestions", { suggestions }); | |
| 360 | + | bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support | |
| 320 | 361 | } else { | |
| 321 | 362 | try { | |
| 322 | 363 | omniboxQueue.push(...suggestions); | |
| 323 | 364 | } catch (err) { | |
| 324 | - | console.error("[dumber] Omnibox queue error", err); | |
| 365 | + | console.error("[dumber-bridge] Omnibox queue error", err); | |
| 325 | 366 | } | |
| 326 | 367 | } | |
| 327 | 368 | }; | |
| 328 | 369 | ||
| 370 | + | // Favorites bridge | |
| 371 | + | window.__dumber_favorites = (favorites: unknown[]) => { | |
| 372 | + | bridgeDispatch("dumber:favorites", { favorites }); | |
| 373 | + | }; | |
| 374 | + | ||
| 375 | + | // Toast notification bridges | |
| 376 | + | window.__dumber_showToast = (message: string, duration?: number, type?: "info" | "success" | "error") => { | |
| 377 | + | bridgeDispatch("dumber:toast", { message, duration, type }); | |
| 378 | + | }; | |
| 379 | + | ||
| 380 | + | window.__dumber_showZoomToast = (level: number) => { | |
| 381 | + | bridgeDispatch("dumber:toast:zoom", { level }); | |
| 382 | + | }; | |
| 383 | + | ||
| 384 | + | // Handle omnibox ready signal and flush queued suggestions | |
| 329 | 385 | const handleOmniboxReady = () => { | |
| 330 | 386 | omniboxReady = true; | |
| 331 | 387 | if (omniboxQueue && omniboxQueue.length) { | |
| 332 | 388 | const items = omniboxQueue.slice(); | |
| 333 | 389 | omniboxQueue.length = 0; | |
| 334 | - | items.forEach((s) => omniboxDispatch([s])); | |
| 390 | + | items.forEach((s) => { | |
| 391 | + | bridgeDispatch("dumber:omnibox:suggestions", { suggestions: [s] }); | |
| 392 | + | bridgeDispatch("dumber:omnibox-suggestions", { suggestions: [s] }); | |
| 393 | + | }); | |
| 335 | 394 | } | |
| 336 | 395 | }; | |
| 337 | 396 | ||
| @@ -340,7 +399,7 @@ interface DumberAPI { | |||
| 340 | 399 | document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady); | |
| 341 | 400 | }); | |
| 342 | 401 | ||
| 343 | - | // Unified omnibox API | |
| 402 | + | // Unified omnibox API for backward compatibility | |
| 344 | 403 | window.__dumber.omnibox = window.__dumber.omnibox || { | |
| 345 | 404 | suggestions: (suggestions: Suggestion[]) => { | |
| 346 | 405 | window.__dumber_omnibox_suggestions!(suggestions); | |
| @@ -824,6 +883,7 @@ interface DumberAPI { | |||
| 824 | 883 | window.__dumber_page_bridge_installed = false; | |
| 825 | 884 | window.__dumber_teardown = undefined; | |
| 826 | 885 | window.__dumber_omnibox_suggestions = undefined; | |
| 886 | + | window.__dumber_favorites = undefined; | |
| 827 | 887 | window.__dumber_showToast = undefined; | |
| 828 | 888 | window.__dumber_showZoomToast = undefined; | |
| 829 | 889 | window.__dumber_applyDomZoom = undefined; | |
| @@ -909,6 +969,29 @@ interface DumberAPI { | |||
| 909 | 969 | }); | |
| 910 | 970 | }; | |
| 911 | 971 | ||
| 972 | + | // Bridge for isolated world communication | |
| 973 | + | // Listen for messages from isolated world and forward to backend via webkit.messageHandlers | |
| 974 | + | const setupIsolatedWorldBridge = () => { | |
| 975 | + | document.addEventListener("dumber:isolated-message", ((event: CustomEvent) => { | |
| 976 | + | try { | |
| 977 | + | const { payload } = event.detail; | |
| 978 | + | if (window.webkit?.messageHandlers?.dumber) { | |
| 979 | + | window.webkit.messageHandlers.dumber.postMessage(JSON.stringify(payload)); | |
| 980 | + | console.log("[dumber-bridge] Forwarded isolated world message:", payload.type); | |
| 981 | + | } else { | |
| 982 | + | console.warn("[dumber-bridge] webkit message handler not available"); | |
| 983 | + | } | |
| 984 | + | } catch (error) { | |
| 985 | + | console.error("[dumber-bridge] Failed to forward message:", error); | |
| 986 | + | } | |
| 987 | + | }) as EventListener); | |
| 988 | + | ||
| 989 | + | console.log("[dumber-bridge] Isolated world message bridge initialized"); | |
| 990 | + | }; | |
| 991 | + | ||
| 992 | + | // Initialize isolated world bridge | |
| 993 | + | setupIsolatedWorldBridge(); | |
| 994 | + | ||
| 912 | 995 | // Initialize console capture | |
| 913 | 996 | setupConsoleCapture(); | |
| 914 | 997 | } catch (err) { | |
D gui/src/injected/modules/appearance-config.ts +0 -133
| @@ -1,133 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Appearance Configuration Module | |
| 3 | - | * | |
| 4 | - | * Handles application appearance configuration including color palettes and fonts. | |
| 5 | - | * This config is injected by the native layer at startup and made available to all | |
| 6 | - | * injected modules and components. | |
| 7 | - | */ | |
| 8 | - | ||
| 9 | - | export interface ColorPalette { | |
| 10 | - | background: string; | |
| 11 | - | surface: string; | |
| 12 | - | surfaceVariant: string; | |
| 13 | - | text: string; | |
| 14 | - | muted: string; | |
| 15 | - | accent: string; | |
| 16 | - | border: string; | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | export interface AppearanceConfig { | |
| 20 | - | theme: "light" | "dark"; | |
| 21 | - | palettes: { | |
| 22 | - | light: ColorPalette; | |
| 23 | - | dark: ColorPalette; | |
| 24 | - | }; | |
| 25 | - | fonts: { | |
| 26 | - | sans: string; | |
| 27 | - | serif: string; | |
| 28 | - | monospace: string; | |
| 29 | - | defaultSize: number; | |
| 30 | - | }; | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | declare global { | |
| 34 | - | interface Window { | |
| 35 | - | __dumber_appearance_config?: AppearanceConfig; | |
| 36 | - | __dumber_applyPalette?: (theme: "light" | "dark") => void; | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | /** | |
| 41 | - | * Applies the appearance configuration to the document | |
| 42 | - | * Sets CSS custom properties for colors and fonts | |
| 43 | - | */ | |
| 44 | - | export function applyAppearanceConfig(): void { | |
| 45 | - | const config = window.__dumber_appearance_config; | |
| 46 | - | if (!config) { | |
| 47 | - | console.warn("[dumber] Appearance config not available"); | |
| 48 | - | return; | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | const theme = config.theme || "light"; | |
| 52 | - | const palette = config.palettes[theme]; | |
| 53 | - | const root = document.documentElement; | |
| 54 | - | ||
| 55 | - | // Apply color palette as CSS custom properties | |
| 56 | - | root.style.setProperty("--color-background", palette.background); | |
| 57 | - | root.style.setProperty("--color-surface", palette.surface); | |
| 58 | - | root.style.setProperty("--color-surface-variant", palette.surfaceVariant); | |
| 59 | - | root.style.setProperty("--color-text", palette.text); | |
| 60 | - | root.style.setProperty("--color-muted", palette.muted); | |
| 61 | - | root.style.setProperty("--color-accent", palette.accent); | |
| 62 | - | root.style.setProperty("--color-border", palette.border); | |
| 63 | - | ||
| 64 | - | // Apply font families | |
| 65 | - | root.style.setProperty("--font-sans", config.fonts.sans); | |
| 66 | - | root.style.setProperty("--font-serif", config.fonts.serif); | |
| 67 | - | root.style.setProperty("--font-mono", config.fonts.monospace); | |
| 68 | - | ||
| 69 | - | console.log(`[dumber] Applied ${theme} appearance config`); | |
| 70 | - | ||
| 71 | - | // Dispatch event for components that need to react to appearance changes | |
| 72 | - | document.dispatchEvent( | |
| 73 | - | new CustomEvent("dumber:appearance-applied", { | |
| 74 | - | detail: { theme, palette, fonts: config.fonts }, | |
| 75 | - | }), | |
| 76 | - | ); | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | /** | |
| 80 | - | * Gets the current theme from appearance config | |
| 81 | - | */ | |
| 82 | - | export function getCurrentTheme(): "light" | "dark" { | |
| 83 | - | return window.__dumber_appearance_config?.theme || "light"; | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | /** | |
| 87 | - | * Gets the current color palette for the active theme | |
| 88 | - | */ | |
| 89 | - | export function getCurrentPalette(): ColorPalette | null { | |
| 90 | - | const config = window.__dumber_appearance_config; | |
| 91 | - | if (!config) return null; | |
| 92 | - | ||
| 93 | - | return config.palettes[config.theme]; | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | /** | |
| 97 | - | * Expose palette application function globally for integration with color-scheme module | |
| 98 | - | */ | |
| 99 | - | window.__dumber_applyPalette = (theme: "light" | "dark") => { | |
| 100 | - | const config = window.__dumber_appearance_config; | |
| 101 | - | if (!config) return; | |
| 102 | - | ||
| 103 | - | const palette = config.palettes[theme]; | |
| 104 | - | const root = document.documentElement; | |
| 105 | - | ||
| 106 | - | root.style.setProperty("--color-background", palette.background); | |
| 107 | - | root.style.setProperty("--color-surface", palette.surface); | |
| 108 | - | root.style.setProperty("--color-surface-variant", palette.surfaceVariant); | |
| 109 | - | root.style.setProperty("--color-text", palette.text); | |
| 110 | - | root.style.setProperty("--color-muted", palette.muted); | |
| 111 | - | root.style.setProperty("--color-accent", palette.accent); | |
| 112 | - | root.style.setProperty("--color-border", palette.border); | |
| 113 | - | ||
| 114 | - | console.log(`[dumber] Switched to ${theme} palette`); | |
| 115 | - | }; | |
| 116 | - | ||
| 117 | - | // Auto-initialize if config is already available | |
| 118 | - | if (window.__dumber_appearance_config) { | |
| 119 | - | applyAppearanceConfig(); | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // Listen for config injection and apply | |
| 123 | - | document.addEventListener("DOMContentLoaded", () => { | |
| 124 | - | if (window.__dumber_appearance_config) { | |
| 125 | - | applyAppearanceConfig(); | |
| 126 | - | } | |
| 127 | - | }); | |
| 128 | - | ||
| 129 | - | export default { | |
| 130 | - | applyAppearanceConfig, | |
| 131 | - | getCurrentTheme, | |
| 132 | - | getCurrentPalette, | |
| 133 | - | }; | |
D gui/src/injected/modules/controls.ts +0 -106
| @@ -1,106 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Browser Controls Module | |
| 3 | - | * | |
| 4 | - | * Provides global keyboard and mouse controls that work on all domains. | |
| 5 | - | * Only includes essential controls that can work cross-origin: | |
| 6 | - | * - Navigation (back/forward) | |
| 7 | - | * - Mouse navigation buttons | |
| 8 | - | * | |
| 9 | - | * Zoom controls are handled natively by the GTK backend | |
| 10 | - | */ | |
| 11 | - | ||
| 12 | - | export interface ControlsConfig { | |
| 13 | - | // Future configuration options can be added here | |
| 14 | - | _placeholder?: never; // Prevent empty interface warning | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | let controlsInitialized = false; | |
| 18 | - | ||
| 19 | - | export function initializeControls(_config?: ControlsConfig): void { | |
| 20 | - | if (controlsInitialized) { | |
| 21 | - | console.log("🔄 Browser controls already initialized, skipping"); | |
| 22 | - | return; | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | console.log("🚀 Initializing browser controls on:", window.location.href); | |
| 26 | - | ||
| 27 | - | // Keyboard event handler for global controls | |
| 28 | - | function handleKeyboardEvent(event: KeyboardEvent): void { | |
| 29 | - | const { altKey, key } = event; | |
| 30 | - | // Note: ctrlKey, metaKey reserved for future use | |
| 31 | - | ||
| 32 | - | try { | |
| 33 | - | // Navigation: Alt + Left Arrow (Back) | |
| 34 | - | if (altKey && key === "ArrowLeft") { | |
| 35 | - | event.preventDefault(); | |
| 36 | - | event.stopPropagation(); | |
| 37 | - | ||
| 38 | - | if (window.history.length > 1) { | |
| 39 | - | console.log("⬅️ Navigating back"); | |
| 40 | - | window.history.back(); | |
| 41 | - | } | |
| 42 | - | return; | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Navigation: Alt + Right Arrow (Forward) | |
| 46 | - | if (altKey && key === "ArrowRight") { | |
| 47 | - | event.preventDefault(); | |
| 48 | - | event.stopPropagation(); | |
| 49 | - | ||
| 50 | - | console.log("➡️ Navigating forward"); | |
| 51 | - | window.history.forward(); | |
| 52 | - | return; | |
| 53 | - | } | |
| 54 | - | } catch (error) { | |
| 55 | - | console.error("❌ Error in keyboard handler:", error); | |
| 56 | - | } | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // Mouse event handler for navigation buttons | |
| 60 | - | function handleMouseEvent(event: MouseEvent): void { | |
| 61 | - | try { | |
| 62 | - | // Mouse button 3 (back button) | |
| 63 | - | if (event.button === 3) { | |
| 64 | - | event.preventDefault(); | |
| 65 | - | event.stopPropagation(); | |
| 66 | - | ||
| 67 | - | if (window.history.length > 1) { | |
| 68 | - | console.log("⬅️ Mouse back button pressed"); | |
| 69 | - | window.history.back(); | |
| 70 | - | } | |
| 71 | - | return; | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // Mouse button 4 (forward button) | |
| 75 | - | if (event.button === 4) { | |
| 76 | - | event.preventDefault(); | |
| 77 | - | event.stopPropagation(); | |
| 78 | - | ||
| 79 | - | console.log("➡️ Mouse forward button pressed"); | |
| 80 | - | window.history.forward(); | |
| 81 | - | return; | |
| 82 | - | } | |
| 83 | - | } catch (error) { | |
| 84 | - | console.error("❌ Error in mouse handler:", error); | |
| 85 | - | } | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | // Add event listeners with high priority (capture phase) | |
| 89 | - | try { | |
| 90 | - | document.addEventListener("keydown", handleKeyboardEvent, true); | |
| 91 | - | document.addEventListener("mousedown", handleMouseEvent, true); | |
| 92 | - | ||
| 93 | - | controlsInitialized = true; | |
| 94 | - | console.log("✅ Browser controls initialized"); | |
| 95 | - | ||
| 96 | - | // Cleanup function for when page is unloaded | |
| 97 | - | window.addEventListener("beforeunload", () => { | |
| 98 | - | document.removeEventListener("keydown", handleKeyboardEvent, true); | |
| 99 | - | document.removeEventListener("mousedown", handleMouseEvent, true); | |
| 100 | - | controlsInitialized = false; | |
| 101 | - | console.log("🧹 Browser controls cleaned up"); | |
| 102 | - | }); | |
| 103 | - | } catch (error) { | |
| 104 | - | console.error("❌ Failed to initialize browser controls:", error); | |
| 105 | - | } | |
| 106 | - | } | |
M gui/src/injected/modules/shadowHost.ts +39 -23
| @@ -182,33 +182,49 @@ export function getGlobalShadowRoot(): ShadowRoot { | |||
| 182 | 182 | `; | |
| 183 | 183 | shadowRoot.appendChild(tokensStyle); | |
| 184 | 184 | ||
| 185 | - | // Inject the global GUI stylesheet so components rendered inside the shadow | |
| 186 | - | // root (e.g., toasts, omnibox) receive their styles | |
| 185 | + | // Inject the global GUI stylesheet (Tailwind) into shadow root | |
| 187 | 186 | try { | |
| 188 | - | // Prefer constructable stylesheets when available | |
| 189 | - | const hasConstructable = typeof CSSStyleSheet !== "undefined"; | |
| 190 | - | const supportsAdopted = "adoptedStyleSheets" in shadowRoot; | |
| 191 | - | if (supportsAdopted && hasConstructable) { | |
| 192 | - | const sheet = new CSSStyleSheet(); | |
| 193 | - | sheet.replaceSync(globalStyles); | |
| 194 | - | const rootWithSheets = shadowRoot as ShadowRoot & { | |
| 195 | - | adoptedStyleSheets: CSSStyleSheet[]; | |
| 196 | - | }; | |
| 197 | - | rootWithSheets.adoptedStyleSheets = [ | |
| 198 | - | ...(rootWithSheets.adoptedStyleSheets ?? []), | |
| 199 | - | sheet, | |
| 200 | - | ]; | |
| 201 | - | } else { | |
| 202 | - | // Fallback: append a <style> element with the global CSS | |
| 203 | - | const styleTag = document.createElement("style"); | |
| 204 | - | styleTag.textContent = globalStyles; | |
| 205 | - | shadowRoot.appendChild(styleTag); | |
| 206 | - | } | |
| 207 | - | } catch { | |
| 208 | - | // Final fallback if anything above fails | |
| 187 | + | // Use <style> tag injection for Tailwind global styles | |
| 209 | 188 | const styleTag = document.createElement("style"); | |
| 189 | + | styleTag.setAttribute("data-source", "dumber-tailwind-styles"); | |
| 210 | 190 | styleTag.textContent = globalStyles; | |
| 211 | 191 | shadowRoot.appendChild(styleTag); | |
| 192 | + | ||
| 193 | + | // Inject component styles from Go-provided string | |
| 194 | + | // These are Svelte component-scoped styles that must be inside the shadow root | |
| 195 | + | // due to Shadow DOM style encapsulation | |
| 196 | + | const componentStyles = (window as any).__dumber_component_styles; | |
| 197 | + | if (componentStyles) { | |
| 198 | + | const componentStyleTag = document.createElement("style"); | |
| 199 | + | componentStyleTag.setAttribute("data-source", "dumber-component-styles"); | |
| 200 | + | componentStyleTag.textContent = componentStyles; | |
| 201 | + | shadowRoot.appendChild(componentStyleTag); | |
| 202 | + | console.log( | |
| 203 | + | "[shadowHost] Component styles injected:", | |
| 204 | + | componentStyles.length, | |
| 205 | + | "chars", | |
| 206 | + | ); | |
| 207 | + | } else { | |
| 208 | + | console.warn("[shadowHost] Component styles not available on window"); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | console.log( | |
| 212 | + | "[shadowHost] All styles injected, shadow root children:", | |
| 213 | + | shadowRoot.childNodes.length, | |
| 214 | + | ); | |
| 215 | + | } catch (error) { | |
| 216 | + | console.error("[shadowHost] Failed to inject styles:", error); | |
| 217 | + | // Fallback attempt | |
| 218 | + | try { | |
| 219 | + | const fallbackStyle = document.createElement("style"); | |
| 220 | + | fallbackStyle.textContent = globalStyles || ""; | |
| 221 | + | shadowRoot.appendChild(fallbackStyle); | |
| 222 | + | } catch (fallbackError) { | |
| 223 | + | console.error( | |
| 224 | + | "[shadowHost] Fallback style injection also failed:", | |
| 225 | + | fallbackError, | |
| 226 | + | ); | |
| 227 | + | } | |
| 212 | 228 | } | |
| 213 | 229 | shadowResetApplied.add(shadowRoot); | |
| 214 | 230 | } | |
D gui/src/main.ts +0 -73
| @@ -1,73 +0,0 @@ | |||
| 1 | - | import { AppService } from "./services/app.js"; | |
| 2 | - | import { BrowserControlsService } from "./services/browser-controls.js"; | |
| 3 | - | import { DOMRenderer } from "./utils/dom.js"; | |
| 4 | - | ||
| 5 | - | class DumberBrowserApp { | |
| 6 | - | private appService: AppService; | |
| 7 | - | private browserControls: BrowserControlsService; | |
| 8 | - | private domRenderer: DOMRenderer; | |
| 9 | - | ||
| 10 | - | constructor() { | |
| 11 | - | this.appService = new AppService(); | |
| 12 | - | this.browserControls = new BrowserControlsService(); | |
| 13 | - | this.domRenderer = new DOMRenderer(); | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | async initialize(): Promise<void> { | |
| 17 | - | try { | |
| 18 | - | // Show loading states | |
| 19 | - | this.domRenderer.showLoading("history"); | |
| 20 | - | this.domRenderer.showLoading("shortcuts"); | |
| 21 | - | ||
| 22 | - | // Initialize services | |
| 23 | - | await this.appService.initialize(); | |
| 24 | - | ||
| 25 | - | // Initialize browser controls | |
| 26 | - | await this.browserControls.initialize(); | |
| 27 | - | ||
| 28 | - | // Render the UI | |
| 29 | - | this.render(); | |
| 30 | - | ||
| 31 | - | console.log("✓ Dumber Browser initialized successfully"); | |
| 32 | - | } catch (error) { | |
| 33 | - | console.error("Failed to initialize Dumber Browser:", error); | |
| 34 | - | this.handleInitializationError(); | |
| 35 | - | } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | private render(): void { | |
| 39 | - | try { | |
| 40 | - | // Render history | |
| 41 | - | const history = this.appService.getHistory(); | |
| 42 | - | this.domRenderer.displayHistory(history); | |
| 43 | - | ||
| 44 | - | // Render shortcuts | |
| 45 | - | const shortcuts = this.appService.getShortcuts(); | |
| 46 | - | this.domRenderer.displayShortcuts(shortcuts); | |
| 47 | - | } catch (error) { | |
| 48 | - | console.error("Failed to render UI:", error); | |
| 49 | - | this.domRenderer.showError("history", "Failed to load history"); | |
| 50 | - | this.domRenderer.showError("shortcuts", "Failed to load shortcuts"); | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | private handleInitializationError(): void { | |
| 55 | - | this.domRenderer.showError("history", "Failed to initialize application"); | |
| 56 | - | this.domRenderer.showError("shortcuts", "Failed to initialize application"); | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Initialize the application when the DOM is ready | |
| 61 | - | function initializeApp(): void { | |
| 62 | - | const app = new DumberBrowserApp(); | |
| 63 | - | app.initialize().catch((error) => { | |
| 64 | - | console.error("Critical error during app initialization:", error); | |
| 65 | - | }); | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Wait for DOM to be ready | |
| 69 | - | if (document.readyState === "loading") { | |
| 70 | - | document.addEventListener("DOMContentLoaded", initializeApp); | |
| 71 | - | } else { | |
| 72 | - | initializeApp(); | |
| 73 | - | } | |
M gui/src/services/browser-controls.ts +1 -1
| @@ -26,7 +26,7 @@ export class BrowserControlsService { | |||
| 26 | 26 | "info", | |
| 27 | 27 | "frontend", | |
| 28 | 28 | "init", | |
| 29 | - | "BrowserControlsService initialized ✓", | |
| 29 | + | "BrowserControlsService initialized", | |
| 30 | 30 | ); | |
| 31 | 31 | } | |
| 32 | 32 | ||
M gui/vite.config.gui.ts +2 -2
| @@ -5,7 +5,7 @@ import { resolve } from "path"; | |||
| 5 | 5 | export default defineConfig({ | |
| 6 | 6 | plugins: [ | |
| 7 | 7 | svelte({ | |
| 8 | - | emitCss: false, // Inline component CSS into JS bundle | |
| 8 | + | emitCss: true, // Extract component CSS to separate file for isolated world injection | |
| 9 | 9 | }), | |
| 10 | 10 | ], | |
| 11 | 11 | build: { | |
| @@ -18,7 +18,7 @@ export default defineConfig({ | |||
| 18 | 18 | name: "__dumberGUI", | |
| 19 | 19 | inlineDynamicImports: true, | |
| 20 | 20 | manualChunks: undefined, // Prevent chunk splitting | |
| 21 | - | assetFileNames: "[name].[ext]", // Don't hash asset names since they're inlined | |
| 21 | + | assetFileNames: "gui.min.[ext]", // CSS will be named gui.min.css | |
| 22 | 22 | }, | |
| 23 | 23 | }, | |
| 24 | 24 | emptyOutDir: false, | |
M internal/app/browser/browser.go +32 -20
| @@ -48,7 +48,11 @@ type BrowserApp struct { | |||
| 48 | 48 | navigationController *control.NavigationController | |
| 49 | 49 | clipboardController *control.ClipboardController | |
| 50 | 50 | ||
| 51 | - | // Pane workspace management | |
| 51 | + | // Tab and workspace management | |
| 52 | + | tabManager *TabManager | |
| 53 | + | ||
| 54 | + | // Convenience accessors (delegate to active tab's workspace) | |
| 55 | + | // These maintain compatibility with existing code | |
| 52 | 56 | panes []*BrowserPane | |
| 53 | 57 | activePane *BrowserPane | |
| 54 | 58 | workspace *WorkspaceManager | |
| @@ -186,6 +190,15 @@ func (app *BrowserApp) loadCachesParallel(ctx context.Context) error { | |||
| 186 | 190 | return nil | |
| 187 | 191 | }) | |
| 188 | 192 | ||
| 193 | + | // Load favorites cache in parallel | |
| 194 | + | g.Go(func() error { | |
| 195 | + | if err := app.browserService.LoadFavoritesCacheFromDB(ctx); err != nil { | |
| 196 | + | log.Printf("[cache] Failed to load favorites cache: %v", err) | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | return nil | |
| 200 | + | }) | |
| 201 | + | ||
| 189 | 202 | // Load fuzzy search cache in parallel for instant dmenu access | |
| 190 | 203 | g.Go(func() error { | |
| 191 | 204 | if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil { | |
| @@ -239,8 +252,19 @@ func (app *BrowserApp) Run() { | |||
| 239 | 252 | // Continue without content blocking | |
| 240 | 253 | } | |
| 241 | 254 | ||
| 242 | - | // Handle browse command if present | |
| 243 | - | app.navigationController.HandleBrowseCommand() | |
| 255 | + | // Handle browse command if present (must use active tab's navigation controller) | |
| 256 | + | if app.tabManager != nil { | |
| 257 | + | activeTab := app.tabManager.GetActiveTab() | |
| 258 | + | if activeTab != nil && activeTab.workspace != nil { | |
| 259 | + | activePane := activeTab.workspace.GetActivePane() | |
| 260 | + | if activePane != nil && activePane.navigationController != nil { | |
| 261 | + | activePane.navigationController.HandleBrowseCommand() | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | } else { | |
| 265 | + | // Fallback to legacy behavior if no tab manager | |
| 266 | + | app.navigationController.HandleBrowseCommand() | |
| 267 | + | } | |
| 244 | 268 | ||
| 245 | 269 | // Setup signal handling | |
| 246 | 270 | app.setupSignalHandling() | |
| @@ -260,23 +284,11 @@ func (app *BrowserApp) cleanup() { | |||
| 260 | 284 | app.windowShortcutHandler = nil | |
| 261 | 285 | } | |
| 262 | 286 | ||
| 263 | - | // Cleanup all panes | |
| 264 | - | if app.panes != nil { | |
| 265 | - | log.Printf("Cleaning up %d panes", len(app.panes)) | |
| 266 | - | for i, pane := range app.panes { | |
| 267 | - | if pane != nil { | |
| 268 | - | log.Printf("Cleaning up pane %d (%s)", i, pane.ID()) | |
| 269 | - | pane.Cleanup() | |
| 270 | - | } | |
| 271 | - | } | |
| 272 | - | app.panes = nil | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | // Cleanup workspace manager | |
| 276 | - | if app.workspace != nil { | |
| 277 | - | log.Printf("Cleaning up workspace manager") | |
| 278 | - | // WorkspaceManager doesn't have explicit cleanup yet, but we clear the reference | |
| 279 | - | app.workspace = nil | |
| 287 | + | // Cleanup tab manager (which cleans up all tabs and their workspaces) | |
| 288 | + | if app.tabManager != nil { | |
| 289 | + | log.Printf("Cleaning up tab manager") | |
| 290 | + | app.tabManager.Cleanup() | |
| 291 | + | app.tabManager = nil | |
| 280 | 292 | } | |
| 281 | 293 | ||
| 282 | 294 | // Flush all caches to ensure pending writes complete before database closes | |
M internal/app/browser/stacked_panes.go +32 -27
| @@ -7,9 +7,10 @@ import ( | |||
| 7 | 7 | "sync/atomic" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | 10 | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 12 | 11 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | const ( | |
| @@ -306,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 306 | 307 | // detachFromParent removes a pane from its current parent | |
| 307 | 308 | func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) { | |
| 308 | 309 | if parent == nil { | |
| 309 | - | // Target is the root - remove it from window | |
| 310 | - | if spm.wm.window != nil { | |
| 311 | - | spm.wm.window.SetChild(nil) | |
| 312 | - | } | |
| 310 | + | // Target is the root - clear it (handles both tab and non-tab environments) | |
| 311 | + | spm.wm.clearRootContainer() | |
| 313 | 312 | // Unparent if it has a GTK parent | |
| 314 | 313 | if target.container != nil { | |
| 315 | 314 | if webkit.WidgetGetParent(target.container) != nil { | |
| @@ -329,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo | |||
| 329 | 328 | // reattachToParent attaches a container to the parent | |
| 330 | 329 | func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) { | |
| 331 | 330 | if parent == nil { | |
| 332 | - | if spm.wm.window != nil { | |
| 333 | - | spm.wm.window.SetChild(container) | |
| 334 | - | } | |
| 331 | + | // Attach as root (handles both tab and non-tab environments) | |
| 332 | + | spm.wm.setRootContainer(container) | |
| 335 | 333 | } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil { | |
| 336 | 334 | if parent.left == target { | |
| 337 | 335 | paned.SetStartChild(container) | |
| @@ -478,12 +476,14 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 478 | 476 | case DirectionUp: | |
| 479 | 477 | newIndex = currentIndex - 1 | |
| 480 | 478 | if newIndex < 0 { | |
| 481 | - | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | |
| 479 | + | // At the top of the stack - allow navigation outside | |
| 480 | + | return false | |
| 482 | 481 | } | |
| 483 | 482 | case DirectionDown: | |
| 484 | 483 | newIndex = currentIndex + 1 | |
| 485 | 484 | if newIndex >= len(stackNode.stackedPanes) { | |
| 486 | - | newIndex = 0 // Wrap to first | |
| 485 | + | // At the bottom of the stack - allow navigation outside | |
| 486 | + | return false | |
| 487 | 487 | } | |
| 488 | 488 | default: | |
| 489 | 489 | return false | |
| @@ -538,10 +538,9 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter { | |||
| 538 | 538 | spm.titleBarToPane[titleBarID] = pane | |
| 539 | 539 | ||
| 540 | 540 | // Attach click handler | |
| 541 | - | // TODO: Implement click handler with gtk.GestureClick | |
| 542 | - | // webkit.WidgetAttachClickHandler(titleBar, func() { | |
| 543 | - | // spm.handleTitleBarClick(titleBarID) | |
| 544 | - | // }) | |
| 541 | + | webkit.WidgetAttachClickHandler(titleBar, func() { | |
| 542 | + | spm.handleTitleBarClick(titleBarID) | |
| 543 | + | }) | |
| 545 | 544 | } | |
| 546 | 545 | ||
| 547 | 546 | return titleBar | |
| @@ -610,10 +609,9 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 610 | 609 | spm.titleBarToPane[titleBarID] = node | |
| 611 | 610 | ||
| 612 | 611 | // Attach click handler | |
| 613 | - | // TODO: Implement with gtk.GestureClick | |
| 614 | - | // webkit.WidgetAttachClickHandler(newTitleBar, func() { | |
| 615 | - | // spm.handleTitleBarClick(titleBarID) | |
| 616 | - | // }) | |
| 612 | + | webkit.WidgetAttachClickHandler(newTitleBar, func() { | |
| 613 | + | spm.handleTitleBarClick(titleBarID) | |
| 614 | + | }) | |
| 617 | 615 | ||
| 618 | 616 | // Replace the old title bar in the stack | |
| 619 | 617 | if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil { | |
| @@ -678,11 +676,18 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||
| 678 | 676 | } | |
| 679 | 677 | ||
| 680 | 678 | if texture != nil && titleBox != nil { | |
| 681 | - | log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL) | |
| 682 | 679 | ||
| 683 | - | // Create favicon image | |
| 680 | + | // Create favicon image with UI scaling | |
| 684 | 681 | faviconImg := gtk.NewImageFromPaintable(texture) | |
| 685 | - | faviconImg.SetPixelSize(16) | |
| 682 | + | // Scale favicon size based on UI scale (base: 16px) | |
| 683 | + | faviconSize := 16 | |
| 684 | + | if spm.wm != nil && spm.wm.app != nil && spm.wm.app.config != nil { | |
| 685 | + | uiScale := spm.wm.app.config.Workspace.Styling.UIScale | |
| 686 | + | if uiScale > 0 { | |
| 687 | + | faviconSize = int(16 * uiScale) | |
| 688 | + | } | |
| 689 | + | } | |
| 690 | + | faviconImg.SetPixelSize(faviconSize) | |
| 686 | 691 | webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon") | |
| 687 | 692 | ||
| 688 | 693 | // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent | |
| @@ -776,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 776 | 781 | webkit.WidgetUnparent(stackNode.container) | |
| 777 | 782 | } | |
| 778 | 783 | } | |
| 779 | - | if spm.wm.window != nil { | |
| 780 | - | spm.wm.window.SetChild(nil) | |
| 781 | - | } | |
| 784 | + | // Clear root container (handles both tab and non-tab environments) | |
| 785 | + | spm.wm.clearRootContainer() | |
| 782 | 786 | _, err := spm.wm.cleanupAndExit(node) | |
| 783 | 787 | return err | |
| 784 | 788 | } | |
| @@ -802,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 802 | 806 | grandparent := parent.parent | |
| 803 | 807 | ||
| 804 | 808 | spm.wm.promoteSibling(grandparent, parent, sibling) | |
| 805 | - | spm.wm.swapContainers(grandparent, sibling) | |
| 809 | + | spm.wm.swapContainers(grandparent, parent, sibling) | |
| 806 | 810 | ||
| 807 | 811 | generation := spm.wm.nextCleanupGeneration() | |
| 808 | 812 | spm.wm.cleanupPane(node, generation) | |
| @@ -840,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 840 | 844 | } | |
| 841 | 845 | ||
| 842 | 846 | if parent == nil { | |
| 847 | + | // Stack was root - make last pane the new root | |
| 843 | 848 | spm.wm.root = lastPane | |
| 844 | 849 | lastPane.parent = nil | |
| 845 | - | if spm.wm.window != nil && lastPaneContainer != nil { | |
| 846 | - | spm.wm.window.SetChild(lastPaneContainer) | |
| 850 | + | if lastPaneContainer != nil { | |
| 851 | + | spm.wm.setRootContainer(lastPaneContainer) | |
| 847 | 852 | webkit.WidgetQueueAllocate(lastPaneContainer) | |
| 848 | 853 | webkit.WidgetShow(lastPaneContainer) | |
| 849 | 854 | } | |
A internal/app/browser/tab_bar.go +240 -0
| @@ -0,0 +1,240 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync/atomic" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // createTabBar creates the horizontal tab bar container. | |
| 13 | + | // Returns the tab bar widget. | |
| 14 | + | func (tm *TabManager) createTabBar() gtk.Widgetter { | |
| 15 | + | logging.Debug("[tabs] Creating tab bar container") | |
| 16 | + | ||
| 17 | + | tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0) | |
| 18 | + | if tabBar == nil { | |
| 19 | + | logging.Error("[tabs] Failed to create tab bar box") | |
| 20 | + | return nil | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | tabBar.AddCSSClass("tab-bar") | |
| 24 | + | tabBar.SetHExpand(true) | |
| 25 | + | tabBar.SetVExpand(false) | |
| 26 | + | ||
| 27 | + | tm.tabBar = tabBar | |
| 28 | + | logging.Debug("[tabs] Tab bar container created") | |
| 29 | + | return tabBar | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // createTabButton creates a simple GTK button widget for a tab with just a text label. | |
| 33 | + | func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter { | |
| 34 | + | logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id)) | |
| 35 | + | ||
| 36 | + | // Create button | |
| 37 | + | button := gtk.NewButton() | |
| 38 | + | if button == nil { | |
| 39 | + | logging.Error("[tabs] Failed to create tab button") | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // CRITICAL: Disable focus-on-click to prevent GTK focus system from interfering | |
| 44 | + | // Tab buttons should be clickable without grabbing focus from the WebView | |
| 45 | + | button.SetFocusOnClick(false) | |
| 46 | + | button.SetCanFocus(false) | |
| 47 | + | ||
| 48 | + | // Apply CSS classes | |
| 49 | + | button.AddCSSClass("tab-button") | |
| 50 | + | ||
| 51 | + | // Get display title (custom name or default name) | |
| 52 | + | displayTitle := tab.title | |
| 53 | + | if tab.customTitle != "" { | |
| 54 | + | displayTitle = tab.customTitle | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Create title label | |
| 58 | + | titleLabel := gtk.NewLabel(displayTitle) | |
| 59 | + | if titleLabel != nil { | |
| 60 | + | titleLabel.AddCSSClass("tab-title") | |
| 61 | + | titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle | |
| 62 | + | titleLabel.SetMaxWidthChars(20) | |
| 63 | + | webkit.WidgetSetVisible(titleLabel, true) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Set button child | |
| 67 | + | button.SetChild(titleLabel) | |
| 68 | + | ||
| 69 | + | // Store button reference for later updates | |
| 70 | + | tab.titleButton = button | |
| 71 | + | ||
| 72 | + | // Note: Click handler will be attached in createTabInternal after tab is added to slice | |
| 73 | + | ||
| 74 | + | // Set active class if this is the active tab | |
| 75 | + | if tab.isActive { | |
| 76 | + | webkit.WidgetAddCSSClass(button, "tab-button-active") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | webkit.WidgetSetVisible(button, true) | |
| 80 | + | logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id)) | |
| 81 | + | return button | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // attachTabClickHandler attaches a click handler to switch to a tab when clicked. | |
| 85 | + | // Uses the same pattern as StackedPaneManager: maps unique button ID -> tab, | |
| 86 | + | // then looks up current index at click time to handle tab reordering correctly. | |
| 87 | + | func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) { | |
| 88 | + | if button == nil || tab == nil { | |
| 89 | + | logging.Error("[tabs] Cannot attach click handler: nil button or tab") | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | gtkButton, ok := button.(*gtk.Button) | |
| 94 | + | if !ok { | |
| 95 | + | logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: widget is not *gtk.Button for tab %s", tab.id)) | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Generate unique button ID and store mapping (pattern from stacked panes) | |
| 100 | + | buttonID := atomic.AddUint64(&tm.nextButtonID, 1) | |
| 101 | + | tm.buttonToTab[buttonID] = tab | |
| 102 | + | ||
| 103 | + | gtkButton.ConnectClicked(func() { | |
| 104 | + | tm.handleTabButtonClick(buttonID) | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s (buttonID=%d)", tab.id, buttonID)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // handleTabButtonClick handles clicks on tab buttons to switch tabs. | |
| 111 | + | // Looks up the current index dynamically to handle tab reordering (pattern from stacked panes). | |
| 112 | + | func (tm *TabManager) handleTabButtonClick(buttonID uint64) { | |
| 113 | + | tm.mu.Lock() | |
| 114 | + | defer tm.mu.Unlock() | |
| 115 | + | ||
| 116 | + | // Look up tab from button ID | |
| 117 | + | tab, exists := tm.buttonToTab[buttonID] | |
| 118 | + | if !exists || tab == nil { | |
| 119 | + | logging.Warn(fmt.Sprintf("[tabs] Tab button click for unknown ID: %d", buttonID)) | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Find current index of this tab | |
| 124 | + | targetIndex := -1 | |
| 125 | + | for i, t := range tm.tabs { | |
| 126 | + | if t == tab { | |
| 127 | + | targetIndex = i | |
| 128 | + | break | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | if targetIndex < 0 { | |
| 133 | + | logging.Error(fmt.Sprintf("[tabs] Tab %s not found in tabs slice", tab.id)) | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Only switch if not already active | |
| 138 | + | if targetIndex == tm.activeIndex { | |
| 139 | + | logging.Debug(fmt.Sprintf("[tabs] Tab %s already active (index %d)", tab.id, targetIndex)) | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | logging.Info(fmt.Sprintf("[tabs] Tab button clicked: switching from tab %d to %d (%s)", tm.activeIndex, targetIndex, tab.id)) | |
| 144 | + | ||
| 145 | + | // Switch to this tab | |
| 146 | + | if err := tm.switchToTabInternal(targetIndex); err != nil { | |
| 147 | + | logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", targetIndex, err)) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // updateTabButton updates the tab button's title label. | |
| 152 | + | func (tm *TabManager) updateTabButton(tab *Tab) { | |
| 153 | + | if tab.titleButton == nil { | |
| 154 | + | logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id)) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | webkit.RunOnMainThread(func() { | |
| 159 | + | button, ok := tab.titleButton.(*gtk.Button) | |
| 160 | + | if !ok { | |
| 161 | + | logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id)) | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Get button child (the label) | |
| 166 | + | child := button.Child() | |
| 167 | + | if child == nil { | |
| 168 | + | logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id)) | |
| 169 | + | return | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | label, ok := child.(*gtk.Label) | |
| 173 | + | if !ok { | |
| 174 | + | logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id)) | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // Update label text | |
| 179 | + | displayTitle := tab.title | |
| 180 | + | if tab.customTitle != "" { | |
| 181 | + | displayTitle = tab.customTitle | |
| 182 | + | } | |
| 183 | + | label.SetLabel(displayTitle) | |
| 184 | + | logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle)) | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // addTabToBar adds a tab button to the tab bar. | |
| 189 | + | func (tm *TabManager) addTabToBar(tab *Tab) { | |
| 190 | + | if tm.tabBar == nil { | |
| 191 | + | logging.Error("[tabs] Cannot add tab to bar: tab bar is nil") | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | if tab.titleButton == nil { | |
| 196 | + | logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id)) | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | webkit.RunOnMainThread(func() { | |
| 201 | + | if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok { | |
| 202 | + | tabBarBox.Append(tab.titleButton) | |
| 203 | + | logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id)) | |
| 204 | + | } else { | |
| 205 | + | logging.Error("[tabs] Tab bar is not a *gtk.Box") | |
| 206 | + | } | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // removeTabFromBar removes a tab button from the tab bar. | |
| 211 | + | func (tm *TabManager) removeTabFromBar(tab *Tab) { | |
| 212 | + | if tm.tabBar == nil || tab.titleButton == nil { | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | webkit.RunOnMainThread(func() { | |
| 217 | + | if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok { | |
| 218 | + | tabBarBox.Remove(tab.titleButton) | |
| 219 | + | logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id)) | |
| 220 | + | } | |
| 221 | + | }) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // setTabActiveStyle applies or removes the active CSS class on a tab button. | |
| 225 | + | func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) { | |
| 226 | + | if tab.titleButton == nil { | |
| 227 | + | logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id)) | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | webkit.RunOnMainThread(func() { | |
| 232 | + | if active { | |
| 233 | + | webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active") | |
| 234 | + | logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id)) | |
| 235 | + | } else { | |
| 236 | + | webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active") | |
| 237 | + | logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id)) | |
| 238 | + | } | |
| 239 | + | }) | |
| 240 | + | } | |
A internal/app/browser/tab_manager.go +540 -0
| @@ -0,0 +1,540 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/config" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // TabManager manages the global tab system for the browser. | |
| 15 | + | // Each tab contains an independent workspace with its own pane tree. | |
| 16 | + | type TabManager struct { | |
| 17 | + | tabs []*Tab | |
| 18 | + | activeIndex int | |
| 19 | + | window *webkit.Window | |
| 20 | + | app *BrowserApp | |
| 21 | + | ||
| 22 | + | // GTK widgets | |
| 23 | + | rootContainer gtk.Widgetter // Main vertical box containing tab bar + content | |
| 24 | + | tabBar gtk.Widgetter // Horizontal tab bar container | |
| 25 | + | ContentArea gtk.Widgetter // Container for active workspace (exported for border styling) | |
| 26 | + | tabModeTarget gtk.Widgetter // Widget currently holding tab mode border margins | |
| 27 | + | ||
| 28 | + | // Modal state | |
| 29 | + | tabModeActive bool | |
| 30 | + | tabModeTimer *time.Timer | |
| 31 | + | ||
| 32 | + | // Tab button click handling (pattern from StackedPaneManager) | |
| 33 | + | buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling | |
| 34 | + | nextButtonID uint64 // Atomic counter for generating unique button IDs | |
| 35 | + | ||
| 36 | + | // Synchronization | |
| 37 | + | mu sync.RWMutex | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Tab represents a single browser tab containing a complete workspace. | |
| 41 | + | type Tab struct { | |
| 42 | + | id string | |
| 43 | + | title string | |
| 44 | + | customTitle string // User-provided custom name (persists across page loads) | |
| 45 | + | workspace *WorkspaceManager | |
| 46 | + | titleButton gtk.Widgetter | |
| 47 | + | isActive bool | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // NewTabManager creates a new tab manager instance. | |
| 51 | + | func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager { | |
| 52 | + | tm := &TabManager{ | |
| 53 | + | tabs: make([]*Tab, 0), | |
| 54 | + | activeIndex: -1, | |
| 55 | + | window: window, | |
| 56 | + | app: app, | |
| 57 | + | buttonToTab: make(map[uint64]*Tab), | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | logging.Info("[tabs] Tab manager initialized") | |
| 61 | + | return tm | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Initialize sets up the tab bar and creates the initial tab. | |
| 65 | + | func (tm *TabManager) Initialize(initialURL string) error { | |
| 66 | + | tm.mu.Lock() | |
| 67 | + | defer tm.mu.Unlock() | |
| 68 | + | ||
| 69 | + | logging.Info("[tabs] Initializing tab system") | |
| 70 | + | ||
| 71 | + | // Create root container layout | |
| 72 | + | if err := tm.createRootContainer(); err != nil { | |
| 73 | + | return fmt.Errorf("failed to create root container: %w", err) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Create initial tab | |
| 77 | + | if err := tm.createTabInternal(initialURL); err != nil { | |
| 78 | + | return fmt.Errorf("failed to create initial tab: %w", err) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | logging.Info("[tabs] Tab system initialized with 1 tab") | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // createRootContainer creates the main vertical container with tab bar and content area. | |
| 86 | + | func (tm *TabManager) createRootContainer() error { | |
| 87 | + | logging.Info("[tabs] Creating root container with tab bar") | |
| 88 | + | ||
| 89 | + | // Get tab bar position from config | |
| 90 | + | cfg := tm.getConfig() | |
| 91 | + | position := cfg.Workspace.TabBarPosition | |
| 92 | + | if position != "top" && position != "bottom" { | |
| 93 | + | position = "bottom" // Fallback to default | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position)) | |
| 97 | + | ||
| 98 | + | // Create main vertical box (tab bar + content area) | |
| 99 | + | rootBox := gtk.NewBox(gtk.OrientationVertical, 0) | |
| 100 | + | if rootBox == nil { | |
| 101 | + | return fmt.Errorf("failed to create root container box") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | rootBox.SetHExpand(true) | |
| 105 | + | rootBox.SetVExpand(true) | |
| 106 | + | ||
| 107 | + | // Create tab bar | |
| 108 | + | tabBar := tm.createTabBar() | |
| 109 | + | if tabBar == nil { | |
| 110 | + | return fmt.Errorf("failed to create tab bar") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Create content area for active workspace | |
| 114 | + | contentArea := gtk.NewBox(gtk.OrientationVertical, 0) | |
| 115 | + | if contentArea == nil { | |
| 116 | + | return fmt.Errorf("failed to create content area") | |
| 117 | + | } | |
| 118 | + | contentArea.SetHExpand(true) | |
| 119 | + | contentArea.SetVExpand(true) | |
| 120 | + | contentArea.AddCSSClass("tab-content-area") | |
| 121 | + | ||
| 122 | + | // Add widgets in order based on position | |
| 123 | + | if position == "top" { | |
| 124 | + | rootBox.Append(tabBar) | |
| 125 | + | rootBox.Append(contentArea) | |
| 126 | + | } else { | |
| 127 | + | rootBox.Append(contentArea) | |
| 128 | + | rootBox.Append(tabBar) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Store references | |
| 132 | + | tm.rootContainer = rootBox | |
| 133 | + | tm.tabBar = tabBar | |
| 134 | + | tm.ContentArea = contentArea | |
| 135 | + | ||
| 136 | + | logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position)) | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // CreateTab creates a new tab and switches to it. | |
| 141 | + | // URL can be empty string to use default homepage. | |
| 142 | + | func (tm *TabManager) CreateTab(url string) error { | |
| 143 | + | tm.mu.Lock() | |
| 144 | + | defer tm.mu.Unlock() | |
| 145 | + | ||
| 146 | + | return tm.createTabInternal(url) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // createTabInternal creates a new tab (must be called with lock held). | |
| 150 | + | func (tm *TabManager) createTabInternal(url string) error { | |
| 151 | + | logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url)) | |
| 152 | + | ||
| 153 | + | // Use default URL if empty | |
| 154 | + | if url == "" { | |
| 155 | + | url = "about:blank" | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // Generate unique tab ID and default name | |
| 159 | + | tabNumber := len(tm.tabs) + 1 | |
| 160 | + | tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix()) | |
| 161 | + | defaultTitle := fmt.Sprintf("Tab %d", tabNumber) | |
| 162 | + | ||
| 163 | + | // Create tab structure | |
| 164 | + | tab := &Tab{ | |
| 165 | + | id: tabID, | |
| 166 | + | title: defaultTitle, | |
| 167 | + | isActive: false, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Create WebView for this tab | |
| 171 | + | cfg, err := tm.app.buildWebkitConfig() | |
| 172 | + | if err != nil { | |
| 173 | + | return fmt.Errorf("failed to build webkit config: %w", err) | |
| 174 | + | } | |
| 175 | + | cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window | |
| 176 | + | ||
| 177 | + | view, err := webkit.NewWebView(cfg) | |
| 178 | + | if err != nil { | |
| 179 | + | return fmt.Errorf("failed to create webview: %w", err) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Create pane for this WebView | |
| 183 | + | pane, err := tm.app.createPaneForView(view) | |
| 184 | + | if err != nil { | |
| 185 | + | return fmt.Errorf("failed to create pane: %w", err) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Create workspace for this tab | |
| 189 | + | workspace := NewWorkspaceManager(tm.app, pane) | |
| 190 | + | tab.workspace = workspace | |
| 191 | + | ||
| 192 | + | // Verify workspace root exists | |
| 193 | + | rootWidget := workspace.GetRootWidget() | |
| 194 | + | if rootWidget == nil { | |
| 195 | + | return fmt.Errorf("workspace root widget is nil") | |
| 196 | + | } | |
| 197 | + | logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace)) | |
| 198 | + | ||
| 199 | + | // Load the URL in the workspace's pane | |
| 200 | + | if url != "" { | |
| 201 | + | if err := view.LoadURL(url); err != nil { | |
| 202 | + | logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err)) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // Create tab button in tab bar | |
| 207 | + | button := tm.createTabButton(tab) | |
| 208 | + | if button == nil { | |
| 209 | + | return fmt.Errorf("failed to create tab button") | |
| 210 | + | } | |
| 211 | + | tab.titleButton = button | |
| 212 | + | ||
| 213 | + | // Add button to tab bar | |
| 214 | + | tm.addTabToBar(tab) | |
| 215 | + | ||
| 216 | + | // Add to tabs slice | |
| 217 | + | tm.tabs = append(tm.tabs, tab) | |
| 218 | + | ||
| 219 | + | // Attach click handler with unique ID (pattern from stacked panes) | |
| 220 | + | tm.attachTabClickHandler(tab.titleButton, tab) | |
| 221 | + | ||
| 222 | + | // Switch to new tab | |
| 223 | + | newIndex := len(tm.tabs) - 1 | |
| 224 | + | if err := tm.switchToTabInternal(newIndex); err != nil { | |
| 225 | + | return fmt.Errorf("failed to switch to new tab: %w", err) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // Update tab bar visibility (hide if only 1 tab) | |
| 229 | + | tm.updateTabBarVisibility() | |
| 230 | + | ||
| 231 | + | logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs))) | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // SwitchToTab switches to the tab at the given index. | |
| 236 | + | func (tm *TabManager) SwitchToTab(index int) error { | |
| 237 | + | tm.mu.Lock() | |
| 238 | + | defer tm.mu.Unlock() | |
| 239 | + | ||
| 240 | + | return tm.switchToTabInternal(index) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // switchToTabInternal switches to a tab (must be called with lock held). | |
| 244 | + | func (tm *TabManager) switchToTabInternal(index int) error { | |
| 245 | + | // Validate index | |
| 246 | + | if index < 0 || index >= len(tm.tabs) { | |
| 247 | + | logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1)) | |
| 248 | + | return fmt.Errorf("tab index %d out of range", index) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // Check if already active | |
| 252 | + | if index == tm.activeIndex { | |
| 253 | + | logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index)) | |
| 254 | + | return nil | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index)) | |
| 258 | + | ||
| 259 | + | // Hide current tab if any | |
| 260 | + | if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) { | |
| 261 | + | oldTab := tm.tabs[tm.activeIndex] | |
| 262 | + | oldTab.isActive = false | |
| 263 | + | ||
| 264 | + | // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events | |
| 265 | + | if oldTab.workspace != nil { | |
| 266 | + | oldTab.workspace.ClearFocus() | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Hide old workspace container and remove from content area | |
| 270 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container, | |
| 271 | + | // because splits/stacks change the workspace root | |
| 272 | + | if oldTab.workspace != nil { | |
| 273 | + | currentRoot := oldTab.workspace.GetRootWidget() | |
| 274 | + | if currentRoot != nil { | |
| 275 | + | webkit.RunOnMainThread(func() { | |
| 276 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |
| 277 | + | contentBox.Remove(currentRoot) | |
| 278 | + | logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot)) | |
| 279 | + | } | |
| 280 | + | }) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // Remove active CSS class from old button | |
| 285 | + | tm.setTabActiveStyle(oldTab, false) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // Show new tab | |
| 289 | + | newTab := tm.tabs[index] | |
| 290 | + | newTab.isActive = true | |
| 291 | + | tm.activeIndex = index | |
| 292 | + | ||
| 293 | + | // Add new workspace container to content area | |
| 294 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container | |
| 295 | + | if newTab.workspace != nil { | |
| 296 | + | currentRoot := newTab.workspace.GetRootWidget() | |
| 297 | + | if currentRoot != nil { | |
| 298 | + | webkit.RunOnMainThread(func() { | |
| 299 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |
| 300 | + | contentBox.Append(currentRoot) | |
| 301 | + | webkit.WidgetSetVisible(currentRoot, true) | |
| 302 | + | logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot)) | |
| 303 | + | } | |
| 304 | + | }) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // Add active CSS class to new button | |
| 309 | + | tm.setTabActiveStyle(newTab, true) | |
| 310 | + | ||
| 311 | + | // Update app-level workspace/panes references for compatibility | |
| 312 | + | if newTab.workspace != nil { | |
| 313 | + | tm.app.workspace = newTab.workspace | |
| 314 | + | tm.app.panes = newTab.workspace.GetAllPanes() | |
| 315 | + | tm.app.activePane = newTab.workspace.GetActivePane() | |
| 316 | + | ||
| 317 | + | // Focus the new workspace's active pane | |
| 318 | + | newTab.workspace.RestoreFocus() | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id)) | |
| 322 | + | return nil | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // CloseTab closes the tab at the given index. | |
| 326 | + | func (tm *TabManager) CloseTab(index int) error { | |
| 327 | + | tm.mu.Lock() | |
| 328 | + | defer tm.mu.Unlock() | |
| 329 | + | ||
| 330 | + | return tm.closeTabInternal(index) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // closeTabInternal closes a tab (must be called with lock held). | |
| 334 | + | func (tm *TabManager) closeTabInternal(index int) error { | |
| 335 | + | // Validate index | |
| 336 | + | if index < 0 || index >= len(tm.tabs) { | |
| 337 | + | logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index)) | |
| 338 | + | return fmt.Errorf("tab index %d out of range", index) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // Prevent closing last tab | |
| 342 | + | if len(tm.tabs) == 1 { | |
| 343 | + | logging.Warn("[tabs] Cannot close last tab") | |
| 344 | + | return fmt.Errorf("cannot close the last tab") | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | tab := tm.tabs[index] | |
| 348 | + | logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id)) | |
| 349 | + | ||
| 350 | + | // Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed) | |
| 351 | + | ||
| 352 | + | // Clean up button mapping (find and remove this tab's button ID) | |
| 353 | + | for buttonID, mappedTab := range tm.buttonToTab { | |
| 354 | + | if mappedTab == tab { | |
| 355 | + | delete(tm.buttonToTab, buttonID) | |
| 356 | + | logging.Debug(fmt.Sprintf("[tabs] Cleaned up button mapping for closed tab %s (buttonID=%d)", tab.id, buttonID)) | |
| 357 | + | break | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // Remove tab button from tab bar | |
| 362 | + | tm.removeTabFromBar(tab) | |
| 363 | + | ||
| 364 | + | // Remove workspace container from content area if it's currently visible | |
| 365 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container | |
| 366 | + | if tab.isActive && tab.workspace != nil { | |
| 367 | + | currentRoot := tab.workspace.GetRootWidget() | |
| 368 | + | if currentRoot != nil { | |
| 369 | + | webkit.RunOnMainThread(func() { | |
| 370 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |
| 371 | + | contentBox.Remove(currentRoot) | |
| 372 | + | logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot)) | |
| 373 | + | } | |
| 374 | + | }) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // Remove from slice | |
| 379 | + | tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...) | |
| 380 | + | ||
| 381 | + | // Determine which tab to switch to | |
| 382 | + | newIndex := index | |
| 383 | + | if index >= len(tm.tabs) { | |
| 384 | + | newIndex = len(tm.tabs) - 1 | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // Update active index and switch | |
| 388 | + | if index == tm.activeIndex { | |
| 389 | + | tm.activeIndex = -1 // Temporarily invalid | |
| 390 | + | if err := tm.switchToTabInternal(newIndex); err != nil { | |
| 391 | + | return fmt.Errorf("failed to switch after close: %w", err) | |
| 392 | + | } | |
| 393 | + | } else if index < tm.activeIndex { | |
| 394 | + | // Adjust active index if a tab before it was closed | |
| 395 | + | tm.activeIndex-- | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // Update tab bar visibility (hide if only 1 tab) | |
| 399 | + | tm.updateTabBarVisibility() | |
| 400 | + | ||
| 401 | + | logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs))) | |
| 402 | + | return nil | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // RenameTab sets a custom title for the tab at the given index. | |
| 406 | + | func (tm *TabManager) RenameTab(index int, customTitle string) error { | |
| 407 | + | tm.mu.Lock() | |
| 408 | + | defer tm.mu.Unlock() | |
| 409 | + | ||
| 410 | + | if index < 0 || index >= len(tm.tabs) { | |
| 411 | + | return fmt.Errorf("tab index %d out of range", index) | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | tab := tm.tabs[index] | |
| 415 | + | tab.customTitle = customTitle | |
| 416 | + | ||
| 417 | + | logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle)) | |
| 418 | + | ||
| 419 | + | // Update tab button label | |
| 420 | + | tm.updateTabButton(tab) | |
| 421 | + | ||
| 422 | + | return nil | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles. | |
| 426 | + | // This is different from stacked panes which show page titles. | |
| 427 | + | // Users can only change tab names via explicit rename action (Alt+T → r) | |
| 428 | + | ||
| 429 | + | // GetActiveTab returns the currently active tab. | |
| 430 | + | func (tm *TabManager) GetActiveTab() *Tab { | |
| 431 | + | tm.mu.RLock() | |
| 432 | + | defer tm.mu.RUnlock() | |
| 433 | + | ||
| 434 | + | if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) { | |
| 435 | + | return tm.tabs[tm.activeIndex] | |
| 436 | + | } | |
| 437 | + | return nil | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // GetTabCount returns the number of open tabs. | |
| 441 | + | func (tm *TabManager) GetTabCount() int { | |
| 442 | + | tm.mu.RLock() | |
| 443 | + | defer tm.mu.RUnlock() | |
| 444 | + | ||
| 445 | + | return len(tm.tabs) | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // IsTabModeActive returns whether tab mode is currently active. | |
| 449 | + | func (tm *TabManager) IsTabModeActive() bool { | |
| 450 | + | tm.mu.RLock() | |
| 451 | + | defer tm.mu.RUnlock() | |
| 452 | + | ||
| 453 | + | return tm.tabModeActive | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | // NextTab switches to the next tab (wraps around). | |
| 457 | + | func (tm *TabManager) NextTab() error { | |
| 458 | + | tm.mu.Lock() | |
| 459 | + | defer tm.mu.Unlock() | |
| 460 | + | ||
| 461 | + | if len(tm.tabs) <= 1 { | |
| 462 | + | return nil // No other tab to switch to | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | nextIndex := (tm.activeIndex + 1) % len(tm.tabs) | |
| 466 | + | return tm.switchToTabInternal(nextIndex) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | // PreviousTab switches to the previous tab (wraps around). | |
| 470 | + | func (tm *TabManager) PreviousTab() error { | |
| 471 | + | tm.mu.Lock() | |
| 472 | + | defer tm.mu.Unlock() | |
| 473 | + | ||
| 474 | + | if len(tm.tabs) <= 1 { | |
| 475 | + | return nil // No other tab to switch to | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | prevIndex := tm.activeIndex - 1 | |
| 479 | + | if prevIndex < 0 { | |
| 480 | + | prevIndex = len(tm.tabs) - 1 | |
| 481 | + | } | |
| 482 | + | return tm.switchToTabInternal(prevIndex) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | // GetRootContainer returns the main container widget that should be set as window child. | |
| 486 | + | func (tm *TabManager) GetRootContainer() gtk.Widgetter { | |
| 487 | + | tm.mu.RLock() | |
| 488 | + | defer tm.mu.RUnlock() | |
| 489 | + | ||
| 490 | + | return tm.rootContainer | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // GetWindow returns the GTK window backing the tab manager. | |
| 494 | + | func (tm *TabManager) GetWindow() *webkit.Window { | |
| 495 | + | tm.mu.RLock() | |
| 496 | + | defer tm.mu.RUnlock() | |
| 497 | + | ||
| 498 | + | return tm.window | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // Cleanup performs cleanup of all tabs and resources. | |
| 502 | + | func (tm *TabManager) Cleanup() { | |
| 503 | + | tm.mu.Lock() | |
| 504 | + | defer tm.mu.Unlock() | |
| 505 | + | ||
| 506 | + | logging.Info("[tabs] Cleaning up tab manager") | |
| 507 | + | ||
| 508 | + | // Stop tab mode timer if active | |
| 509 | + | if tm.tabModeTimer != nil { | |
| 510 | + | tm.tabModeTimer.Stop() | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | // Cleanup all tabs (workspaces cleanup automatically when dereferenced) | |
| 514 | + | ||
| 515 | + | logging.Info("[tabs] Tab manager cleanup complete") | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // updateTabBarVisibility shows or hides the tab bar based on tab count. | |
| 519 | + | // Tab bar is hidden when there's only 1 tab (no need to show it). | |
| 520 | + | func (tm *TabManager) updateTabBarVisibility() { | |
| 521 | + | if tm.tabBar == nil { | |
| 522 | + | return | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | tabCount := len(tm.tabs) | |
| 526 | + | shouldShow := tabCount > 1 | |
| 527 | + | ||
| 528 | + | webkit.WidgetSetVisible(tm.tabBar, shouldShow) | |
| 529 | + | ||
| 530 | + | if shouldShow { | |
| 531 | + | logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount)) | |
| 532 | + | } else { | |
| 533 | + | logging.Debug("[tabs] Tab bar hidden (only 1 tab)") | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | // GetConfig is a helper to get the current configuration. | |
| 538 | + | func (tm *TabManager) getConfig() *config.Config { | |
| 539 | + | return config.Get() | |
| 540 | + | } | |
A internal/app/browser/tab_mode.go +321 -0
| @@ -0,0 +1,321 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // EnterTabMode activates the modal tab management mode. | |
| 13 | + | // This is triggered by Alt+T and provides visual feedback and temporary key bindings. | |
| 14 | + | func (tm *TabManager) EnterTabMode() { | |
| 15 | + | logging.Debug("[tab-mode] EnterTabMode called - attempting to acquire lock") | |
| 16 | + | tm.mu.Lock() | |
| 17 | + | defer tm.mu.Unlock() | |
| 18 | + | logging.Debug("[tab-mode] Lock acquired") | |
| 19 | + | ||
| 20 | + | // Already in tab mode? | |
| 21 | + | if tm.tabModeActive { | |
| 22 | + | logging.Debug("[tab-mode] Tab mode already active") | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | logging.Info("[tab-mode] Entering tab mode") | |
| 27 | + | ||
| 28 | + | logging.Debug("[tab-mode] Setting tabModeActive flag") | |
| 29 | + | tm.tabModeActive = true | |
| 30 | + | ||
| 31 | + | // Apply visual border indicator | |
| 32 | + | logging.Debug("[tab-mode] About to call applyTabModeBorder()") | |
| 33 | + | tm.applyTabModeBorder() | |
| 34 | + | logging.Debug("[tab-mode] Returned from applyTabModeBorder()") | |
| 35 | + | ||
| 36 | + | // Notify JavaScript for UI feedback (if needed) | |
| 37 | + | logging.Debug("[tab-mode] About to call dispatchTabModeEventInternal()") | |
| 38 | + | tm.dispatchTabModeEventInternal("entered", "") | |
| 39 | + | logging.Debug("[tab-mode] Returned from dispatchTabModeEventInternal()") | |
| 40 | + | ||
| 41 | + | // Start timeout timer to auto-exit tab mode | |
| 42 | + | logging.Debug("[tab-mode] Getting config") | |
| 43 | + | cfg := tm.getConfig() | |
| 44 | + | timeoutDuration := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 45 | + | logging.Debug(fmt.Sprintf("[tab-mode] Config retrieved, timeout: %v", timeoutDuration)) | |
| 46 | + | ||
| 47 | + | // Cancel existing timer if any | |
| 48 | + | if tm.tabModeTimer != nil { | |
| 49 | + | logging.Debug("[tab-mode] Stopping existing timer") | |
| 50 | + | tm.tabModeTimer.Stop() | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | logging.Debug("[tab-mode] Creating new timeout timer") | |
| 54 | + | tm.tabModeTimer = time.AfterFunc(timeoutDuration, func() { | |
| 55 | + | logging.Debug("[tab-mode] Timeout reached, exiting tab mode") | |
| 56 | + | tm.ExitTabMode("timeout") | |
| 57 | + | }) | |
| 58 | + | ||
| 59 | + | logging.Debug(fmt.Sprintf("[tab-mode] Tab mode entered successfully, will auto-exit in %v", timeoutDuration)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ExitTabMode deactivates tab mode and removes visual indicators. | |
| 63 | + | func (tm *TabManager) ExitTabMode(reason string) { | |
| 64 | + | tm.mu.Lock() | |
| 65 | + | defer tm.mu.Unlock() | |
| 66 | + | ||
| 67 | + | if !tm.tabModeActive { | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | logging.Info(fmt.Sprintf("[tab-mode] Exiting tab mode: %s", reason)) | |
| 72 | + | ||
| 73 | + | tm.tabModeActive = false | |
| 74 | + | ||
| 75 | + | // Stop timeout timer | |
| 76 | + | if tm.tabModeTimer != nil { | |
| 77 | + | tm.tabModeTimer.Stop() | |
| 78 | + | tm.tabModeTimer = nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Remove visual border indicator | |
| 82 | + | tm.removeTabModeBorder() | |
| 83 | + | ||
| 84 | + | // Notify JavaScript (use internal version to avoid lock reentry) | |
| 85 | + | tm.dispatchTabModeEventInternal("exited", reason) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // HandleTabAction processes a tab mode action (new-tab, close-tab, etc.). | |
| 89 | + | func (tm *TabManager) HandleTabAction(action string) { | |
| 90 | + | tm.mu.Lock() | |
| 91 | + | ||
| 92 | + | if !tm.tabModeActive { | |
| 93 | + | tm.mu.Unlock() | |
| 94 | + | logging.Debug(fmt.Sprintf("[tab-mode] Ignoring action '%s': tab mode not active", action)) | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | tm.mu.Unlock() | |
| 99 | + | ||
| 100 | + | logging.Info(fmt.Sprintf("[tab-mode] Handling action '%s'", action)) | |
| 101 | + | ||
| 102 | + | switch action { | |
| 103 | + | case "new-tab": | |
| 104 | + | tm.handleNewTab() | |
| 105 | + | case "close-tab": | |
| 106 | + | tm.handleCloseTab() | |
| 107 | + | case "next-tab": | |
| 108 | + | tm.handleNextTab() | |
| 109 | + | case "previous-tab": | |
| 110 | + | tm.handlePreviousTab() | |
| 111 | + | case "rename-tab": | |
| 112 | + | tm.handleRenameTab() | |
| 113 | + | case "confirm": | |
| 114 | + | // Just exit tab mode | |
| 115 | + | tm.ExitTabMode("confirm") | |
| 116 | + | case "cancel": | |
| 117 | + | // Exit tab mode without action | |
| 118 | + | tm.ExitTabMode("cancel") | |
| 119 | + | default: | |
| 120 | + | logging.Warn(fmt.Sprintf("[tab-mode] Unknown action: %s", action)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Exit tab mode after most actions (except rename which shows a dialog) | |
| 124 | + | if action != "cancel" && action != "confirm" && action != "rename-tab" { | |
| 125 | + | tm.ExitTabMode(action) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // handleNewTab creates a new tab. | |
| 130 | + | func (tm *TabManager) handleNewTab() { | |
| 131 | + | logging.Debug("[tab-mode] Creating new tab") | |
| 132 | + | ||
| 133 | + | if err := tm.CreateTab(""); err != nil { | |
| 134 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to create new tab: %v", err)) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // handleCloseTab closes the currently active tab. | |
| 139 | + | func (tm *TabManager) handleCloseTab() { | |
| 140 | + | logging.Debug("[tab-mode] Closing active tab") | |
| 141 | + | ||
| 142 | + | tm.mu.RLock() | |
| 143 | + | activeIndex := tm.activeIndex | |
| 144 | + | tabCount := len(tm.tabs) | |
| 145 | + | tm.mu.RUnlock() | |
| 146 | + | ||
| 147 | + | if tabCount <= 1 { | |
| 148 | + | logging.Warn("[tab-mode] Cannot close last tab") | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | if err := tm.CloseTab(activeIndex); err != nil { | |
| 153 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to close tab: %v", err)) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // handleNextTab switches to the next tab. | |
| 158 | + | func (tm *TabManager) handleNextTab() { | |
| 159 | + | logging.Debug("[tab-mode] Switching to next tab") | |
| 160 | + | ||
| 161 | + | if err := tm.NextTab(); err != nil { | |
| 162 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to next tab: %v", err)) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // handlePreviousTab switches to the previous tab. | |
| 167 | + | func (tm *TabManager) handlePreviousTab() { | |
| 168 | + | logging.Debug("[tab-mode] Switching to previous tab") | |
| 169 | + | ||
| 170 | + | if err := tm.PreviousTab(); err != nil { | |
| 171 | + | logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to previous tab: %v", err)) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // handleRenameTab shows a dialog to rename the active tab. | |
| 176 | + | func (tm *TabManager) handleRenameTab() { | |
| 177 | + | logging.Debug("[tab-mode] Opening rename dialog") | |
| 178 | + | ||
| 179 | + | // Exit tab mode first so the dialog can be interacted with | |
| 180 | + | tm.ExitTabMode("rename-dialog") | |
| 181 | + | ||
| 182 | + | tm.mu.RLock() | |
| 183 | + | activeIndex := tm.activeIndex | |
| 184 | + | var currentTitle string | |
| 185 | + | if activeIndex >= 0 && activeIndex < len(tm.tabs) { | |
| 186 | + | tab := tm.tabs[activeIndex] | |
| 187 | + | if tab.customTitle != "" { | |
| 188 | + | currentTitle = tab.customTitle | |
| 189 | + | } else { | |
| 190 | + | currentTitle = tab.title | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | tm.mu.RUnlock() | |
| 194 | + | ||
| 195 | + | if activeIndex < 0 { | |
| 196 | + | logging.Error("[tab-mode] No active tab to rename") | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Show rename dialog (will be implemented in Step 10) | |
| 201 | + | // For now, just log | |
| 202 | + | logging.Info(fmt.Sprintf("[tab-mode] Would show rename dialog for tab %d (current: %s)", activeIndex, currentTitle)) | |
| 203 | + | ||
| 204 | + | // TODO: Implement GTK dialog in Step 10 | |
| 205 | + | // tm.showRenameDialog(activeIndex, currentTitle) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // applyTabModeBorder applies visual indicator when tab mode is active. | |
| 209 | + | // This adds an orange border around the entire workspace area. | |
| 210 | + | func (tm *TabManager) applyTabModeBorder() { | |
| 211 | + | logging.Debug("[tab-mode] applyTabModeBorder: entry") | |
| 212 | + | ||
| 213 | + | if tm.window == nil { | |
| 214 | + | logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning") | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Determine which container should receive the margins: | |
| 219 | + | // - Prefer the root container (direct child of the window) so the window background shows | |
| 220 | + | // - Fallback to ContentArea for older builds | |
| 221 | + | var target gtk.Widgetter | |
| 222 | + | if tm.rootContainer != nil { | |
| 223 | + | target = tm.rootContainer | |
| 224 | + | logging.Debug("[tab-mode] applyTabModeBorder: using root container for border") | |
| 225 | + | } else if tm.ContentArea != nil { | |
| 226 | + | target = tm.ContentArea | |
| 227 | + | logging.Debug("[tab-mode] applyTabModeBorder: using content area fallback for border") | |
| 228 | + | } else { | |
| 229 | + | logging.Debug("[tab-mode] applyTabModeBorder: no container available, returning") | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Remember the container for removal | |
| 234 | + | tm.tabModeTarget = target | |
| 235 | + | ||
| 236 | + | // Apply 4px margins to create space for the border | |
| 237 | + | webkit.WidgetSetMargin(target, 4) | |
| 238 | + | ||
| 239 | + | // Add CSS class to window for background color (the "border" color shows in the margin space) | |
| 240 | + | gtkWindow := tm.window.AsWindow() | |
| 241 | + | webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active") | |
| 242 | + | ||
| 243 | + | // Force resize/allocation to apply margin changes immediately | |
| 244 | + | webkit.WidgetQueueResize(target) | |
| 245 | + | webkit.WidgetQueueAllocate(target) | |
| 246 | + | ||
| 247 | + | // Queue redraw to show changes | |
| 248 | + | webkit.WidgetQueueDraw(gtkWindow) | |
| 249 | + | webkit.WidgetQueueDraw(target) | |
| 250 | + | ||
| 251 | + | logging.Debug("[tab-mode] applyTabModeBorder: border applied successfully") | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // removeTabModeBorder removes the visual indicator when exiting tab mode. | |
| 255 | + | func (tm *TabManager) removeTabModeBorder() { | |
| 256 | + | if tm.window == nil { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // Use whichever container received margins | |
| 261 | + | target := tm.tabModeTarget | |
| 262 | + | if target == nil { | |
| 263 | + | if tm.rootContainer != nil { | |
| 264 | + | target = tm.rootContainer | |
| 265 | + | } else { | |
| 266 | + | target = tm.ContentArea | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | if target == nil { | |
| 270 | + | return | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Remove margins | |
| 274 | + | webkit.WidgetSetMargin(target, 0) | |
| 275 | + | ||
| 276 | + | // Remove CSS class from window | |
| 277 | + | gtkWindow := tm.window.AsWindow() | |
| 278 | + | webkit.WidgetRemoveCSSClass(gtkWindow, "tab-mode-active") | |
| 279 | + | ||
| 280 | + | // Force resize/allocation to apply margin changes immediately | |
| 281 | + | webkit.WidgetQueueResize(target) | |
| 282 | + | webkit.WidgetQueueAllocate(target) | |
| 283 | + | ||
| 284 | + | // Queue redraw to show changes | |
| 285 | + | webkit.WidgetQueueDraw(gtkWindow) | |
| 286 | + | webkit.WidgetQueueDraw(target) | |
| 287 | + | ||
| 288 | + | tm.tabModeTarget = nil | |
| 289 | + | ||
| 290 | + | logging.Debug("[tab-mode] Removed tab mode visual indicator") | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // dispatchTabModeEventInternal sends an event without acquiring locks. | |
| 294 | + | // Must be called with lock already held. | |
| 295 | + | func (tm *TabManager) dispatchTabModeEventInternal(event string, detail string) { | |
| 296 | + | logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: entry (event=%s, detail=%s)", event, detail)) | |
| 297 | + | ||
| 298 | + | // Get active tab | |
| 299 | + | if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) { | |
| 300 | + | logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: invalid activeIndex=%d, returning", tm.activeIndex)) | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | activeTab := tm.tabs[tm.activeIndex] | |
| 305 | + | if activeTab == nil { | |
| 306 | + | logging.Debug("[tab-mode] dispatchTabModeEventInternal: activeTab is nil, returning") | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // Log tab mode events for debugging (no JS dispatch needed for simple text tab labels) | |
| 311 | + | logging.Debug(fmt.Sprintf("[tab-mode] Tab mode event '%s' with detail '%s' for tab %s", event, detail, activeTab.id)) | |
| 312 | + | logging.Debug("[tab-mode] dispatchTabModeEventInternal: completed successfully") | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // dispatchTabModeEvent sends a custom event to the active tab's webview (with locking). | |
| 316 | + | // This can be used to provide UI feedback in the web content. | |
| 317 | + | func (tm *TabManager) dispatchTabModeEvent(event string, detail string) { | |
| 318 | + | tm.mu.RLock() | |
| 319 | + | defer tm.mu.RUnlock() | |
| 320 | + | tm.dispatchTabModeEventInternal(event, detail) | |
| 321 | + | } | |
M internal/app/browser/webview.go +22 -16
| @@ -249,28 +249,34 @@ func (app *BrowserApp) createWebView() error { | |||
| 249 | 249 | app.clipboardController = pane.clipboardController | |
| 250 | 250 | app.messageHandler = pane.messageHandler | |
| 251 | 251 | app.shortcutHandler = pane.shortcutHandler | |
| 252 | - | app.panes = []*BrowserPane{pane} | |
| 253 | - | app.activePane = pane | |
| 254 | 252 | ||
| 255 | - | if app.workspace == nil { | |
| 256 | - | app.workspace = NewWorkspaceManager(app, pane) | |
| 257 | - | // CRITICAL: Register navigation handler for the root webview | |
| 258 | - | // This is needed because buildPane() couldn't register it when workspace was nil | |
| 259 | - | app.workspace.RegisterNavigationHandler(view) | |
| 253 | + | // Initialize tab manager (which manages workspaces) | |
| 254 | + | window := view.Window() | |
| 255 | + | if window != nil && app.tabManager == nil { | |
| 256 | + | app.tabManager = NewTabManager(app, window) | |
| 260 | 257 | ||
| 261 | - | // CRITICAL: Setup popup handling for the root webview | |
| 262 | - | // This must be done after workspace is created so WebKit's create signal is connected | |
| 263 | - | node := app.workspace.GetNodeForWebView(view) | |
| 264 | - | if node != nil { | |
| 265 | - | app.workspace.setupPopupHandling(view, node) | |
| 266 | - | log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID()) | |
| 258 | + | // Determine initial URL | |
| 259 | + | initialURL := "about:blank" | |
| 260 | + | ||
| 261 | + | // Initialize tab system (creates root container with tab bar and first tab) | |
| 262 | + | if err := app.tabManager.Initialize(initialURL); err != nil { | |
| 263 | + | log.Printf("ERROR: failed to initialize tab manager: %v", err) | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // Set tab manager's root container as window content | |
| 268 | + | rootContainer := app.tabManager.GetRootContainer() | |
| 269 | + | if rootContainer != nil { | |
| 270 | + | window.SetChild(rootContainer) | |
| 271 | + | log.Printf("[tabs] Tab system initialized and set as window content") | |
| 267 | 272 | } else { | |
| 268 | - | log.Printf("[webview] WARNING: Could not find node for root WebView ID: %d", view.ID()) | |
| 273 | + | log.Printf("ERROR: tab manager root container is nil") | |
| 274 | + | return fmt.Errorf("failed to get tab manager root container") | |
| 269 | 275 | } | |
| 270 | 276 | } | |
| 271 | 277 | ||
| 272 | - | // Initialize window-level global shortcuts AFTER workspace is set up | |
| 273 | - | if window := view.Window(); window != nil { | |
| 278 | + | // Initialize window-level global shortcuts AFTER tab manager is set up | |
| 279 | + | if window != nil { | |
| 274 | 280 | app.windowShortcutHandler = NewWindowShortcutHandler(window, app) | |
| 275 | 281 | if app.windowShortcutHandler != nil { | |
| 276 | 282 | log.Printf("Window-level global shortcuts initialized") | |
M internal/app/browser/window_shortcuts.go +276 -8
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/app/messaging" | |
| 10 | 10 | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 12 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // WindowShortcutHandler manages global shortcuts at the window level | |
| @@ -47,6 +49,11 @@ func (h *WindowShortcutHandler) initialize() error { | |||
| 47 | 49 | return ErrFailedToInitialize | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 52 | + | // Register hardware keycode-based shortcuts (for AZERTY/international keyboards) | |
| 53 | + | if err := h.registerKeycodeShortcuts(); err != nil { | |
| 54 | + | log.Printf("[window-shortcuts] Failed to register keycode shortcuts: %v", err) | |
| 55 | + | } | |
| 56 | + | ||
| 50 | 57 | return h.registerGlobalShortcuts() | |
| 51 | 58 | } | |
| 52 | 59 | ||
| @@ -60,9 +67,23 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 60 | 67 | {"ctrl+f", h.handleFindToggle, "Find in page"}, | |
| 61 | 68 | {"ctrl+shift+c", h.handleCopyURL, "Copy URL"}, | |
| 62 | 69 | {"ctrl+shift+p", h.handlePrint, "Print page"}, | |
| 63 | - | {"ctrl+t", h.handleNewTab, "New tab (no-op)"}, | |
| 64 | - | {"ctrl+w", h.handleClosePane, "Close current pane"}, | |
| 65 | 70 | {"F12", h.handleDevTools, "Developer tools"}, | |
| 71 | + | // Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts) | |
| 72 | + | // Tab management | |
| 73 | + | {"ctrl+t", h.handleTabMode, "Enter tab mode"}, | |
| 74 | + | // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts | |
| 75 | + | // This prevents GTK's default focus cycling behavior | |
| 76 | + | // NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered | |
| 77 | + | // as global shortcuts. They are handled by the WebView keyboard bridge which checks | |
| 78 | + | // if tab mode is active before processing them. This prevents them from consuming | |
| 79 | + | // keys when tab mode is not active (allowing normal typing in web pages). | |
| 80 | + | // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts) | |
| 81 | + | // This supports all keyboard layouts (QWERTY, AZERTY, etc.) | |
| 82 | + | // Page reload shortcuts | |
| 83 | + | {"ctrl+r", h.handleReload, "Reload page"}, | |
| 84 | + | {"ctrl+shift+r", h.handleHardReload, "Hard reload (bypass cache)"}, | |
| 85 | + | {"F5", h.handleReload, "Reload page"}, | |
| 86 | + | {"ctrl+F5", h.handleHardReload, "Hard reload (bypass cache)"}, | |
| 66 | 87 | // Zoom shortcuts - global level for proper active pane targeting | |
| 67 | 88 | {"ctrl+plus", h.handleZoomIn, "Zoom in"}, | |
| 68 | 89 | {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"}, | |
| @@ -73,6 +94,11 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 73 | 94 | {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"}, | |
| 74 | 95 | {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"}, | |
| 75 | 96 | {"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"}, | |
| 97 | + | // Vim-style workspace navigation | |
| 98 | + | {"alt+h", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane (vim)"}, | |
| 99 | + | {"alt+l", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane (vim)"}, | |
| 100 | + | {"alt+k", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane (vim)"}, | |
| 101 | + | {"alt+j", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane (vim)"}, | |
| 76 | 102 | } | |
| 77 | 103 | ||
| 78 | 104 | for _, shortcut := range shortcuts { | |
| @@ -81,6 +107,142 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 81 | 107 | shortcut.key, shortcut.desc) | |
| 82 | 108 | } | |
| 83 | 109 | ||
| 110 | + | // NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge | |
| 111 | + | // (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode() | |
| 112 | + | ||
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // registerKeycodeShortcuts registers keyboard shortcuts based on hardware keycodes | |
| 117 | + | // This allows Alt+number shortcuts to work on AZERTY and other keyboard layouts | |
| 118 | + | func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |
| 119 | + | if h.window == nil { | |
| 120 | + | return fmt.Errorf("window is nil") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | gtkWindow := h.window.AsWindow() | |
| 124 | + | if gtkWindow == nil { | |
| 125 | + | return fmt.Errorf("gtk window is nil") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Create event controller for keyboard events | |
| 129 | + | keyController := gtk.NewEventControllerKey() | |
| 130 | + | keyController.SetPropagationPhase(gtk.PhaseCapture) | |
| 131 | + | ||
| 132 | + | // GDK key constants | |
| 133 | + | const ( | |
| 134 | + | gdkKeyTab = 0xff09 | |
| 135 | + | gdkKeyEscape = 0xff1b | |
| 136 | + | gdkKeyReturn = 0xff0d | |
| 137 | + | gdkKeyN = 0x006e | |
| 138 | + | gdkKeyC = 0x0063 | |
| 139 | + | gdkKeyX = 0x0078 | |
| 140 | + | gdkKeyL = 0x006c | |
| 141 | + | gdkKeyH = 0x0068 | |
| 142 | + | gdkKeyR = 0x0072 | |
| 143 | + | ) | |
| 144 | + | ||
| 145 | + | // Map hardware keycodes to tab indices | |
| 146 | + | // These keycodes are the same across all keyboard layouts | |
| 147 | + | keycodeToTab := map[uint]int{ | |
| 148 | + | 10: 0, // 1/& key | |
| 149 | + | 11: 1, // 2/é key | |
| 150 | + | 12: 2, // 3/" key | |
| 151 | + | 13: 3, // 4/' key | |
| 152 | + | 14: 4, // 5/( key | |
| 153 | + | 15: 5, // 6/- key | |
| 154 | + | 16: 6, // 7/è key | |
| 155 | + | 17: 7, // 8/_ key | |
| 156 | + | 18: 8, // 9/ç key | |
| 157 | + | 19: 9, // 0/à key | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 161 | + | // IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first | |
| 162 | + | // During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView | |
| 163 | + | // So we only intercept specific shortcuts here and let everything else through | |
| 164 | + | ||
| 165 | + | // Handle Ctrl+Tab for next tab | |
| 166 | + | if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) { | |
| 167 | + | log.Printf("[window-shortcuts] Ctrl+Tab -> next tab") | |
| 168 | + | h.handleNextTab() | |
| 169 | + | return true // Consume event to prevent focus cycling | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Handle Ctrl+Shift+Tab for previous tab | |
| 173 | + | if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && state.Has(gdk.ShiftMask) { | |
| 174 | + | log.Printf("[window-shortcuts] Ctrl+Shift+Tab -> previous tab") | |
| 175 | + | h.handlePrevTab() | |
| 176 | + | return true // Consume event to prevent focus cycling | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // Check for Alt+number (but not with Ctrl or Shift) | |
| 180 | + | if state.Has(gdk.AltMask) && !state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) { | |
| 181 | + | // Check if this is a number key | |
| 182 | + | if tabIndex, ok := keycodeToTab[keycode]; ok { | |
| 183 | + | log.Printf("[window-shortcuts] Hardware keycode shortcut: Alt+keycode(%d) -> tab %d", keycode, tabIndex) | |
| 184 | + | h.handleDirectTabSwitch(tabIndex) | |
| 185 | + | return true // Handled | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Handle tab mode action keys (only when tab mode is active) | |
| 190 | + | // These keys need to be checked here so they can propagate when tab mode is not active | |
| 191 | + | if h.isTabModeActive() { | |
| 192 | + | handled := false | |
| 193 | + | action := "" | |
| 194 | + | ||
| 195 | + | // Check for no modifiers (plain key press) | |
| 196 | + | if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) { | |
| 197 | + | switch keyval { | |
| 198 | + | case gdkKeyN, gdkKeyC: | |
| 199 | + | action = "new-tab" | |
| 200 | + | handled = true | |
| 201 | + | case gdkKeyX: | |
| 202 | + | action = "close-tab" | |
| 203 | + | handled = true | |
| 204 | + | case gdkKeyL: | |
| 205 | + | action = "next-tab" | |
| 206 | + | handled = true | |
| 207 | + | case gdkKeyH: | |
| 208 | + | action = "previous-tab" | |
| 209 | + | handled = true | |
| 210 | + | case gdkKeyR: | |
| 211 | + | action = "rename-tab" | |
| 212 | + | handled = true | |
| 213 | + | case gdkKeyTab: | |
| 214 | + | action = "next-tab" | |
| 215 | + | handled = true | |
| 216 | + | case gdkKeyReturn: | |
| 217 | + | action = "confirm" | |
| 218 | + | handled = true | |
| 219 | + | case gdkKeyEscape: | |
| 220 | + | action = "cancel" | |
| 221 | + | handled = true | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Handle Shift+Tab for previous tab in tab mode | |
| 226 | + | if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) { | |
| 227 | + | action = "previous-tab" | |
| 228 | + | handled = true | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | if handled { | |
| 232 | + | log.Printf("[window-shortcuts] Tab mode action: %s", action) | |
| 233 | + | h.handleTabModeAction(action) | |
| 234 | + | return true // Consume event | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // Let all other keys propagate to WebView's keyboard bridge | |
| 239 | + | // This includes Ctrl+P, Ctrl+T, and all pane mode action keys | |
| 240 | + | return false | |
| 241 | + | }) | |
| 242 | + | ||
| 243 | + | gtkWindow.AddController(keyController) | |
| 244 | + | log.Printf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number") | |
| 245 | + | ||
| 84 | 246 | return nil | |
| 85 | 247 | } | |
| 86 | 248 | ||
| @@ -112,7 +274,7 @@ func (h *WindowShortcutHandler) handleCopyURL() { | |||
| 112 | 274 | script := ` | |
| 113 | 275 | (async function() { | |
| 114 | 276 | const toast = (message, type) => { | |
| 115 | - | document.dispatchEvent(new CustomEvent('dumber:showToast', { | |
| 277 | + | document.dispatchEvent(new CustomEvent('dumber:toast', { | |
| 116 | 278 | detail: { message, duration: 2000, type } | |
| 117 | 279 | })); | |
| 118 | 280 | }; | |
| @@ -176,6 +338,38 @@ func (h *WindowShortcutHandler) handlePrint() { | |||
| 176 | 338 | } | |
| 177 | 339 | } | |
| 178 | 340 | ||
| 341 | + | func (h *WindowShortcutHandler) handleReload() { | |
| 342 | + | h.mu.Lock() | |
| 343 | + | defer h.mu.Unlock() | |
| 344 | + | ||
| 345 | + | if h.app.activePane == nil || h.app.activePane.webView == nil { | |
| 346 | + | log.Printf("[window-shortcuts] No active pane for reload") | |
| 347 | + | return | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | log.Printf("[window-shortcuts] Reload page -> pane %p", h.app.activePane.webView) | |
| 351 | + | ||
| 352 | + | if err := h.app.activePane.webView.Reload(); err != nil { | |
| 353 | + | log.Printf("[window-shortcuts] Failed to reload page: %v", err) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func (h *WindowShortcutHandler) handleHardReload() { | |
| 358 | + | h.mu.Lock() | |
| 359 | + | defer h.mu.Unlock() | |
| 360 | + | ||
| 361 | + | if h.app.activePane == nil || h.app.activePane.webView == nil { | |
| 362 | + | log.Printf("[window-shortcuts] No active pane for hard reload") | |
| 363 | + | return | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | log.Printf("[window-shortcuts] Hard reload (bypass cache) -> pane %p", h.app.activePane.webView) | |
| 367 | + | ||
| 368 | + | if err := h.app.activePane.webView.ReloadBypassCache(); err != nil { | |
| 369 | + | log.Printf("[window-shortcuts] Failed to hard reload page: %v", err) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 179 | 373 | // handleZoomIn increases zoom level by 10% | |
| 180 | 374 | func (h *WindowShortcutHandler) handleZoomIn() { | |
| 181 | 375 | h.handleZoom("in", 1.1) | |
| @@ -367,11 +561,85 @@ func (h *WindowShortcutHandler) handleClosePane() { | |||
| 367 | 561 | } | |
| 368 | 562 | } | |
| 369 | 563 | ||
| 370 | - | // handleNewTab is a no-op handler for Ctrl+T | |
| 371 | - | // This prevents the default browser behavior and allows for future implementation | |
| 372 | - | func (h *WindowShortcutHandler) handleNewTab() { | |
| 373 | - | log.Printf("[window-shortcuts] Ctrl+T pressed (no-op - tabs not yet implemented)") | |
| 374 | - | // TODO: Implement new tab functionality | |
| 564 | + | // handlePaneMode enters pane mode for modal pane management. | |
| 565 | + | func (h *WindowShortcutHandler) handlePaneMode() { | |
| 566 | + | if h.app == nil || h.app.workspace == nil { | |
| 567 | + | log.Printf("[window-shortcuts] Cannot enter pane mode: workspace not available") | |
| 568 | + | return | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | log.Printf("[window-shortcuts] Entering pane mode") | |
| 572 | + | h.app.workspace.EnterPaneMode() | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // handleTabMode enters tab mode for modal tab management. | |
| 576 | + | func (h *WindowShortcutHandler) handleTabMode() { | |
| 577 | + | if h.app == nil || h.app.tabManager == nil { | |
| 578 | + | log.Printf("[window-shortcuts] Cannot enter tab mode: tab manager not available") | |
| 579 | + | return | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | log.Printf("[window-shortcuts] Entering tab mode") | |
| 583 | + | h.app.tabManager.EnterTabMode() | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | // handleNextTab switches to the next tab. | |
| 587 | + | func (h *WindowShortcutHandler) handleNextTab() { | |
| 588 | + | if h.app == nil || h.app.tabManager == nil { | |
| 589 | + | log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available") | |
| 590 | + | return | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | log.Printf("[window-shortcuts] Switching to next tab") | |
| 594 | + | if err := h.app.tabManager.NextTab(); err != nil { | |
| 595 | + | log.Printf("[window-shortcuts] Failed to switch to next tab: %v", err) | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // handlePrevTab switches to the previous tab. | |
| 600 | + | func (h *WindowShortcutHandler) handlePrevTab() { | |
| 601 | + | if h.app == nil || h.app.tabManager == nil { | |
| 602 | + | log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available") | |
| 603 | + | return | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | log.Printf("[window-shortcuts] Switching to previous tab") | |
| 607 | + | if err := h.app.tabManager.PreviousTab(); err != nil { | |
| 608 | + | log.Printf("[window-shortcuts] Failed to switch to previous tab: %v", err) | |
| 609 | + | } | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | // handleDirectTabSwitch switches to a specific tab by index (0-based). | |
| 613 | + | func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) { | |
| 614 | + | if h.app == nil || h.app.tabManager == nil { | |
| 615 | + | log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available") | |
| 616 | + | return | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | log.Printf("[window-shortcuts] Direct tab switch to index %d", index) | |
| 620 | + | if err := h.app.tabManager.SwitchToTab(index); err != nil { | |
| 621 | + | log.Printf("[window-shortcuts] Failed to switch to tab %d: %v", index, err) | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | // isTabModeActive checks if tab mode is currently active | |
| 626 | + | func (h *WindowShortcutHandler) isTabModeActive() bool { | |
| 627 | + | if h.app == nil || h.app.tabManager == nil { | |
| 628 | + | return false | |
| 629 | + | } | |
| 630 | + | return h.app.tabManager.IsTabModeActive() | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | |
| 634 | + | // Only processes actions when tab mode is active | |
| 635 | + | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | |
| 636 | + | if h.app == nil || h.app.tabManager == nil { | |
| 637 | + | return | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | // Tab mode is active, handle the action | |
| 641 | + | log.Printf("[window-shortcuts] Tab mode action: %s", action) | |
| 642 | + | h.app.tabManager.HandleTabAction(action) | |
| 375 | 643 | } | |
| 376 | 644 | ||
| 377 | 645 | // Cleanup releases resources | |
M internal/app/browser/workspace_css.go +174 -28
| @@ -36,6 +36,22 @@ func getStackTitleTextColor(isDark bool) string { | |||
| 36 | 36 | return "#333333" | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | // getStackTitleBorderColor returns a subtle border color slightly darker than the title background | |
| 40 | + | func getStackTitleBorderColor(isDark bool) string { | |
| 41 | + | if isDark { | |
| 42 | + | return "#353535" // Slightly darker than #404040 | |
| 43 | + | } | |
| 44 | + | return "#e5e5e5" // Slightly darker than #f0f0f0 | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // getActiveTabBg returns a distinct background color for the active tab | |
| 48 | + | func getActiveTabBg(isDark bool) string { | |
| 49 | + | if isDark { | |
| 50 | + | return "#707070" // Significantly lighter gray than inactive (#404040) - similar to stacked panes | |
| 51 | + | } | |
| 52 | + | return "#c0c0c0" // Significantly darker gray than inactive (#f0f0f0) - similar to stacked panes | |
| 53 | + | } | |
| 54 | + | ||
| 39 | 55 | // getActivePaneBorderColor returns the border color for active panes based on config and theme | |
| 40 | 56 | func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string { | |
| 41 | 57 | // Use configured border color if set | |
| @@ -55,30 +71,68 @@ func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool | |||
| 55 | 71 | return "#87CEEB" // Sky blue pastel for light theme | |
| 56 | 72 | } | |
| 57 | 73 | ||
| 74 | + | // getInactivePaneBorderColor returns the border color for inactive panes based on config and theme | |
| 75 | + | func getInactivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string { | |
| 76 | + | // Use configured border color if set | |
| 77 | + | if styling.InactiveBorderColor != "" { | |
| 78 | + | // Check if it's a GTK theme variable (starts with @) | |
| 79 | + | if strings.HasPrefix(styling.InactiveBorderColor, "@") { | |
| 80 | + | return styling.InactiveBorderColor | |
| 81 | + | } | |
| 82 | + | // Return the configured color as-is (could be hex, rgb, etc.) | |
| 83 | + | return styling.InactiveBorderColor | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Fallback to hardcoded colors based on theme | |
| 87 | + | if isDark { | |
| 88 | + | return "#333333" // Dark border for dark theme | |
| 89 | + | } | |
| 90 | + | return "#dddddd" // Light border for light theme | |
| 91 | + | } | |
| 92 | + | ||
| 58 | 93 | // generateWorkspaceCSS generates the complete CSS for workspace panes and stacked panes | |
| 59 | 94 | func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |
| 60 | 95 | cfg := config.Get() | |
| 61 | 96 | styling := cfg.Workspace.Styling | |
| 62 | 97 | ||
| 63 | 98 | // Get appropriate colors based on GTK theme preference | |
| 64 | - | var inactiveBorderColor, windowBackgroundColor string | |
| 99 | + | var windowBackgroundColor string | |
| 65 | 100 | isDark := webkit.PrefersDarkTheme() | |
| 66 | 101 | if isDark { | |
| 67 | - | inactiveBorderColor = "#333333" // Dark border for dark theme | |
| 68 | 102 | windowBackgroundColor = "#2b2b2b" // Dark window background | |
| 69 | 103 | } else { | |
| 70 | - | inactiveBorderColor = "#dddddd" // Light border for light theme | |
| 71 | 104 | windowBackgroundColor = "#ffffff" // Light window background | |
| 72 | 105 | } | |
| 73 | 106 | ||
| 74 | 107 | activeBorderColor := getActivePaneBorderColor(styling, isDark) | |
| 108 | + | inactiveBorderColor := getInactivePaneBorderColor(styling, isDark) | |
| 75 | 109 | ||
| 76 | - | // Pane mode border color - use configured value or fallback to orange | |
| 110 | + | // Pane mode border color - use configured value or fallback to blue | |
| 77 | 111 | paneModeColor := styling.PaneModeBorderColor | |
| 78 | 112 | if paneModeColor == "" { | |
| 79 | - | paneModeColor = "#FFA500" // Fallback to orange if not configured | |
| 113 | + | paneModeColor = "#4A90E2" // Fallback to blue if not configured | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Tab mode border color - use configured value or fallback to orange | |
| 117 | + | tabModeColor := styling.TabModeBorderColor | |
| 118 | + | if tabModeColor == "" { | |
| 119 | + | tabModeColor = "#FFA500" // Fallback to orange if not configured | |
| 80 | 120 | } | |
| 81 | 121 | ||
| 122 | + | // Stacked title border - subtle separator between titles | |
| 123 | + | stackedTitleBorder := fmt.Sprintf("1px solid %s", getStackTitleBorderColor(isDark)) | |
| 124 | + | ||
| 125 | + | // UI Scale calculations for title bar elements (base values at 1.0 scale) | |
| 126 | + | uiScale := styling.UIScale | |
| 127 | + | if uiScale <= 0 { | |
| 128 | + | uiScale = 1.0 // Fallback to default if invalid | |
| 129 | + | } | |
| 130 | + | titleFontSize := int(12 * uiScale) // Base: 12px | |
| 131 | + | titlePaddingVertical := int(4 * uiScale) // Base: 4px | |
| 132 | + | titlePaddingHorizontal := int(8 * uiScale) // Base: 8px | |
| 133 | + | titleMinHeight := int(24 * uiScale) // Base: 24px | |
| 134 | + | faviconMargin := int(4 * uiScale) // Base: 4px | |
| 135 | + | ||
| 82 | 136 | css := fmt.Sprintf(`window { | |
| 83 | 137 | background-color: %s; | |
| 84 | 138 | padding: 0; | |
| @@ -96,16 +150,21 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 96 | 150 | transition: margin 150ms ease-in-out; | |
| 97 | 151 | } | |
| 98 | 152 | ||
| 99 | - | /* Base pane styling - subtle border for inactive panes */ | |
| 153 | + | /* Base pane styling - configurable inactive borders */ | |
| 100 | 154 | .workspace-pane, .stacked-pane-container { | |
| 101 | - | border: 2px solid %s; | |
| 155 | + | border-width: %dpx; | |
| 156 | + | border-style: solid; | |
| 157 | + | border-color: %s; | |
| 102 | 158 | border-radius: %dpx; | |
| 103 | - | transition: border-color %dms ease-in-out; | |
| 104 | 159 | margin: 0; | |
| 160 | + | transition-property: border-color; | |
| 161 | + | transition-duration: %dms; | |
| 162 | + | transition-timing-function: ease-in-out; | |
| 105 | 163 | } | |
| 106 | 164 | ||
| 107 | 165 | /* Active pane border styling */ | |
| 108 | 166 | .workspace-pane-active { | |
| 167 | + | border-width: %dpx; | |
| 109 | 168 | border-color: %s; | |
| 110 | 169 | } | |
| 111 | 170 | ||
| @@ -114,16 +173,17 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 114 | 173 | background-color: %s; | |
| 115 | 174 | } | |
| 116 | 175 | ||
| 117 | - | /* Stacked pane containers never get active border - focus is shown via WebKit */ | |
| 176 | + | /* Stacked pane containers keep inactive border when active */ | |
| 118 | 177 | .stacked-pane-container.workspace-pane-active { | |
| 178 | + | border-width: %dpx; | |
| 119 | 179 | border-color: %s; | |
| 120 | 180 | } | |
| 121 | 181 | ||
| 122 | 182 | .stacked-pane-title { | |
| 123 | 183 | background-color: %s; | |
| 124 | - | border-bottom: 1px solid %s; | |
| 125 | - | padding: 4px 8px; | |
| 126 | - | min-height: 24px; | |
| 184 | + | border-bottom: %s; | |
| 185 | + | padding: %dpx %dpx; | |
| 186 | + | min-height: %dpx; | |
| 127 | 187 | transition: background-color %dms ease-in-out; | |
| 128 | 188 | } | |
| 129 | 189 | ||
| @@ -132,13 +192,13 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 132 | 192 | } | |
| 133 | 193 | ||
| 134 | 194 | .stacked-pane-title-text { | |
| 135 | - | font-size: 12px; | |
| 195 | + | font-size: %dpx; | |
| 136 | 196 | color: %s; | |
| 137 | 197 | font-weight: 500; | |
| 138 | 198 | } | |
| 139 | 199 | ||
| 140 | 200 | .stacked-pane-favicon { | |
| 141 | - | margin-right: 4px; | |
| 201 | + | margin-right: %dpx; | |
| 142 | 202 | } | |
| 143 | 203 | ||
| 144 | 204 | .stacked-pane-active { | |
| @@ -147,21 +207,107 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 147 | 207 | ||
| 148 | 208 | .stacked-pane-collapsed { | |
| 149 | 209 | /* Collapsed panes are hidden - handled in code via widget visibility */ | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /* Tab bar styling */ | |
| 213 | + | .tab-bar { | |
| 214 | + | background-color: %s; | |
| 215 | + | border-top: 2px solid %s; | |
| 216 | + | padding: 0; | |
| 217 | + | min-height: %dpx; | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /* Tab mode active - window background changes to border color (like pane mode) */ | |
| 221 | + | window.tab-mode-active { | |
| 222 | + | background-color: %s; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /* Tab button styling - matches stacked pane titles */ | |
| 226 | + | button.tab-button { | |
| 227 | + | background-color: %s; | |
| 228 | + | background-image: none; | |
| 229 | + | border: none; | |
| 230 | + | border-right: 1px solid %s; | |
| 231 | + | border-radius: 0; | |
| 232 | + | padding: %dpx %dpx; | |
| 233 | + | transition: background-color %dms ease-in-out; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | button.tab-button:hover { | |
| 237 | + | background-color: %s; | |
| 238 | + | background-image: none; | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | button.tab-button.tab-button-active { | |
| 242 | + | background-color: %s; | |
| 243 | + | background-image: none; | |
| 244 | + | border: none; | |
| 245 | + | border-right: 1px solid %s; | |
| 246 | + | border-radius: 0; | |
| 247 | + | font-weight: 600; | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /* Tab title text */ | |
| 251 | + | .tab-title { | |
| 252 | + | font-size: %dpx; | |
| 253 | + | color: %s; | |
| 254 | + | font-weight: 500; | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /* Tab content area */ | |
| 258 | + | .tab-content-area { | |
| 259 | + | background-color: %s; | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | /* Tab workspace containers */ | |
| 263 | + | .tab-workspace-container { | |
| 264 | + | background-color: %s; | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /* Pulse animation for tab mode */ | |
| 268 | + | @keyframes pulse-border { | |
| 269 | + | 0%%, 100%% { opacity: 1.0; } | |
| 270 | + | 50%% { opacity: 0.6; } | |
| 150 | 271 | }`, | |
| 151 | - | windowBackgroundColor, // window background | |
| 152 | - | paneModeColor, // window.pane-mode-active background (border color) | |
| 153 | - | windowBackgroundColor, // paned, box background | |
| 154 | - | inactiveBorderColor, // base pane border color (inactive) | |
| 155 | - | styling.BorderRadius, // base pane border radius | |
| 156 | - | styling.TransitionDuration, // base pane border transition | |
| 157 | - | activeBorderColor, // workspace-pane-active border color | |
| 158 | - | windowBackgroundColor, // stacked-pane-container background | |
| 159 | - | inactiveBorderColor, // stacked-pane-container.workspace-pane-active border color (keep subtle) | |
| 160 | - | getStackTitleBg(isDark), // stacked-pane-title background | |
| 161 | - | inactiveBorderColor, // stacked-pane-title border-bottom | |
| 162 | - | styling.TransitionDuration, // stacked-pane-title transition | |
| 163 | - | getStackTitleHoverBg(isDark), // stacked-pane-title:hover background | |
| 164 | - | getStackTitleTextColor(isDark), // stacked-pane-title-text color | |
| 272 | + | windowBackgroundColor, // window background | |
| 273 | + | paneModeColor, // window.pane-mode-active background (border color) | |
| 274 | + | windowBackgroundColor, // paned, box background | |
| 275 | + | styling.InactiveBorderWidth, // base pane border-width (inactive) | |
| 276 | + | inactiveBorderColor, // base pane border-color (inactive) | |
| 277 | + | styling.BorderRadius, // base pane border radius | |
| 278 | + | styling.TransitionDuration, // transition-duration | |
| 279 | + | styling.BorderWidth, // workspace-pane-active border-width | |
| 280 | + | activeBorderColor, // workspace-pane-active border color | |
| 281 | + | windowBackgroundColor, // stacked-pane-container background | |
| 282 | + | styling.InactiveBorderWidth, // stacked-pane-container.active border-width | |
| 283 | + | inactiveBorderColor, // stacked-pane-container.active border-color | |
| 284 | + | getStackTitleBg(isDark), // stacked-pane-title background | |
| 285 | + | stackedTitleBorder, // stacked-pane-title border-bottom | |
| 286 | + | titlePaddingVertical, // stacked-pane-title padding vertical | |
| 287 | + | titlePaddingHorizontal, // stacked-pane-title padding horizontal | |
| 288 | + | titleMinHeight, // stacked-pane-title min-height | |
| 289 | + | styling.TransitionDuration, // stacked-pane-title transition | |
| 290 | + | getStackTitleHoverBg(isDark), // stacked-pane-title:hover background | |
| 291 | + | titleFontSize, // stacked-pane-title-text font-size | |
| 292 | + | getStackTitleTextColor(isDark), // stacked-pane-title-text color | |
| 293 | + | faviconMargin, // stacked-pane-favicon margin-right | |
| 294 | + | // Tab bar styles | |
| 295 | + | windowBackgroundColor, // tab-bar background | |
| 296 | + | getStackTitleBorderColor(isDark), // tab-bar border-top | |
| 297 | + | int(32 * uiScale), // tab-bar min-height | |
| 298 | + | tabModeColor, // window.tab-mode-active .tab-bar border-top-color (orange, distinct from pane mode blue) | |
| 299 | + | getStackTitleBg(isDark), // tab-button background (same as stacked title) | |
| 300 | + | getStackTitleBorderColor(isDark), // tab-button border-right | |
| 301 | + | titlePaddingVertical, // tab-button padding vertical | |
| 302 | + | titlePaddingHorizontal, // tab-button padding horizontal | |
| 303 | + | styling.TransitionDuration, // tab-button transition | |
| 304 | + | getStackTitleHoverBg(isDark), // tab-button:hover background | |
| 305 | + | getActiveTabBg(isDark), // tab-button-active background (much more visible) | |
| 306 | + | getStackTitleBorderColor(isDark), // tab-button-active border-right | |
| 307 | + | titleFontSize, // tab-title font-size | |
| 308 | + | getStackTitleTextColor(isDark), // tab-title color | |
| 309 | + | windowBackgroundColor, // tab-content-area background | |
| 310 | + | windowBackgroundColor, // tab-workspace-container background | |
| 165 | 311 | ) | |
| 166 | 312 | ||
| 167 | 313 | return css | |
M internal/app/browser/workspace_manager.go +259 -14
| @@ -73,6 +73,34 @@ type WorkspaceManager struct { | |||
| 73 | 73 | pendingIdle map[uintptr][]*paneNode | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | // resolveWindow ensures the workspace has a reference to the GTK window. | |
| 77 | + | func (wm *WorkspaceManager) resolveWindow() *webkit.Window { | |
| 78 | + | if wm == nil { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | if wm.window != nil { | |
| 83 | + | return wm.window | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | if wm.app != nil { | |
| 87 | + | if wm.app.tabManager != nil { | |
| 88 | + | if win := wm.app.tabManager.GetWindow(); win != nil { | |
| 89 | + | wm.window = win | |
| 90 | + | return win | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | if wm.app.webView != nil { | |
| 94 | + | if win := wm.app.webView.Window(); win != nil { | |
| 95 | + | wm.window = win | |
| 96 | + | return win | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 76 | 104 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| 77 | 105 | ||
| 78 | 106 | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | |
| @@ -149,7 +177,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 149 | 177 | handler.SetWorkspaceObserver(manager) | |
| 150 | 178 | } | |
| 151 | 179 | ||
| 152 | - | app.workspace = manager | |
| 180 | + | // CRITICAL: Register navigation handler (including pane mode) for the root pane | |
| 181 | + | // This must happen here because when creating the first tab, app.workspace is nil | |
| 182 | + | // in createPaneForView, so it doesn't register the handler | |
| 183 | + | manager.RegisterNavigationHandler(rootPane.webView) | |
| 184 | + | ||
| 185 | + | // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager. | |
| 186 | + | // TabManager sets app.workspace during tab switching (including initial tab creation). | |
| 187 | + | // This prevents workspaces from interfering with each other when multiple tabs exist. | |
| 153 | 188 | ||
| 154 | 189 | // Configure debug settings from app config | |
| 155 | 190 | if app.config != nil { | |
| @@ -414,15 +449,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||
| 414 | 449 | return wm.FocusNeighbor(direction) | |
| 415 | 450 | }) | |
| 416 | 451 | ||
| 417 | - | // Register pane mode callbacks for Ctrl+P and pane mode actions | |
| 452 | + | // Register pane mode callbacks for Ctrl+P and pane mode action keys | |
| 418 | 453 | webView.RegisterPaneModeHandler(func(action string) bool { | |
| 419 | 454 | if wm == nil { | |
| 420 | 455 | return false | |
| 421 | 456 | } | |
| 422 | 457 | ||
| 423 | 458 | if action == "enter" { | |
| 424 | - | wm.EnterPaneMode() | |
| 425 | - | return true | |
| 459 | + | // CRITICAL FIX: Use app.workspace instead of closure's wm | |
| 460 | + | // This ensures Ctrl+P always targets the ACTIVE tab's workspace | |
| 461 | + | if wm.app != nil && wm.app.workspace != nil { | |
| 462 | + | wm.app.workspace.EnterPaneMode() | |
| 463 | + | return true | |
| 464 | + | } | |
| 465 | + | return false | |
| 426 | 466 | } | |
| 427 | 467 | ||
| 428 | 468 | if action == "exit" { | |
| @@ -438,18 +478,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||
| 438 | 478 | return false // No mode active, pass through to DOM | |
| 439 | 479 | } | |
| 440 | 480 | ||
| 441 | - | // For other actions, only handle if pane mode is active | |
| 442 | - | wm.paneMutex.Lock() | |
| 443 | - | isActive := wm.paneModeActive | |
| 444 | - | wm.paneMutex.Unlock() | |
| 481 | + | // For other actions, use active workspace (same pattern as "enter") | |
| 482 | + | // This ensures action keys work on the correct tab | |
| 483 | + | if wm.app != nil && wm.app.workspace != nil { | |
| 484 | + | // Check if pane mode is active on the active workspace | |
| 485 | + | activeWs := wm.app.workspace | |
| 486 | + | activeWs.paneMutex.Lock() | |
| 487 | + | isActive := activeWs.paneModeActive | |
| 488 | + | activeWs.paneMutex.Unlock() | |
| 489 | + | ||
| 490 | + | if !isActive { | |
| 491 | + | return false // Not in pane mode, allow normal behavior | |
| 492 | + | } | |
| 445 | 493 | ||
| 446 | - | if !isActive { | |
| 447 | - | return false // Not in pane mode, allow normal behavior | |
| 494 | + | // Pane mode is active, handle the action on active workspace | |
| 495 | + | activeWs.HandlePaneAction(action) | |
| 496 | + | return true | |
| 448 | 497 | } | |
| 449 | - | ||
| 450 | - | // Pane mode is active, handle the action | |
| 451 | - | wm.HandlePaneAction(action) | |
| 452 | - | return true | |
| 498 | + | return false | |
| 453 | 499 | }, func() bool { | |
| 454 | 500 | // Check if pane mode is currently active | |
| 455 | 501 | wm.paneMutex.Lock() | |
| @@ -457,5 +503,204 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||
| 457 | 503 | return wm.paneModeActive | |
| 458 | 504 | }) | |
| 459 | 505 | ||
| 506 | + | // Register middle-click link handler to open links in new panes | |
| 507 | + | webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool { | |
| 508 | + | if wm == nil { | |
| 509 | + | return false | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL) | |
| 513 | + | ||
| 514 | + | // Get the node for this webview | |
| 515 | + | node := wm.GetNodeForWebView(webView) | |
| 516 | + | if node == nil { | |
| 517 | + | log.Printf("[workspace] Cannot find node for middle-clicked link") | |
| 518 | + | return false | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | // Get behavior from BlankTargetBehavior config | |
| 522 | + | behavior := "stacked" // Default | |
| 523 | + | if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" { | |
| 524 | + | behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior) | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | var newNode *paneNode | |
| 528 | + | var err error | |
| 529 | + | ||
| 530 | + | switch behavior { | |
| 531 | + | case "stacked": | |
| 532 | + | // Stack the current pane - this also creates a new pane in the stack | |
| 533 | + | log.Printf("[workspace] Using stacked behavior for gesture link") | |
| 534 | + | newNode, err = wm.stackedPaneManager.StackPane(node) | |
| 535 | + | if err != nil { | |
| 536 | + | log.Printf("[workspace] Failed to stack pane for gesture link: %v", err) | |
| 537 | + | return false | |
| 538 | + | } | |
| 539 | + | // newNode is the new pane that was added to the stack | |
| 540 | + | log.Printf("[workspace] StackPane created new pane in stack: %p", newNode) | |
| 541 | + | ||
| 542 | + | case "split": | |
| 543 | + | // Regular split behavior | |
| 544 | + | log.Printf("[workspace] Using split behavior for gesture link") | |
| 545 | + | direction := "right" | |
| 546 | + | if wm.app.config.Workspace.Popups.Placement != "" { | |
| 547 | + | direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 548 | + | } | |
| 549 | + | newNode, err = wm.SplitPane(node, direction) | |
| 550 | + | ||
| 551 | + | case "tabbed": | |
| 552 | + | // Tabbed not yet implemented, fall back to split | |
| 553 | + | log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split") | |
| 554 | + | direction := "right" | |
| 555 | + | if wm.app.config.Workspace.Popups.Placement != "" { | |
| 556 | + | direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 557 | + | } | |
| 558 | + | newNode, err = wm.SplitPane(node, direction) | |
| 559 | + | ||
| 560 | + | default: | |
| 561 | + | // Unknown behavior, fall back to split | |
| 562 | + | log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior) | |
| 563 | + | direction := "right" | |
| 564 | + | if wm.app.config.Workspace.Popups.Placement != "" { | |
| 565 | + | direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 566 | + | } | |
| 567 | + | newNode, err = wm.SplitPane(node, direction) | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | if err != nil { | |
| 571 | + | log.Printf("[workspace] Failed to handle gesture link: %v", err) | |
| 572 | + | return false | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // Navigate the new pane to the URL | |
| 576 | + | if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil { | |
| 577 | + | if err := newNode.pane.WebView().LoadURL(linkURL); err != nil { | |
| 578 | + | log.Printf("[workspace] Failed to load URL in new pane: %v", err) | |
| 579 | + | return false | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // Set the new pane as active | |
| 583 | + | wm.SetActivePane(newNode, SourceProgrammatic) | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | return true // Indicate we handled the click | |
| 587 | + | }) | |
| 588 | + | ||
| 460 | 589 | log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID()) | |
| 461 | 590 | } | |
| 591 | + | ||
| 592 | + | // GetAllPanes returns all BrowserPanes in this workspace. | |
| 593 | + | // This is used by TabManager to update app-level panes reference. | |
| 594 | + | func (wm *WorkspaceManager) GetAllPanes() []*BrowserPane { | |
| 595 | + | panes := make([]*BrowserPane, 0, len(wm.viewToNode)) | |
| 596 | + | for _, node := range wm.viewToNode { | |
| 597 | + | if node.pane != nil { | |
| 598 | + | panes = append(panes, node.pane) | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | return panes | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | // GetActivePane returns the currently active BrowserPane in this workspace. | |
| 605 | + | // This is used by TabManager to update app-level activePane reference. | |
| 606 | + | func (wm *WorkspaceManager) GetActivePane() *BrowserPane { | |
| 607 | + | node := wm.GetActiveNode() | |
| 608 | + | if node != nil && node.pane != nil { | |
| 609 | + | return node.pane | |
| 610 | + | } | |
| 611 | + | return nil | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | // RestoreFocus restores focus to the active pane in this workspace. | |
| 615 | + | // Called when switching to a tab to ensure the correct pane has focus. | |
| 616 | + | func (wm *WorkspaceManager) RestoreFocus() { | |
| 617 | + | log.Printf("[workspace] RestoreFocus: workspace %p", wm) | |
| 618 | + | ||
| 619 | + | // Get the active pane and explicitly grab GTK focus | |
| 620 | + | activeNode := wm.GetActiveNode() | |
| 621 | + | if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil { | |
| 622 | + | // Explicitly grab GTK focus to this WebView | |
| 623 | + | // This ensures keyboard events go to the active tab's WebView | |
| 624 | + | // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread | |
| 625 | + | webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget()) | |
| 626 | + | log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm) | |
| 627 | + | ||
| 628 | + | // Also request focus via FSM | |
| 629 | + | if wm.focusStateMachine != nil { | |
| 630 | + | wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic) | |
| 631 | + | } | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | // IsInActiveTab returns whether this workspace is the app's current active workspace. | |
| 636 | + | // This is checked dynamically by comparing with app.workspace (updated during tab switching). | |
| 637 | + | func (wm *WorkspaceManager) IsInActiveTab() bool { | |
| 638 | + | if wm.app == nil { | |
| 639 | + | return false | |
| 640 | + | } | |
| 641 | + | // The active workspace is the one currently assigned to app.workspace | |
| 642 | + | isActive := wm.app.workspace == wm | |
| 643 | + | log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive) | |
| 644 | + | return isActive | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | // ClearFocus removes GTK focus from all panes in this workspace. | |
| 648 | + | // This prevents inactive tab's WebViews from catching keyboard events. | |
| 649 | + | func (wm *WorkspaceManager) ClearFocus() { | |
| 650 | + | log.Printf("[workspace] ClearFocus: workspace %p", wm) | |
| 651 | + | // No state to clear - IsInActiveTab() checks dynamically | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | // GetRootWidget returns the root GTK widget for this workspace. | |
| 655 | + | // This is the container that should be added to the tab's content area. | |
| 656 | + | func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter { | |
| 657 | + | if wm.root != nil && wm.root.container != nil { | |
| 658 | + | return wm.root.container | |
| 659 | + | } | |
| 660 | + | return nil | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | // setRootContainer attaches a widget as the workspace root to TabManager.ContentArea. | |
| 664 | + | // The tab system is always present (even with a single tab), so this always uses ContentArea. | |
| 665 | + | func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) { | |
| 666 | + | if container == nil { | |
| 667 | + | return | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 671 | + | log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized") | |
| 672 | + | return | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 676 | + | if !ok || contentBox == nil { | |
| 677 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 678 | + | return | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | contentBox.Append(container) | |
| 682 | + | webkit.WidgetSetVisible(container, true) | |
| 683 | + | log.Printf("[workspace] Attached root container %p to ContentArea", container) | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | // clearRootContainer removes the current workspace root from TabManager.ContentArea. | |
| 687 | + | // The tab system is always present (even with a single tab), so this always uses ContentArea. | |
| 688 | + | func (wm *WorkspaceManager) clearRootContainer() { | |
| 689 | + | if wm.root == nil || wm.root.container == nil { | |
| 690 | + | return | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 694 | + | log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized") | |
| 695 | + | return | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 699 | + | if !ok || contentBox == nil { | |
| 700 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 701 | + | return | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | contentBox.Remove(wm.root.container) | |
| 705 | + | log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container) | |
| 706 | + | } | |
M internal/app/browser/workspace_navigation.go +66 -11
| @@ -208,13 +208,15 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | 210 | if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil { | |
| 211 | + | log.Printf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction) | |
| 211 | 212 | wm.SetActivePane(neighbor, SourceKeyboard) | |
| 212 | 213 | return true | |
| 213 | 214 | } | |
| 214 | 215 | ||
| 215 | - | curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container) | |
| 216 | + | curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused) | |
| 216 | 217 | cx := float64(curX) + float64(curWidth)/2.0 | |
| 217 | 218 | cy := float64(curY) + float64(curHeight)/2.0 | |
| 219 | + | log.Printf("[workspace] focusAdjacent: current pane center=(%.0f, %.0f) direction=%s", cx, cy, direction) | |
| 218 | 220 | ||
| 219 | 221 | leaves := wm.collectLeaves() | |
| 220 | 222 | bestScore := math.MaxFloat64 | |
| @@ -226,7 +228,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 226 | 228 | continue | |
| 227 | 229 | } | |
| 228 | 230 | ||
| 229 | - | x, y, width, height := webkit.WidgetGetAllocation(candidate.container) | |
| 231 | + | x, y, width, height := wm.getNavigationAllocation(candidate) | |
| 230 | 232 | tx := float64(x) + float64(width)/2.0 | |
| 231 | 233 | ty := float64(y) + float64(height)/2.0 | |
| 232 | 234 | ||
| @@ -247,18 +249,18 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 247 | 249 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 248 | 250 | case DirectionUp: | |
| 249 | 251 | if dy >= -focusEpsilon { | |
| 252 | + | debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not above)", candidate.container, tx, ty, dy)) | |
| 250 | 253 | continue | |
| 251 | 254 | } | |
| 252 | 255 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 256 | + | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score)) | |
| 253 | 257 | case DirectionDown: | |
| 254 | 258 | if dy <= focusEpsilon { | |
| 259 | + | debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not below)", candidate.container, tx, ty, dy)) | |
| 255 | 260 | continue | |
| 256 | 261 | } | |
| 257 | 262 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | if direction == DirectionUp || direction == DirectionDown { | |
| 261 | - | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score)) | |
| 263 | + | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score)) | |
| 262 | 264 | } | |
| 263 | 265 | ||
| 264 | 266 | if score < bestScore { | |
| @@ -268,39 +270,83 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 268 | 270 | } | |
| 269 | 271 | ||
| 270 | 272 | if best != nil { | |
| 273 | + | bx, by, _, _ := webkit.WidgetGetAllocation(best.container) | |
| 274 | + | log.Printf("[workspace] focusAdjacent: found best candidate at pos=(%d,%d) for direction=%s", bx, by, direction) | |
| 271 | 275 | wm.SetActivePane(best, SourceKeyboard) | |
| 272 | 276 | return true | |
| 273 | 277 | } | |
| 274 | 278 | ||
| 275 | 279 | if len(debugCandidates) > 0 { | |
| 276 | - | log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%p candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; ")) | |
| 280 | + | log.Printf("[workspace] focusAdjacent: NO candidate found for direction=%s current=%p center=(%.0f,%.0f) candidates=[%s]", direction, currentFocused.container, cx, cy, strings.Join(debugCandidates, "; ")) | |
| 277 | 281 | } | |
| 278 | 282 | return false | |
| 279 | 283 | } | |
| 280 | 284 | ||
| 285 | + | // getNavigationAllocation returns window-absolute coordinates for navigation geometric checks. | |
| 286 | + | // Uses ComputeBounds relative to the window root to get actual screen positions. | |
| 287 | + | // For panes inside a stack, returns the stack wrapper's bounds. | |
| 288 | + | // For regular panes, returns the pane's own bounds. | |
| 289 | + | func (wm *WorkspaceManager) getNavigationAllocation(node *paneNode) (x, y, width, height int) { | |
| 290 | + | if node == nil || node.container == nil { | |
| 291 | + | return 0, 0, 0, 0 | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // If this pane is inside a stack, use the stack wrapper's window bounds | |
| 295 | + | if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil { | |
| 296 | + | return webkit.WidgetGetWindowBounds(node.parent.stackWrapper) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // For regular panes or stack containers themselves, use their own window bounds | |
| 300 | + | return webkit.WidgetGetWindowBounds(node.container) | |
| 301 | + | } | |
| 302 | + | ||
| 281 | 303 | // structuralNeighbor finds neighbors based on the tree structure rather than geometry | |
| 282 | 304 | func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode { | |
| 283 | 305 | if node == nil || node.container == nil { | |
| 284 | 306 | return nil | |
| 285 | 307 | } | |
| 286 | 308 | ||
| 287 | - | refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container) | |
| 309 | + | refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node) | |
| 288 | 310 | cx := float64(refX) + float64(refWidth)/2.0 | |
| 289 | 311 | cy := float64(refY) + float64(refHeight)/2.0 | |
| 290 | 312 | axisVertical := direction == DirectionUp || direction == DirectionDown | |
| 291 | 313 | ||
| 292 | - | for parent := node.parent; parent != nil; parent = parent.parent { | |
| 314 | + | log.Printf("[workspace] structuralNeighbor: node=%p pos=(%d,%d) direction=%s", node.container, refX, refY, direction) | |
| 315 | + | ||
| 316 | + | // Start from the node's parent, but skip stack containers for vertical navigation | |
| 317 | + | // Stack containers are transparent to vertical navigation - we want to navigate | |
| 318 | + | // from stack to external panes, not within the stack (that's handled by NavigateStack) | |
| 319 | + | startParent := node.parent | |
| 320 | + | if startParent != nil && startParent.isStacked && axisVertical { | |
| 321 | + | log.Printf("[workspace] structuralNeighbor: skipping stack container parent, using stack's parent instead") | |
| 322 | + | node = startParent // Treat the stack container as the navigation node | |
| 323 | + | startParent = startParent.parent | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | for parent := startParent; parent != nil; parent = parent.parent { | |
| 327 | + | isLeft := parent.left == node | |
| 328 | + | isRight := parent.right == node | |
| 329 | + | log.Printf("[workspace] structuralNeighbor: checking parent orientation=%v isLeft=%v isRight=%v", parent.orientation, isLeft, isRight) | |
| 330 | + | ||
| 293 | 331 | switch direction { | |
| 294 | 332 | case DirectionUp: | |
| 295 | 333 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node { | |
| 334 | + | log.Printf("[workspace] structuralNeighbor: DirectionUp - we are RIGHT child, looking in LEFT subtree") | |
| 296 | 335 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 336 | + | lx, ly, _, _ := webkit.WidgetGetAllocation(leaf.container) | |
| 337 | + | log.Printf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly) | |
| 297 | 338 | return leaf | |
| 298 | 339 | } | |
| 299 | 340 | } | |
| 300 | 341 | case DirectionDown: | |
| 301 | 342 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node { | |
| 343 | + | log.Printf("[workspace] structuralNeighbor: DirectionDown - we are LEFT child, looking in RIGHT subtree") | |
| 302 | 344 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 345 | + | lx, ly, _, _ := wm.getNavigationAllocation(leaf) | |
| 346 | + | log.Printf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly) | |
| 303 | 347 | return leaf | |
| 348 | + | } else { | |
| 349 | + | log.Printf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown") | |
| 304 | 350 | } | |
| 305 | 351 | } | |
| 306 | 352 | case DirectionLeft: | |
| @@ -323,7 +369,8 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) | |||
| 323 | 369 | ||
| 324 | 370 | // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction | |
| 325 | 371 | func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode { | |
| 326 | - | leaves := wm.collectLeavesFrom(node) | |
| 372 | + | leaves := wm.collectLeavesFromWithDirection(node, direction) | |
| 373 | + | log.Printf("[workspace] closestLeafFromSubtree: found %d leaves for direction=%s from cx=%.0f cy=%.0f", len(leaves), direction, cx, cy) | |
| 327 | 374 | bestScore := math.MaxFloat64 | |
| 328 | 375 | var best *paneNode | |
| 329 | 376 | for _, leaf := range leaves { | |
| @@ -331,44 +378,52 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6 | |||
| 331 | 378 | continue | |
| 332 | 379 | } | |
| 333 | 380 | ||
| 334 | - | x, y, width, height := webkit.WidgetGetAllocation(leaf.container) | |
| 381 | + | x, y, width, height := wm.getNavigationAllocation(leaf) | |
| 335 | 382 | tx := float64(x) + float64(width)/2.0 | |
| 336 | 383 | ty := float64(y) + float64(height)/2.0 | |
| 337 | 384 | dx := tx - cx | |
| 338 | 385 | dy := ty - cy | |
| 386 | + | log.Printf("[workspace] closestLeafFromSubtree: leaf=%p pos=(%d,%d) center=(%.0f,%.0f) dx=%.0f dy=%.0f", leaf.container, x, y, tx, ty, dx, dy) | |
| 339 | 387 | var score float64 | |
| 340 | 388 | switch direction { | |
| 341 | 389 | case DirectionLeft: | |
| 342 | 390 | if dx >= -focusEpsilon { | |
| 391 | + | log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx) | |
| 343 | 392 | continue | |
| 344 | 393 | } | |
| 345 | 394 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 346 | 395 | case DirectionRight: | |
| 347 | 396 | if dx <= focusEpsilon { | |
| 397 | + | log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx) | |
| 348 | 398 | continue | |
| 349 | 399 | } | |
| 350 | 400 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 351 | 401 | case DirectionUp: | |
| 352 | 402 | if dy >= -focusEpsilon { | |
| 403 | + | log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy) | |
| 353 | 404 | continue | |
| 354 | 405 | } | |
| 355 | 406 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 356 | 407 | case DirectionDown: | |
| 357 | 408 | if dy <= focusEpsilon { | |
| 409 | + | log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy) | |
| 358 | 410 | continue | |
| 359 | 411 | } | |
| 360 | 412 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 361 | 413 | default: | |
| 362 | 414 | continue | |
| 363 | 415 | } | |
| 416 | + | log.Printf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score) | |
| 364 | 417 | if score < bestScore { | |
| 365 | 418 | bestScore = score | |
| 366 | 419 | best = leaf | |
| 367 | 420 | } | |
| 368 | 421 | } | |
| 369 | 422 | if best == nil { | |
| 423 | + | log.Printf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback") | |
| 370 | 424 | return wm.boundaryFallback(node, direction) | |
| 371 | 425 | } | |
| 426 | + | log.Printf("[workspace] closestLeafFromSubtree: returning best=%p", best.container) | |
| 372 | 427 | return best | |
| 373 | 428 | } | |
| 374 | 429 | ||
M internal/app/browser/workspace_navigation_test.go +76 -0
| @@ -144,3 +144,79 @@ func TestCaseSensitiveDirections(t *testing.T) { | |||
| 144 | 144 | }) | |
| 145 | 145 | } | |
| 146 | 146 | } | |
| 147 | + | ||
| 148 | + | // TestNavigationCoordinateBug documents the coordinate space bug in navigation | |
| 149 | + | // | |
| 150 | + | // Bug Description: | |
| 151 | + | // When comparing positions for geometric navigation, the code mixes two coordinate spaces: | |
| 152 | + | // - Normal panes: return window-absolute coordinates from WidgetGetAllocation() | |
| 153 | + | // - Stacked panes: return stack-box-relative coordinates from WidgetGetAllocation() | |
| 154 | + | // | |
| 155 | + | // This causes navigation to fail because we're comparing coordinates in different spaces. | |
| 156 | + | // | |
| 157 | + | // Real-world example from logs: | |
| 158 | + | // Normal pane (top half): center=(957, 532) - window coords | |
| 159 | + | // Stack pane (bottom): center=(955, 511) - relative to stack box! | |
| 160 | + | // Result: dy=-21 → SKIPPED as "not below" | |
| 161 | + | // | |
| 162 | + | // The fix: Always use stack wrapper allocation for panes inside stacks. | |
| 163 | + | func TestNavigationCoordinateBug(t *testing.T) { | |
| 164 | + | // Simulate the coordinate math from real user scenario | |
| 165 | + | // Window: 1914x1064, vertical split: normal pane (top) + stack (bottom) | |
| 166 | + | ||
| 167 | + | t.Run("BugValidation_CoordinateMixing", func(t *testing.T) { | |
| 168 | + | // Normal pane occupies top half - window-absolute coords | |
| 169 | + | normalCX, normalCY := 957.0, 266.0 | |
| 170 | + | ||
| 171 | + | // Stack pane child returns RELATIVE coordinates (bug!) | |
| 172 | + | // Its allocation is relative to stack box at y=532 | |
| 173 | + | stackChildCX, stackChildCY := 957.0, 133.0 // Relative to parent! | |
| 174 | + | ||
| 175 | + | dy := stackChildCY - normalCY | |
| 176 | + | t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY) | |
| 177 | + | t.Logf("Stack child (buggy relative): center=(%.0f, %.0f)", stackChildCX, stackChildCY) | |
| 178 | + | t.Logf("dy = %.0f (negative = appears above)", dy) | |
| 179 | + | ||
| 180 | + | // Navigation DOWN requires dy > 0, but we get negative! | |
| 181 | + | const focusEpsilon = 0.5 | |
| 182 | + | if dy <= focusEpsilon { | |
| 183 | + | t.Logf("✓ BUG: dy=%.0f, navigation DOWN would FAIL", dy) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | ||
| 187 | + | t.Run("CorrectBehavior_UseStackWrapper", func(t *testing.T) { | |
| 188 | + | // Normal pane (same as before) | |
| 189 | + | normalCX, normalCY := 957.0, 266.0 | |
| 190 | + | ||
| 191 | + | // Stack wrapper has correct window-absolute coordinates | |
| 192 | + | stackWrapperCX, stackWrapperCY := 957.0, 798.0 | |
| 193 | + | ||
| 194 | + | dy := stackWrapperCY - normalCY | |
| 195 | + | t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY) | |
| 196 | + | t.Logf("Stack wrapper (correct): center=(%.0f, %.0f)", stackWrapperCX, stackWrapperCY) | |
| 197 | + | t.Logf("dy = %.0f (positive = correctly below)", dy) | |
| 198 | + | ||
| 199 | + | const focusEpsilon = 0.5 | |
| 200 | + | if dy > focusEpsilon { | |
| 201 | + | t.Logf("✓ CORRECT: Navigation DOWN would SUCCEED") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Also verify UP navigation | |
| 205 | + | dyUp := normalCY - stackWrapperCY | |
| 206 | + | if dyUp < -focusEpsilon { | |
| 207 | + | t.Logf("✓ CORRECT: Navigation UP would SUCCEED") | |
| 208 | + | } | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // TestGetNavigationAllocation tests the proposed fix helper function | |
| 213 | + | // This will be uncommented and updated after implementing the helper | |
| 214 | + | func TestGetNavigationAllocation(t *testing.T) { | |
| 215 | + | t.Skip("Enable this test after implementing getNavigationAllocation() helper") | |
| 216 | + | ||
| 217 | + | // TODO: After implementing getNavigationAllocation(), test: | |
| 218 | + | // 1. Regular leaf pane → returns pane.container allocation | |
| 219 | + | // 2. Pane inside stack → returns stack.stackWrapper allocation | |
| 220 | + | // 3. Stack container itself → returns stack.stackWrapper allocation | |
| 221 | + | // 4. Nil safety: node=nil, container=nil, parent=nil, stackWrapper=nil | |
| 222 | + | } | |
M internal/app/browser/workspace_pane_mode.go +46 -15
| @@ -5,11 +5,14 @@ import ( | |||
| 5 | 5 | "log" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // EnterPaneMode activates pane mode on the currently focused pane | |
| 11 | 12 | // This is called directly from the keyboard bridge, not from JavaScript | |
| 12 | 13 | func (wm *WorkspaceManager) EnterPaneMode() { | |
| 14 | + | // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab) | |
| 15 | + | ||
| 13 | 16 | wm.paneMutex.Lock() | |
| 14 | 17 | defer wm.paneMutex.Unlock() | |
| 15 | 18 | ||
| @@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() { | |||
| 26 | 29 | return | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | - | log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode) | |
| 32 | + | log.Printf("[pane-mode] Entering pane mode on pane %p (workspace=%p)", activeNode, wm) | |
| 30 | 33 | ||
| 31 | 34 | wm.paneModeActive = true | |
| 32 | 35 | wm.paneModeActivePane = activeNode | |
| @@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) { | |||
| 67 | 70 | // HandlePaneAction processes pane mode actions (close, split, etc.) | |
| 68 | 71 | // Called from keyboard bridge when user presses action keys in pane mode | |
| 69 | 72 | func (wm *WorkspaceManager) HandlePaneAction(action string) { | |
| 73 | + | // Note: No IsInActiveTab check here - caller (RegisterPaneModeHandler) ensures | |
| 74 | + | // this is called on app.workspace (active tab's workspace) | |
| 75 | + | ||
| 70 | 76 | wm.paneMutex.Lock() | |
| 71 | 77 | ||
| 72 | 78 | if !wm.paneModeActive { | |
| @@ -201,34 +207,59 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) { | |||
| 201 | 207 | ||
| 202 | 208 | // applyPaneModeBorder applies pane mode visual indicator using GTK margins | |
| 203 | 209 | func (wm *WorkspaceManager) applyPaneModeBorder() { | |
| 204 | - | if wm.root == nil || wm.root.container == nil || wm.window == nil { | |
| 210 | + | window := wm.resolveWindow() | |
| 211 | + | if window == nil { | |
| 212 | + | log.Printf("[pane-mode] Cannot apply border: missing window reference") | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // Determine which container to apply margins to: | |
| 217 | + | // - If tab manager exists, apply to its root container (direct child of window) | |
| 218 | + | // so margins reveal the window background color | |
| 219 | + | // - Otherwise, apply to workspace root (direct child of window) | |
| 220 | + | var targetContainer gtk.Widgetter | |
| 221 | + | if wm.app != nil && wm.app.tabManager != nil { | |
| 222 | + | if rootContainer := wm.app.tabManager.GetRootContainer(); rootContainer != nil { | |
| 223 | + | // Tab environment: apply margins to the root container that's attached to the window | |
| 224 | + | targetContainer = rootContainer | |
| 225 | + | log.Printf("[pane-mode] Using tab manager root container for border") | |
| 226 | + | } else if wm.app.tabManager.ContentArea != nil { | |
| 227 | + | // Fallback: older builds where root container isn't available yet | |
| 228 | + | targetContainer = wm.app.tabManager.ContentArea | |
| 229 | + | log.Printf("[pane-mode] Using tab manager content area fallback for border") | |
| 230 | + | } | |
| 231 | + | } else if wm.root != nil && wm.root.container != nil { | |
| 232 | + | // Non-tab environment: apply margins to workspace root | |
| 233 | + | targetContainer = wm.root.container | |
| 234 | + | log.Printf("[pane-mode] Using workspace root for border") | |
| 235 | + | } else { | |
| 205 | 236 | return | |
| 206 | 237 | } | |
| 207 | 238 | ||
| 208 | 239 | // Save the container reference so we can remove margins from it later | |
| 209 | - | // (even if wm.root.container changes due to splits) | |
| 210 | - | wm.paneModeContainer = wm.root.container | |
| 240 | + | wm.paneModeContainer = targetContainer | |
| 211 | 241 | ||
| 212 | - | // Apply 4px margins to root container to create space for the border | |
| 213 | - | webkit.WidgetSetMargin(wm.root.container, 4) | |
| 242 | + | // Apply 4px margins to create space for the border | |
| 243 | + | webkit.WidgetSetMargin(targetContainer, 4) | |
| 214 | 244 | ||
| 215 | 245 | // Add CSS class to window for background color (the "border" color shows in the margin space) | |
| 216 | - | webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active") | |
| 246 | + | webkit.WidgetAddCSSClass(window.AsWindow(), "pane-mode-active") | |
| 217 | 247 | ||
| 218 | 248 | // Force resize/allocation to apply margin changes immediately | |
| 219 | - | webkit.WidgetQueueResize(wm.root.container) | |
| 220 | - | webkit.WidgetQueueAllocate(wm.root.container) | |
| 249 | + | webkit.WidgetQueueResize(targetContainer) | |
| 250 | + | webkit.WidgetQueueAllocate(targetContainer) | |
| 221 | 251 | ||
| 222 | 252 | // Queue redraw to show changes | |
| 223 | - | webkit.WidgetQueueDraw(wm.window.AsWindow()) | |
| 224 | - | webkit.WidgetQueueDraw(wm.root.container) | |
| 253 | + | webkit.WidgetQueueDraw(window.AsWindow()) | |
| 254 | + | webkit.WidgetQueueDraw(targetContainer) | |
| 225 | 255 | ||
| 226 | - | log.Printf("[pane-mode] Applied border to workspace root using margins (container=%p)", wm.root.container) | |
| 256 | + | log.Printf("[pane-mode] Applied border using margins (container=%p)", targetContainer) | |
| 227 | 257 | } | |
| 228 | 258 | ||
| 229 | 259 | // removePaneModeBorder removes the pane mode visual indicator | |
| 230 | 260 | func (wm *WorkspaceManager) removePaneModeBorder() { | |
| 231 | - | if wm.window == nil { | |
| 261 | + | window := wm.resolveWindow() | |
| 262 | + | if window == nil { | |
| 232 | 263 | return | |
| 233 | 264 | } | |
| 234 | 265 | ||
| @@ -247,14 +278,14 @@ func (wm *WorkspaceManager) removePaneModeBorder() { | |||
| 247 | 278 | webkit.WidgetSetMargin(container, 0) | |
| 248 | 279 | ||
| 249 | 280 | // Remove CSS class from window | |
| 250 | - | webkit.WidgetRemoveCSSClass(wm.window.AsWindow(), "pane-mode-active") | |
| 281 | + | webkit.WidgetRemoveCSSClass(window.AsWindow(), "pane-mode-active") | |
| 251 | 282 | ||
| 252 | 283 | // Force resize/allocation to apply margin changes immediately | |
| 253 | 284 | webkit.WidgetQueueResize(container) | |
| 254 | 285 | webkit.WidgetQueueAllocate(container) | |
| 255 | 286 | ||
| 256 | 287 | // Queue redraw to show changes | |
| 257 | - | webkit.WidgetQueueDraw(wm.window.AsWindow()) | |
| 288 | + | webkit.WidgetQueueDraw(window.AsWindow()) | |
| 258 | 289 | webkit.WidgetQueueDraw(container) | |
| 259 | 290 | ||
| 260 | 291 | // Clear the saved container reference | |
M internal/app/browser/workspace_pane_ops.go +132 -27
| @@ -3,11 +3,13 @@ package browser | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "log" | |
| 7 | 8 | ||
| 8 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | 9 | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 10 | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // closeContext manages the context for a pane close operation | |
| @@ -52,6 +54,60 @@ func (wm *WorkspaceManager) collectLeaves() []*paneNode { | |||
| 52 | 54 | return wm.collectLeavesFrom(wm.root) | |
| 53 | 55 | } | |
| 54 | 56 | ||
| 57 | + | // collectLeavesFromWithDirection collects leaves from a subtree with direction-aware stack entry | |
| 58 | + | func (wm *WorkspaceManager) collectLeavesFromWithDirection(node *paneNode, direction string) []*paneNode { | |
| 59 | + | var leaves []*paneNode | |
| 60 | + | visited := make(map[*paneNode]bool) | |
| 61 | + | ||
| 62 | + | var walk func(*paneNode, int) | |
| 63 | + | walk = func(n *paneNode, depth int) { | |
| 64 | + | // Prevent infinite recursion and cycles | |
| 65 | + | const maxDepth = 50 | |
| 66 | + | if n == nil || depth > maxDepth { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | if visited[n] { | |
| 70 | + | log.Printf("[workspace] collectLeavesFromWithDirection: cycle detected in tree") | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | visited[n] = true | |
| 74 | + | ||
| 75 | + | if n.isLeaf { | |
| 76 | + | leaves = append(leaves, n) | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Handle stacked panes - select entry pane based on direction | |
| 81 | + | if n.isStacked && len(n.stackedPanes) > 0 { | |
| 82 | + | var entryIndex int | |
| 83 | + | switch direction { | |
| 84 | + | case DirectionDown: | |
| 85 | + | // Coming from above - enter at first pane | |
| 86 | + | entryIndex = 0 | |
| 87 | + | case DirectionUp: | |
| 88 | + | // Coming from below - enter at last pane | |
| 89 | + | entryIndex = len(n.stackedPanes) - 1 | |
| 90 | + | default: | |
| 91 | + | // For horizontal directions, use active pane | |
| 92 | + | entryIndex = n.activeStackIndex | |
| 93 | + | if entryIndex < 0 || entryIndex >= len(n.stackedPanes) { | |
| 94 | + | entryIndex = 0 | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | if entryIndex >= 0 && entryIndex < len(n.stackedPanes) { | |
| 98 | + | walk(n.stackedPanes[entryIndex], depth+1) | |
| 99 | + | } | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Handle regular split nodes | |
| 104 | + | walk(n.left, depth+1) | |
| 105 | + | walk(n.right, depth+1) | |
| 106 | + | } | |
| 107 | + | walk(node, 0) | |
| 108 | + | return leaves | |
| 109 | + | } | |
| 110 | + | ||
| 55 | 111 | // collectLeavesFrom collects all leaf nodes from a given subtree | |
| 56 | 112 | func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode { | |
| 57 | 113 | var leaves []*paneNode | |
| @@ -475,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi | |||
| 475 | 531 | ||
| 476 | 532 | // GTK4 handles widget operations automatically - no need to force redraw | |
| 477 | 533 | ||
| 478 | - | // Handle root split: attach paned to window FIRST, then let GTK handle reparenting | |
| 534 | + | // Handle root split: attach paned to ContentArea (tab system is always present) | |
| 479 | 535 | if parent == nil { | |
| 480 | - | // ROOT SPLIT: Attach empty paned to window, then add children | |
| 481 | - | // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild | |
| 482 | - | if wm.window != nil { | |
| 483 | - | // Clear any existing window child (the splitTargetContainer) | |
| 484 | - | wm.window.SetChild(nil) | |
| 485 | - | ||
| 486 | - | // Attach the new paned to window FIRST | |
| 487 | - | wm.window.SetChild(paned) | |
| 488 | - | log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned) | |
| 536 | + | // ROOT SPLIT: Manipulate TabManager.ContentArea | |
| 537 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 538 | + | log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized") | |
| 539 | + | return nil, fmt.Errorf("tab manager not initialized") | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 543 | + | if !ok || contentBox == nil { | |
| 544 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 545 | + | return nil, fmt.Errorf("ContentArea is not a Box") | |
| 489 | 546 | } | |
| 490 | 547 | ||
| 548 | + | // Remove the old workspace root from ContentArea | |
| 549 | + | contentBox.Remove(splitTargetContainer) | |
| 550 | + | log.Printf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer) | |
| 551 | + | ||
| 552 | + | // Attach the new paned to ContentArea FIRST | |
| 553 | + | contentBox.Append(paned) | |
| 554 | + | log.Printf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned) | |
| 555 | + | ||
| 491 | 556 | // Now add children to the paned - GTK will handle reparenting automatically | |
| 492 | 557 | if existingFirst { | |
| 493 | 558 | paned.SetStartChild(splitTargetContainer) | |
| @@ -656,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si | |||
| 656 | 721 | } | |
| 657 | 722 | ||
| 658 | 723 | // swapContainers updates GTK widget hierarchy for promoted siblings | |
| 659 | - | func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |
| 724 | + | func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) { | |
| 660 | 725 | if grand == nil { | |
| 726 | + | // Sibling is being promoted to root | |
| 661 | 727 | // When TreeRebalancer is enabled, skip GTK attachment here as it will be | |
| 662 | 728 | // handled by the rebalancer's promotion transaction to avoid double-attachment | |
| 663 | 729 | if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled { | |
| 664 | - | wm.attachRoot(sibling) | |
| 730 | + | // NOTE: Can't use attachRoot() here because wm.root has already been updated | |
| 731 | + | // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea: | |
| 732 | + | // the old paned (parent) is still in ContentArea and needs to be removed, | |
| 733 | + | // then the sibling needs to be added. | |
| 734 | + | ||
| 735 | + | // The sibling has already been unparented from the old paned earlier in the close logic, | |
| 736 | + | // so it has no GTK parent now. | |
| 737 | + | ||
| 738 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 739 | + | log.Printf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized") | |
| 740 | + | return | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 744 | + | if !ok || contentBox == nil { | |
| 745 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 746 | + | return | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | // CRITICAL: Remove the old paned from ContentArea first | |
| 750 | + | if parent != nil && parent.container != nil { | |
| 751 | + | contentBox.Remove(parent.container) | |
| 752 | + | log.Printf("[workspace] Removed old paned %p from ContentArea", parent.container) | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | // Now add sibling to ContentArea as the new root | |
| 756 | + | contentBox.Append(sibling.container) | |
| 757 | + | webkit.WidgetSetVisible(sibling.container, true) | |
| 758 | + | log.Printf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container) | |
| 665 | 759 | } | |
| 666 | 760 | return | |
| 667 | 761 | } | |
| @@ -773,34 +867,45 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 773 | 867 | } | |
| 774 | 868 | } | |
| 775 | 869 | ||
| 776 | - | // attachRoot attaches a node as the new window root | |
| 870 | + | // attachRoot attaches a node as the new root in TabManager.ContentArea. | |
| 871 | + | // NOTE: This is typically called AFTER wm.root has already been updated to point to this node. | |
| 872 | + | // The old root container (if any) will be orphaned in ContentArea and garbage collected. | |
| 777 | 873 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |
| 778 | - | if root == nil || root.container == nil || wm.window == nil { | |
| 874 | + | if root == nil || root.container == nil { | |
| 779 | 875 | return | |
| 780 | 876 | } | |
| 781 | 877 | ||
| 782 | 878 | // Note: In gotk4, reference counting is handled automatically by Go's GC | |
| 783 | 879 | // No need for manual Ref()/Unref() calls | |
| 784 | 880 | ||
| 785 | - | // Detach from previous container (paned, stack, etc.) before replacing the window child. | |
| 786 | - | // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent | |
| 881 | + | // Detach from previous container (paned, stack, etc.) before attaching to ContentArea | |
| 882 | + | // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent | |
| 787 | 883 | if parent := webkit.WidgetGetParent(root.container); parent != nil { | |
| 788 | - | log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent) | |
| 884 | + | log.Printf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent) | |
| 789 | 885 | webkit.WidgetUnparent(root.container) | |
| 790 | 886 | // Note: In gotk4, the Go object reference remains valid after unparent | |
| 791 | 887 | // Only the GTK parent relationship is cleared | |
| 792 | 888 | } | |
| 793 | 889 | ||
| 794 | - | // Clear any existing window child to avoid GTK warnings when swapping roots. | |
| 795 | - | wm.window.SetChild(nil) | |
| 796 | - | ||
| 797 | 890 | if root.container != nil { | |
| 798 | 891 | webkit.WidgetSetHExpand(root.container, true) | |
| 799 | 892 | webkit.WidgetSetVExpand(root.container, true) | |
| 800 | 893 | } | |
| 801 | 894 | ||
| 802 | - | // GTK4 will automatically unparent root.container from its previous parent when we attach it here | |
| 803 | - | wm.window.SetChild(root.container) | |
| 895 | + | // Add to ContentArea (the old root will be orphaned and garbage collected) | |
| 896 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 897 | + | log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized") | |
| 898 | + | return | |
| 899 | + | } | |
| 900 | + | ||
| 901 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 902 | + | if !ok || contentBox == nil { | |
| 903 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 904 | + | return | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | contentBox.Append(root.container) | |
| 908 | + | webkit.WidgetSetVisible(root.container, true) | |
| 804 | 909 | if root.container != nil { | |
| 805 | 910 | webkit.WidgetQueueAllocate(root.container) | |
| 806 | 911 | webkit.WidgetShow(root.container) | |
| @@ -997,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 997 | 1102 | ||
| 998 | 1103 | // STEP 7: GTK widget reparenting | |
| 999 | 1104 | // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | |
| 1000 | - | // For window: we already manually unparented above, so now we can safely attach | |
| 1001 | - | wm.swapContainers(grandparent, sibling) | |
| 1105 | + | // For root promotion: we need to remove the old paned from ContentArea | |
| 1106 | + | wm.swapContainers(grandparent, parent, sibling) | |
| 1002 | 1107 | ||
| 1003 | 1108 | // Reconnect the promoted sibling's rendering pipeline after GTK reparenting. | |
| 1004 | 1109 | if sibling != nil && sibling.container != nil { | |
| @@ -1083,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||
| 1083 | 1188 | } | |
| 1084 | 1189 | } | |
| 1085 | 1190 | } else { | |
| 1086 | - | // Stack was root | |
| 1191 | + | // Stack was root - attach remaining pane as new root | |
| 1087 | 1192 | wm.root = remaining | |
| 1088 | 1193 | if remaining.container != nil { | |
| 1089 | - | wm.window.SetChild(remaining.container) | |
| 1194 | + | wm.setRootContainer(remaining.container) | |
| 1090 | 1195 | } | |
| 1091 | 1196 | } | |
| 1092 | 1197 | ||
M internal/app/browser/workspace_popup.go +53 -31
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/config" | |
| 10 | 10 | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | 11 | webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 12 | - | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | const ( | |
| @@ -342,34 +341,26 @@ func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNo | |||
| 342 | 341 | } | |
| 343 | 342 | log.Printf("[workspace] Step 3 complete: stored in pendingPopups") | |
| 344 | 343 | ||
| 345 | - | // CRITICAL: Do NOT connect signals before returning to WebKit | |
| 346 | - | // Connecting signals might trigger WebKit to access WindowFeatures before it's set | |
| 347 | - | // Instead, we'll connect them after WebKit has processed the return | |
| 348 | - | log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)") | |
| 344 | + | // Connect signals immediately BEFORE returning to WebKit | |
| 345 | + | // This is safe because we're only connecting signal handlers, not accessing properties | |
| 346 | + | // WebKit will configure WindowFeatures AFTER this callback returns, which is fine | |
| 347 | + | log.Printf("[workspace] Step 4: Connecting signals immediately before returning to WebKit") | |
| 349 | 348 | ||
| 350 | - | // Use GLib idle_add to connect signals AFTER this callback returns | |
| 351 | - | // This ensures WebKit has finished configuring WindowFeatures before we touch the view | |
| 352 | - | glib.IdleAdd(func() bool { | |
| 353 | - | log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID) | |
| 354 | - | ||
| 355 | - | // Connect ready-to-show signal NOW (after WebKit has configured the view) | |
| 356 | - | barePopupView.ConnectReadyToShow(func() { | |
| 357 | - | log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID) | |
| 358 | - | wm.handlePopupReadyToShow(popupID) | |
| 359 | - | }) | |
| 360 | - | log.Printf("[workspace] Idle callback: ready-to-show connected") | |
| 361 | - | ||
| 362 | - | // Connect close handler | |
| 363 | - | barePopupView.ConnectClose(func() { | |
| 364 | - | log.Printf("[workspace] close signal received for popup ID=%d", popupID) | |
| 365 | - | wm.handlePopupClose(popupID) | |
| 366 | - | }) | |
| 367 | - | log.Printf("[workspace] Idle callback: close signal connected") | |
| 349 | + | // Connect ready-to-show signal | |
| 350 | + | barePopupView.ConnectReadyToShow(func() { | |
| 351 | + | log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID) | |
| 352 | + | wm.handlePopupReadyToShow(popupID) | |
| 353 | + | }) | |
| 354 | + | log.Printf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID) | |
| 368 | 355 | ||
| 369 | - | return false // Don't repeat | |
| 356 | + | // Connect close handler | |
| 357 | + | barePopupView.ConnectClose(func() { | |
| 358 | + | log.Printf("[workspace] close signal received for popup ID=%d", popupID) | |
| 359 | + | wm.handlePopupClose(popupID) | |
| 370 | 360 | }) | |
| 361 | + | log.Printf("[workspace] Connected close signal for popup ID=%d", popupID) | |
| 371 | 362 | ||
| 372 | - | log.Printf("[workspace] Step 4 complete: signal connections scheduled") | |
| 363 | + | log.Printf("[workspace] Step 4 complete: signals connected") | |
| 373 | 364 | ||
| 374 | 365 | log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)") | |
| 375 | 366 | ||
| @@ -418,9 +409,39 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 418 | 409 | ||
| 419 | 410 | log.Printf("[workspace] Created popup pane, now inserting into workspace") | |
| 420 | 411 | ||
| 421 | - | // NOW it's safe to insert into workspace based on configured behavior | |
| 422 | - | behavior := wm.app.config.Workspace.Popups.Behavior | |
| 423 | - | log.Printf("[workspace] Popup behavior: %s", behavior) | |
| 412 | + | // Determine window type from node (set during setupPopupHandling) | |
| 413 | + | node := wm.viewToNode[wrappedView] | |
| 414 | + | windowType := webkit.WindowTypePopup | |
| 415 | + | if node != nil { | |
| 416 | + | windowType = node.windowType | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // Select behavior based on window type: | |
| 420 | + | // - WindowTypeTab (_blank links): Use BlankTargetBehavior config | |
| 421 | + | // - WindowTypePopup (JavaScript popups): Use global Behavior config | |
| 422 | + | var behaviorStr string | |
| 423 | + | if windowType == webkit.WindowTypeTab { | |
| 424 | + | behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior | |
| 425 | + | log.Printf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr) | |
| 426 | + | } else { | |
| 427 | + | behaviorStr = string(wm.app.config.Workspace.Popups.Behavior) | |
| 428 | + | log.Printf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // Map string to PopupBehavior enum | |
| 432 | + | var behavior config.PopupBehavior | |
| 433 | + | switch behaviorStr { | |
| 434 | + | case "split": | |
| 435 | + | behavior = config.PopupBehaviorSplit | |
| 436 | + | case "stacked": | |
| 437 | + | behavior = config.PopupBehaviorStacked | |
| 438 | + | case "tabbed": | |
| 439 | + | behavior = config.PopupBehaviorTabbed | |
| 440 | + | default: | |
| 441 | + | behavior = config.PopupBehaviorSplit // Fallback | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | log.Printf("[workspace] Final behavior: %s", behavior) | |
| 424 | 445 | ||
| 425 | 446 | switch behavior { | |
| 426 | 447 | case config.PopupBehaviorSplit: | |
| @@ -490,12 +511,13 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 490 | 511 | } | |
| 491 | 512 | ||
| 492 | 513 | // Configure the popup node | |
| 493 | - | node := wm.viewToNode[wrappedView] | |
| 514 | + | // Note: windowType should already be set correctly from setupPopupHandling | |
| 515 | + | // Don't overwrite it here - preserve WindowTypeTab for _blank links | |
| 516 | + | node = wm.viewToNode[wrappedView] | |
| 494 | 517 | if node != nil { | |
| 495 | - | node.windowType = webkit.WindowTypePopup | |
| 496 | 518 | node.isRelated = true | |
| 497 | 519 | node.parentPane = info.parentNode | |
| 498 | - | node.isPopup = true | |
| 520 | + | node.isPopup = true // Mark as popup to prevent browser exit when parent closes | |
| 499 | 521 | node.autoClose = wm.shouldAutoClose(info.url) | |
| 500 | 522 | node.popupID = popupID // Store popupID for close signal lookup | |
| 501 | 523 | ||
M internal/app/browser/workspace_utils.go +9 -0
| @@ -33,15 +33,24 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // mapDirection maps a direction string to GTK orientation and positioning | |
| 36 | + | // The bool return value indicates whether the existing pane should be the first child (start) | |
| 37 | + | // In GTK Paned with Vertical orientation: start=top, end=bottom | |
| 38 | + | // In GTK Paned with Horizontal orientation: start=left, end=right | |
| 36 | 39 | func mapDirection(direction string) (gtk.Orientation, bool) { | |
| 37 | 40 | switch direction { | |
| 38 | 41 | case DirectionLeft: | |
| 42 | + | // New pane goes to left, existing to right | |
| 39 | 43 | return gtk.OrientationHorizontal, false | |
| 40 | 44 | case DirectionUp: | |
| 45 | + | // New pane goes to top (start), existing to bottom (end) | |
| 46 | + | // So existingFirst = false | |
| 41 | 47 | return gtk.OrientationVertical, false | |
| 42 | 48 | case DirectionDown: | |
| 49 | + | // New pane goes to bottom (end), existing to top (start) | |
| 50 | + | // So existingFirst = true | |
| 43 | 51 | return gtk.OrientationVertical, true | |
| 44 | 52 | default: | |
| 53 | + | // Right: new pane to right, existing to left | |
| 45 | 54 | return gtk.OrientationHorizontal, true | |
| 46 | 55 | } | |
| 47 | 56 | } | |
M internal/app/control/clipboard.go +1 -1
| @@ -51,7 +51,7 @@ func (c *ClipboardController) showToast(message, toastType string) { | |||
| 51 | 51 | detail["type"] = toastType | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | if err := c.webView.DispatchCustomEvent("dumber:showToast", detail); err != nil { | |
| 54 | + | if err := c.webView.DispatchCustomEvent("dumber:toast", detail); err != nil { | |
| 55 | 55 | log.Printf("Failed to dispatch toast event: %v", err) | |
| 56 | 56 | } | |
| 57 | 57 | } | |
M internal/app/control/zoom.go +8 -1
| @@ -26,6 +26,7 @@ type WebViewInterface interface { | |||
| 26 | 26 | InjectScript(script string) error | |
| 27 | 27 | RegisterZoomChangedHandler(handler func(float64)) | |
| 28 | 28 | RegisterURIChangedHandler(handler func(string)) | |
| 29 | + | RegisterLoadCommittedHandler(handler func(string)) | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // NewZoomController creates a new zoom controller | |
| @@ -41,11 +42,17 @@ func NewZoomController(browserService *services.BrowserService, webView *webkit. | |||
| 41 | 42 | func (z *ZoomController) RegisterHandlers() { | |
| 42 | 43 | z.webView.RegisterZoomChangedHandler(z.handleZoomChange) | |
| 43 | 44 | z.webView.RegisterURIChangedHandler(z.handleURIChange) | |
| 45 | + | z.webView.RegisterLoadCommittedHandler(z.handleLoadCommitted) | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | - | // handleURIChange responds to URL changes and applies saved zoom levels | |
| 48 | + | // handleURIChange tracks URL changes but doesn't apply zoom (wait for load-committed) | |
| 47 | 49 | func (z *ZoomController) handleURIChange(url string) { | |
| 48 | 50 | z.currentURL = url | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // handleLoadCommitted responds to load committed events and applies saved zoom levels | |
| 54 | + | // This fires when the page actually starts loading content, preventing visual glitches | |
| 55 | + | func (z *ZoomController) handleLoadCommitted(url string) { | |
| 49 | 56 | if url == "" { | |
| 50 | 57 | return | |
| 51 | 58 | } | |
M internal/app/environment/locale.go +34 -24
| @@ -12,38 +12,21 @@ import ( | |||
| 12 | 12 | func DetectAndSetKeyboardLocale() { | |
| 13 | 13 | const colonSplitParts = 2 // Number of parts when splitting on colon | |
| 14 | 14 | ||
| 15 | + | var locale string | |
| 16 | + | ||
| 15 | 17 | // 0) Explicit override | |
| 16 | - | locale := os.Getenv("DUMBER_KEYBOARD_LOCALE") | |
| 18 | + | locale = os.Getenv("DUMBER_KEYBOARD_LOCALE") | |
| 17 | 19 | if locale == "" { | |
| 18 | 20 | locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix | |
| 19 | 21 | } | |
| 20 | - | // 1) Environment | |
| 21 | - | if locale == "" { | |
| 22 | - | locale = os.Getenv("LC_ALL") | |
| 23 | - | } | |
| 24 | - | if locale == "" { | |
| 25 | - | locale = os.Getenv("LANG") | |
| 26 | - | } | |
| 27 | - | if locale == "" { | |
| 28 | - | locale = os.Getenv("LC_CTYPE") | |
| 29 | - | } | |
| 30 | - | // Trim variants | |
| 31 | - | if i := strings.IndexByte(locale, '.'); i > 0 { | |
| 32 | - | locale = locale[:i] | |
| 33 | - | } | |
| 34 | - | if i := strings.IndexByte(locale, '@'); i > 0 { | |
| 35 | - | locale = locale[:i] | |
| 36 | - | } | |
| 37 | - | if i := strings.IndexByte(locale, '_'); i > 0 { | |
| 38 | - | locale = locale[:i] | |
| 39 | - | } | |
| 40 | 22 | ||
| 41 | - | // 2) Try XKB env override | |
| 23 | + | // 1) XKB env override (actual keyboard layout) | |
| 42 | 24 | if locale == "" { | |
| 43 | 25 | locale = os.Getenv("XKB_DEFAULT_LAYOUT") | |
| 44 | 26 | } | |
| 45 | 27 | ||
| 46 | - | // 3) Best-effort probe of setxkbmap/localectl (non-fatal) | |
| 28 | + | // 2) Best-effort probe of localectl/setxkbmap (actual keyboard layout) | |
| 29 | + | // Check these BEFORE LANG since LANG is system language, not keyboard layout | |
| 47 | 30 | if locale == "" { | |
| 48 | 31 | if out, err := exec.Command("localectl", "status").Output(); err == nil { | |
| 49 | 32 | s := string(out) | |
| @@ -55,6 +38,7 @@ func DetectAndSetKeyboardLocale() { | |||
| 55 | 38 | cand := strings.TrimSpace(parts[1]) | |
| 56 | 39 | if cand != "" { | |
| 57 | 40 | locale = cand | |
| 41 | + | log.Printf("[locale] keyboard layout from localectl: %s", locale) | |
| 58 | 42 | } | |
| 59 | 43 | } | |
| 60 | 44 | break | |
| @@ -73,6 +57,7 @@ func DetectAndSetKeyboardLocale() { | |||
| 73 | 57 | cand := strings.TrimSpace(parts[1]) | |
| 74 | 58 | if cand != "" { | |
| 75 | 59 | locale = cand | |
| 60 | + | log.Printf("[locale] keyboard layout from setxkbmap: %s", locale) | |
| 76 | 61 | } | |
| 77 | 62 | } | |
| 78 | 63 | break | |
| @@ -81,9 +66,34 @@ func DetectAndSetKeyboardLocale() { | |||
| 81 | 66 | } | |
| 82 | 67 | } | |
| 83 | 68 | ||
| 69 | + | // 3) Fallback to system locale (language, not keyboard layout) | |
| 70 | + | if locale == "" { | |
| 71 | + | locale = os.Getenv("LC_ALL") | |
| 72 | + | } | |
| 73 | + | if locale == "" { | |
| 74 | + | locale = os.Getenv("LANG") | |
| 75 | + | } | |
| 76 | + | if locale == "" { | |
| 77 | + | locale = os.Getenv("LC_CTYPE") | |
| 78 | + | } | |
| 79 | + | // Trim variants from locale | |
| 80 | + | if locale != "" { | |
| 81 | + | if i := strings.IndexByte(locale, '.'); i > 0 { | |
| 82 | + | locale = locale[:i] | |
| 83 | + | } | |
| 84 | + | if i := strings.IndexByte(locale, '@'); i > 0 { | |
| 85 | + | locale = locale[:i] | |
| 86 | + | } | |
| 87 | + | if i := strings.IndexByte(locale, '_'); i > 0 { | |
| 88 | + | locale = locale[:i] | |
| 89 | + | } | |
| 90 | + | log.Printf("[locale] keyboard layout from system locale: %s", locale) | |
| 91 | + | } | |
| 92 | + | ||
| 84 | 93 | if locale == "" { | |
| 85 | 94 | locale = "en" | |
| 95 | + | log.Printf("[locale] keyboard layout defaulted to: %s", locale) | |
| 86 | 96 | } | |
| 87 | - | // No layout-specific remaps; log for diagnostics only | |
| 97 | + | ||
| 88 | 98 | log.Printf("[locale] keyboard locale detected: %s", locale) | |
| 89 | 99 | } | |
M internal/app/environment/media.go +2 -8
| @@ -15,18 +15,12 @@ func SetupMediaPipeline() { | |||
| 15 | 15 | log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err) | |
| 16 | 16 | } | |
| 17 | 17 | } | |
| 18 | - | // Provide a safe audio sink to avoid autoaudiosink failures | |
| 19 | - | if os.Getenv("GST_AUDIO_SINK") == "" { | |
| 20 | - | if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil { | |
| 21 | - | log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err) | |
| 22 | - | } | |
| 23 | - | } | |
| 24 | - | // Optionally disable media stream features that can engage complex pipelines | |
| 18 | + | // Optionally disable media stream features that can engage complex pipelines (webcam/mic) | |
| 25 | 19 | if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" { | |
| 26 | 20 | if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil { | |
| 27 | 21 | log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err) | |
| 28 | 22 | } | |
| 29 | 23 | } | |
| 30 | - | log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)") | |
| 24 | + | log.Printf("[media] Safe mode enabled: VAAPI demoted, webcam/mic disabled (override with DUMBER_MEDIA_SAFE=0)") | |
| 31 | 25 | } | |
| 32 | 26 | } | |
M internal/app/messaging/handler.go +120 -0
| @@ -50,6 +50,9 @@ type Message struct { | |||
| 50 | 50 | // Wails fetch bridge | |
| 51 | 51 | ID string `json:"id"` | |
| 52 | 52 | Payload json.RawMessage `json:"payload"` | |
| 53 | + | // Favorites operations | |
| 54 | + | Title string `json:"title"` | |
| 55 | + | FaviconURL string `json:"faviconURL"` | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | // NewHandler creates a new message handler | |
| @@ -110,6 +113,12 @@ func (h *Handler) Handle(payload string) { | |||
| 110 | 113 | h.handleGetSearchShortcuts(msg) | |
| 111 | 114 | case "get_color_palettes": | |
| 112 | 115 | h.handleGetColorPalettes(msg) | |
| 116 | + | case "get_favorites": | |
| 117 | + | h.handleGetFavorites(msg) | |
| 118 | + | case "toggle_favorite": | |
| 119 | + | h.handleToggleFavorite(msg) | |
| 120 | + | case "is_favorite": | |
| 121 | + | h.handleIsFavorite(msg) | |
| 113 | 122 | } | |
| 114 | 123 | } | |
| 115 | 124 | ||
| @@ -568,3 +577,114 @@ func (h *Handler) handleGetColorPalettes(msg Message) { | |||
| 568 | 577 | log.Printf("[messaging] Sending color palettes to JavaScript") | |
| 569 | 578 | _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")") | |
| 570 | 579 | } | |
| 580 | + | ||
| 581 | + | // handleGetFavorites sends all favorites to JavaScript | |
| 582 | + | func (h *Handler) handleGetFavorites(msg Message) { | |
| 583 | + | if h.webView == nil { | |
| 584 | + | log.Printf("[messaging] Cannot provide favorites - no webview available") | |
| 585 | + | return | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | if h.webView.IsDestroyed() { | |
| 589 | + | log.Printf("[messaging] Cannot provide favorites - webview is destroyed") | |
| 590 | + | return | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | // Get favorites from browser service | |
| 594 | + | favorites, err := h.browserService.GetFavorites(context.Background()) | |
| 595 | + | if err != nil { | |
| 596 | + | log.Printf("[messaging] Failed to get favorites: %v", err) | |
| 597 | + | _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to get favorites')") | |
| 598 | + | return | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | // Marshal to JSON | |
| 602 | + | b, err := json.Marshal(favorites) | |
| 603 | + | if err != nil { | |
| 604 | + | log.Printf("[messaging] Failed to marshal favorites: %v", err) | |
| 605 | + | _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to load favorites')") | |
| 606 | + | return | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | // Inject the favorites into the page | |
| 610 | + | log.Printf("[messaging] Sending %d favorites to JavaScript", len(favorites)) | |
| 611 | + | _ = h.webView.InjectScript("window.__dumber_favorites && window.__dumber_favorites(" + string(b) + ")") | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | // handleToggleFavorite adds or removes a URL from favorites | |
| 615 | + | func (h *Handler) handleToggleFavorite(msg Message) { | |
| 616 | + | if h.webView == nil { | |
| 617 | + | log.Printf("[messaging] Cannot toggle favorite - no webview available") | |
| 618 | + | return | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | if h.webView.IsDestroyed() { | |
| 622 | + | log.Printf("[messaging] Cannot toggle favorite - webview is destroyed") | |
| 623 | + | return | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | if msg.URL == "" { | |
| 627 | + | log.Printf("[messaging] Cannot toggle favorite - URL is empty") | |
| 628 | + | return | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | // Toggle the favorite | |
| 632 | + | added, err := h.browserService.ToggleFavorite(context.Background(), msg.URL, msg.Title, msg.FaviconURL) | |
| 633 | + | if err != nil { | |
| 634 | + | log.Printf("[messaging] Failed to toggle favorite for %s: %v", msg.URL, err) | |
| 635 | + | _ = h.webView.InjectScript("window.__dumber_favorite_toggled_error && window.__dumber_favorite_toggled_error('Failed to toggle favorite')") | |
| 636 | + | return | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | // Send result back to JavaScript | |
| 640 | + | result := map[string]interface{}{ | |
| 641 | + | "url": msg.URL, | |
| 642 | + | "added": added, | |
| 643 | + | } | |
| 644 | + | b, err := json.Marshal(result) | |
| 645 | + | if err != nil { | |
| 646 | + | log.Printf("[messaging] Failed to marshal toggle result: %v", err) | |
| 647 | + | return | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | log.Printf("[messaging] Favorite toggled for %s (added: %v)", msg.URL, added) | |
| 651 | + | _ = h.webView.InjectScript("window.__dumber_favorite_toggled && window.__dumber_favorite_toggled(" + string(b) + ")") | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | // handleIsFavorite checks if a URL is favorited | |
| 655 | + | func (h *Handler) handleIsFavorite(msg Message) { | |
| 656 | + | if h.webView == nil { | |
| 657 | + | log.Printf("[messaging] Cannot check favorite - no webview available") | |
| 658 | + | return | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | if h.webView.IsDestroyed() { | |
| 662 | + | log.Printf("[messaging] Cannot check favorite - webview is destroyed") | |
| 663 | + | return | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | if msg.URL == "" { | |
| 667 | + | log.Printf("[messaging] Cannot check favorite - URL is empty") | |
| 668 | + | return | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // Check if the URL is favorited | |
| 672 | + | isFavorite, err := h.browserService.IsFavorite(context.Background(), msg.URL) | |
| 673 | + | if err != nil { | |
| 674 | + | log.Printf("[messaging] Failed to check if favorite for %s: %v", msg.URL, err) | |
| 675 | + | return | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | // Send result back to JavaScript | |
| 679 | + | result := map[string]interface{}{ | |
| 680 | + | "url": msg.URL, | |
| 681 | + | "isFavorite": isFavorite, | |
| 682 | + | } | |
| 683 | + | b, err := json.Marshal(result) | |
| 684 | + | if err != nil { | |
| 685 | + | log.Printf("[messaging] Failed to marshal is_favorite result: %v", err) | |
| 686 | + | return | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | _ = h.webView.InjectScript("window.__dumber_is_favorite && window.__dumber_is_favorite(" + string(b) + ")") | |
| 690 | + | } | |
A internal/cache/favorites_cache.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package cache | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/cache/generic" | |
| 8 | + | "github.com/bnema/dumber/internal/db" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // FavoritesDBOperations implements DatabaseOperations for favorites cache. | |
| 12 | + | // Handles loading, persisting, and deleting favorites from the database. | |
| 13 | + | type FavoritesDBOperations struct { | |
| 14 | + | queries db.DatabaseQuerier | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewFavoritesDBOperations creates a new FavoritesDBOperations instance. | |
| 18 | + | func NewFavoritesDBOperations(queries db.DatabaseQuerier) *FavoritesDBOperations { | |
| 19 | + | return &FavoritesDBOperations{ | |
| 20 | + | queries: queries, | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // LoadAll loads all favorites from the database. | |
| 25 | + | // Returns a map of URL -> Favorite. | |
| 26 | + | func (f *FavoritesDBOperations) LoadAll(ctx context.Context) (map[string]db.Favorite, error) { | |
| 27 | + | favorites, err := f.queries.GetAllFavorites(ctx) | |
| 28 | + | if err != nil { | |
| 29 | + | return nil, err | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | result := make(map[string]db.Favorite, len(favorites)) | |
| 33 | + | for _, fav := range favorites { | |
| 34 | + | result[fav.Url] = fav | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | return result, nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Persist saves a favorite to the database. | |
| 41 | + | // Uses UPSERT logic: if URL exists, updates metadata; otherwise creates new. | |
| 42 | + | func (f *FavoritesDBOperations) Persist(ctx context.Context, url string, favorite db.Favorite) error { | |
| 43 | + | // Check if favorite already exists | |
| 44 | + | _, err := f.queries.GetFavoriteByURL(ctx, url) | |
| 45 | + | if err == sql.ErrNoRows { | |
| 46 | + | // Create new favorite | |
| 47 | + | return f.queries.CreateFavorite(ctx, url, favorite.Title, favorite.FaviconUrl) | |
| 48 | + | } else if err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Update existing favorite | |
| 53 | + | return f.queries.UpdateFavorite(ctx, favorite.Title, favorite.FaviconUrl, url) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Delete removes a favorite from the database. | |
| 57 | + | func (f *FavoritesDBOperations) Delete(ctx context.Context, url string) error { | |
| 58 | + | return f.queries.DeleteFavorite(ctx, url) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // FavoritesCache is a specialized cache for favorites. | |
| 62 | + | // It wraps GenericCache with favorites-specific helper methods. | |
| 63 | + | type FavoritesCache struct { | |
| 64 | + | *generic.GenericCache[string, db.Favorite] | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // NewFavoritesCache creates a new favorites cache. | |
| 68 | + | func NewFavoritesCache(queries db.DatabaseQuerier) *FavoritesCache { | |
| 69 | + | dbOps := NewFavoritesDBOperations(queries) | |
| 70 | + | return &FavoritesCache{ | |
| 71 | + | GenericCache: generic.NewGenericCache(dbOps), | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // GetAll returns all favorites as a slice, ordered by position. | |
| 76 | + | // This is a convenience method for the frontend. | |
| 77 | + | func (f *FavoritesCache) GetAll() []db.Favorite { | |
| 78 | + | // Get all favorites from the cache using the List method | |
| 79 | + | favorites := f.GenericCache.List() | |
| 80 | + | ||
| 81 | + | // Sort by position | |
| 82 | + | // Note: favorites should already be ordered by position from DB load, | |
| 83 | + | // but this ensures consistency even if items were added at runtime. | |
| 84 | + | for i := 0; i < len(favorites)-1; i++ { | |
| 85 | + | for j := i + 1; j < len(favorites); j++ { | |
| 86 | + | if favorites[i].Position > favorites[j].Position { | |
| 87 | + | favorites[i], favorites[j] = favorites[j], favorites[i] | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | return favorites | |
| 93 | + | } | |
M internal/config/config.go +104 -53
| @@ -26,7 +26,9 @@ type Config struct { | |||
| 26 | 26 | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | |
| 27 | 27 | History HistoryConfig `mapstructure:"history" yaml:"history"` | |
| 28 | 28 | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | |
| 29 | - | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | |
| 29 | + | // DefaultSearchEngine is the URL template for the default search engine (must contain %s placeholder) | |
| 30 | + | DefaultSearchEngine string `mapstructure:"default_search_engine" yaml:"default_search_engine"` | |
| 31 | + | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | |
| 30 | 32 | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | |
| 31 | 33 | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | |
| 32 | 34 | VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"` | |
| @@ -67,8 +69,8 @@ type HistoryConfig struct { | |||
| 67 | 69 | ||
| 68 | 70 | // SearchShortcut represents a search shortcut configuration. | |
| 69 | 71 | type SearchShortcut struct { | |
| 70 | - | URL string `mapstructure:"url" yaml:"url" json:"url"` | |
| 71 | - | Description string `mapstructure:"description" yaml:"description" json:"description"` | |
| 72 | + | URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"` | |
| 73 | + | Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"` | |
| 72 | 74 | } | |
| 73 | 75 | ||
| 74 | 76 | // DmenuConfig holds dmenu/rofi integration configuration. | |
| @@ -214,12 +216,14 @@ type DebugConfig struct { | |||
| 214 | 216 | ||
| 215 | 217 | // WorkspaceConfig captures layout, pane, and tab behaviour preferences. | |
| 216 | 218 | type WorkspaceConfig struct { | |
| 217 | - | // EnableZellijControls toggles Zellij-inspired keybindings. | |
| 218 | - | EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"` | |
| 219 | 219 | // PaneMode defines modal pane behaviour and bindings. | |
| 220 | 220 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"` | |
| 221 | + | // TabMode defines modal tab behaviour and bindings (Alt+T). | |
| 222 | + | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" json:"tab_mode"` | |
| 221 | 223 | // Tabs holds classic browser tab shortcuts. | |
| 222 | 224 | Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" json:"tabs"` | |
| 225 | + | // TabBarPosition determines tab bar placement: "top" or "bottom". | |
| 226 | + | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" json:"tab_bar_position"` | |
| 223 | 227 | // Popups configures default popup placement rules. | |
| 224 | 228 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"` | |
| 225 | 229 | // Styling configures workspace visual appearance. | |
| @@ -228,9 +232,40 @@ type WorkspaceConfig struct { | |||
| 228 | 232 | ||
| 229 | 233 | // PaneModeConfig defines modal behaviour for pane management. | |
| 230 | 234 | type PaneModeConfig struct { | |
| 231 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"` | |
| 232 | - | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"` | |
| 233 | - | ActionBindings map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"` | |
| 235 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"` | |
| 236 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"` | |
| 237 | + | Actions map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"` | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 241 | + | // This is built from the action→keys structure in the config. | |
| 242 | + | func (p *PaneModeConfig) GetKeyBindings() map[string]string { | |
| 243 | + | keyToAction := make(map[string]string) | |
| 244 | + | for action, keys := range p.Actions { | |
| 245 | + | for _, key := range keys { | |
| 246 | + | keyToAction[key] = action | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | return keyToAction | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // TabModeConfig defines modal behaviour for tab management (Zellij-style). | |
| 253 | + | type TabModeConfig struct { | |
| 254 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"` | |
| 255 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"` | |
| 256 | + | Actions map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"` | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 260 | + | // This is built from the action→keys structure in the config. | |
| 261 | + | func (t *TabModeConfig) GetKeyBindings() map[string]string { | |
| 262 | + | keyToAction := make(map[string]string) | |
| 263 | + | for action, keys := range t.Actions { | |
| 264 | + | for _, key := range keys { | |
| 265 | + | keyToAction[key] = action | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | return keyToAction | |
| 234 | 269 | } | |
| 235 | 270 | ||
| 236 | 271 | // TabKeyConfig defines Zellij-inspired tab shortcuts. | |
| @@ -270,8 +305,9 @@ type PopupBehaviorConfig struct { | |||
| 270 | 305 | // FollowPaneContext determines if popup placement follows parent pane context | |
| 271 | 306 | FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"` | |
| 272 | 307 | ||
| 273 | - | // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents | |
| 274 | - | // Accepted values: "pane" (default) or "tab" (future support) | |
| 308 | + | // BlankTargetBehavior determines how target="_blank" links are opened | |
| 309 | + | // Accepted values: "split", "stacked" (default), "tabbed" | |
| 310 | + | // This is separate from Behavior which controls JavaScript popups | |
| 275 | 311 | BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"` | |
| 276 | 312 | ||
| 277 | 313 | // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents | |
| @@ -283,17 +319,28 @@ type PopupBehaviorConfig struct { | |||
| 283 | 319 | ||
| 284 | 320 | // WorkspaceStylingConfig defines visual styling for workspace panes. | |
| 285 | 321 | type WorkspaceStylingConfig struct { | |
| 286 | - | // BorderWidth in pixels for pane borders | |
| 322 | + | // BorderWidth in pixels for active pane borders | |
| 287 | 323 | BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"` | |
| 288 | 324 | // BorderColor for focused panes (CSS color value or theme variable) | |
| 289 | 325 | BorderColor string `mapstructure:"border_color" yaml:"border_color" json:"border_color"` | |
| 326 | + | // InactiveBorderWidth in pixels for inactive pane borders (0 = hidden) | |
| 327 | + | InactiveBorderWidth int `mapstructure:"inactive_border_width" yaml:"inactive_border_width" json:"inactive_border_width"` | |
| 328 | + | // InactiveBorderColor for unfocused panes (CSS color value or theme variable) | |
| 329 | + | InactiveBorderColor string `mapstructure:"inactive_border_color" yaml:"inactive_border_color" json:"inactive_border_color"` | |
| 330 | + | // ShowStackedTitleBorder enables the separator line below stacked pane titles | |
| 331 | + | ShowStackedTitleBorder bool `mapstructure:"show_stacked_title_border" yaml:"show_stacked_title_border" json:"show_stacked_title_border"` | |
| 290 | 332 | // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable) | |
| 291 | - | // Defaults to "#FFA500" (orange) if not set | |
| 333 | + | // Defaults to "#4A90E2" (blue) if not set | |
| 292 | 334 | PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" json:"pane_mode_border_color"` | |
| 335 | + | // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable) | |
| 336 | + | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | |
| 337 | + | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" json:"tab_mode_border_color"` | |
| 293 | 338 | // TransitionDuration in milliseconds for border animations | |
| 294 | 339 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"` | |
| 295 | 340 | // BorderRadius in pixels for pane border corners | |
| 296 | 341 | BorderRadius int `mapstructure:"border_radius" yaml:"border_radius" json:"border_radius"` | |
| 342 | + | // UIScale is a multiplier for UI elements like title bars (1.0 = 100%, 1.2 = 120%) | |
| 343 | + | UIScale float64 `mapstructure:"ui_scale" yaml:"ui_scale" json:"ui_scale"` | |
| 297 | 344 | } | |
| 298 | 345 | ||
| 299 | 346 | // Manager handles configuration loading, watching, and reloading. | |
| @@ -315,7 +362,7 @@ func NewManager() (*Manager, error) { | |||
| 315 | 362 | // Add config paths | |
| 316 | 363 | configDir, err := GetConfigDir() | |
| 317 | 364 | if err != nil { | |
| 318 | - | return nil, fmt.Errorf("failed to get config directory: %w", err) | |
| 365 | + | return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err) | |
| 319 | 366 | } | |
| 320 | 367 | v.AddConfigPath(configDir) | |
| 321 | 368 | v.AddConfigPath(".") // Current directory for development | |
| @@ -325,36 +372,11 @@ func NewManager() (*Manager, error) { | |||
| 325 | 372 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) | |
| 326 | 373 | v.AutomaticEnv() | |
| 327 | 374 | ||
| 328 | - | // Bind specific environment variables | |
| 329 | - | bindings := map[string]string{ | |
| 330 | - | "database.path": "DATABASE_PATH", | |
| 331 | - | "history.max_entries": "HISTORY_MAX_ENTRIES", | |
| 332 | - | "history.retention_period": "HISTORY_RETENTION_PERIOD", | |
| 333 | - | "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL", | |
| 334 | - | "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS", | |
| 335 | - | "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT", | |
| 336 | - | "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED", | |
| 337 | - | "dmenu.history_prefix": "DMENU_HISTORY_PREFIX", | |
| 338 | - | "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX", | |
| 339 | - | "dmenu.url_prefix": "DMENU_URL_PREFIX", | |
| 340 | - | "dmenu.date_format": "DMENU_DATE_FORMAT", | |
| 341 | - | "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT", | |
| 342 | - | "logging.level": "LOGGING_LEVEL", | |
| 343 | - | "logging.format": "LOGGING_FORMAT", | |
| 344 | - | "logging.filename": "LOGGING_FILENAME", | |
| 345 | - | "logging.max_size": "LOGGING_MAX_SIZE", | |
| 346 | - | "logging.max_backups": "LOGGING_MAX_BACKUPS", | |
| 347 | - | "logging.max_age": "LOGGING_MAX_AGE", | |
| 348 | - | "logging.compress": "LOGGING_COMPRESS", | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | for key, env := range bindings { | |
| 352 | - | if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil { | |
| 353 | - | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |
| 354 | - | } | |
| 355 | - | } | |
| 375 | + | // Note: Most environment variables are handled automatically via AutomaticEnv() | |
| 376 | + | // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH). | |
| 377 | + | // The explicit bindings below are only for special cases with different naming patterns. | |
| 356 | 378 | ||
| 357 | - | // Explicit binding for rendering mode via dedicated env var | |
| 379 | + | // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_) | |
| 358 | 380 | if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil { | |
| 359 | 381 | return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err) | |
| 360 | 382 | } | |
| @@ -366,6 +388,7 @@ func NewManager() (*Manager, error) { | |||
| 366 | 388 | } | |
| 367 | 389 | ||
| 368 | 390 | // Video acceleration environment variable bindings | |
| 391 | + | // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix | |
| 369 | 392 | videoAccelEnvBindings := map[string]string{ | |
| 370 | 393 | "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE", | |
| 371 | 394 | "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT", | |
| @@ -381,6 +404,7 @@ func NewManager() (*Manager, error) { | |||
| 381 | 404 | } | |
| 382 | 405 | ||
| 383 | 406 | // Codec preferences environment variable bindings | |
| 407 | + | // These use DUMBER_* prefix for shorter, more convenient env var names | |
| 384 | 408 | codecEnvBindings := map[string]string{ | |
| 385 | 409 | "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS", | |
| 386 | 410 | "codec_preferences.force_av1": "DUMBER_FORCE_AV1", | |
| @@ -426,21 +450,28 @@ func (m *Manager) Load() error { | |||
| 426 | 450 | if errors.As(err, &configFileNotFoundError) { | |
| 427 | 451 | // Config file not found, create default one | |
| 428 | 452 | if err := m.createDefaultConfig(); err != nil { | |
| 429 | - | return fmt.Errorf("failed to create default config: %w", err) | |
| 453 | + | configDir, _ := GetConfigDir() | |
| 454 | + | return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err) | |
| 430 | 455 | } | |
| 431 | 456 | // Re-read the newly created config file | |
| 432 | 457 | if err := m.viper.ReadInConfig(); err != nil { | |
| 433 | - | return fmt.Errorf("failed to read newly created config: %w", err) | |
| 458 | + | return fmt.Errorf("failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format", err) | |
| 434 | 459 | } | |
| 435 | 460 | } else { | |
| 436 | - | return fmt.Errorf("failed to read config file: %w", err) | |
| 461 | + | configFile := m.viper.ConfigFileUsed() | |
| 462 | + | if configFile == "" { | |
| 463 | + | configDir, _ := GetConfigDir() | |
| 464 | + | configFile = filepath.Join(configDir, "config.*") | |
| 465 | + | } | |
| 466 | + | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML, JSON, or YAML) and permissions", configFile, err) | |
| 437 | 467 | } | |
| 438 | 468 | } | |
| 439 | 469 | ||
| 440 | 470 | // Unmarshal into config struct | |
| 441 | 471 | config := &Config{} | |
| 442 | 472 | if err := m.viper.Unmarshal(config); err != nil { | |
| 443 | - | return fmt.Errorf("failed to unmarshal config: %w", err) | |
| 473 | + | configFile := m.viper.ConfigFileUsed() | |
| 474 | + | return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err) | |
| 444 | 475 | } | |
| 445 | 476 | ||
| 446 | 477 | // Set database path if not specified | |
| @@ -478,6 +509,11 @@ func (m *Manager) Load() error { | |||
| 478 | 509 | // Validate ColorScheme setting | |
| 479 | 510 | m.validateColorScheme(config) | |
| 480 | 511 | ||
| 512 | + | // Validate all config values | |
| 513 | + | if err := validateConfig(config); err != nil { | |
| 514 | + | return fmt.Errorf("configuration validation failed: %w", err) | |
| 515 | + | } | |
| 516 | + | ||
| 481 | 517 | m.config = config | |
| 482 | 518 | return nil | |
| 483 | 519 | } | |
| @@ -579,6 +615,11 @@ func (m *Manager) reload() error { | |||
| 579 | 615 | // Validate ColorScheme setting | |
| 580 | 616 | m.validateColorScheme(config) | |
| 581 | 617 | ||
| 618 | + | // Validate all config values | |
| 619 | + | if err := validateConfig(config); err != nil { | |
| 620 | + | return fmt.Errorf("configuration validation failed: %w", err) | |
| 621 | + | } | |
| 622 | + | ||
| 582 | 623 | m.config = config | |
| 583 | 624 | return nil | |
| 584 | 625 | } | |
| @@ -598,6 +639,7 @@ func (m *Manager) setDefaults() { | |||
| 598 | 639 | ||
| 599 | 640 | // Search shortcuts defaults | |
| 600 | 641 | m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts) | |
| 642 | + | m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine) | |
| 601 | 643 | ||
| 602 | 644 | // Dmenu defaults | |
| 603 | 645 | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | |
| @@ -674,14 +716,17 @@ func (m *Manager) setDefaults() { | |||
| 674 | 716 | m.viper.SetDefault("default_zoom", defaults.DefaultZoom) | |
| 675 | 717 | ||
| 676 | 718 | // Workspace defaults | |
| 677 | - | m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls) | |
| 678 | 719 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | |
| 679 | 720 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | |
| 680 | - | m.viper.SetDefault("workspace.pane_mode.action_bindings", defaults.Workspace.PaneMode.ActionBindings) | |
| 721 | + | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | |
| 722 | + | m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut) | |
| 723 | + | m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds) | |
| 724 | + | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | |
| 681 | 725 | m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab) | |
| 682 | 726 | m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab) | |
| 683 | 727 | m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab) | |
| 684 | 728 | m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab) | |
| 729 | + | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 685 | 730 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | |
| 686 | 731 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | |
| 687 | 732 | m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane) | |
| @@ -692,9 +737,13 @@ func (m *Manager) setDefaults() { | |||
| 692 | 737 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | |
| 693 | 738 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | |
| 694 | 739 | m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor) | |
| 740 | + | m.viper.SetDefault("workspace.styling.inactive_border_width", defaults.Workspace.Styling.InactiveBorderWidth) | |
| 741 | + | m.viper.SetDefault("workspace.styling.inactive_border_color", defaults.Workspace.Styling.InactiveBorderColor) | |
| 742 | + | m.viper.SetDefault("workspace.styling.show_stacked_title_border", defaults.Workspace.Styling.ShowStackedTitleBorder) | |
| 695 | 743 | m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor) | |
| 696 | 744 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 697 | 745 | m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius) | |
| 746 | + | m.viper.SetDefault("workspace.styling.ui_scale", defaults.Workspace.Styling.UIScale) | |
| 698 | 747 | } | |
| 699 | 748 | ||
| 700 | 749 | // createDefaultConfig creates a default configuration file. | |
| @@ -714,12 +763,14 @@ func (m *Manager) createDefaultConfig() error { | |||
| 714 | 763 | return fmt.Errorf("failed to write config file: %w", err) | |
| 715 | 764 | } | |
| 716 | 765 | ||
| 717 | - | fmt.Printf("Created default configuration file: %s\n", configFile) | |
| 766 | + | fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile) | |
| 718 | 767 | ||
| 719 | - | // Generate JSON schema file | |
| 720 | - | if err := GenerateSchemaFile(); err != nil { | |
| 721 | - | // Log error but don't fail config creation | |
| 722 | - | fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err) | |
| 768 | + | // Generate JSON schema file only for JSON configs (used for IDE autocompletion) | |
| 769 | + | if filepath.Ext(configFile) == ".json" { | |
| 770 | + | if err := GenerateSchemaFile(); err != nil { | |
| 771 | + | // Log error but don't fail config creation | |
| 772 | + | fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err) | |
| 773 | + | } | |
| 723 | 774 | } | |
| 724 | 775 | ||
| 725 | 776 | return nil | |
M internal/config/defaults.go +49 -26
| @@ -21,14 +21,22 @@ const ( | |||
| 21 | 21 | // Workspace defaults | |
| 22 | 22 | defaultPaneActivationShortcut = "ctrl+p" | |
| 23 | 23 | defaultPaneTimeoutMilliseconds = 3000 | |
| 24 | + | defaultTabActivationShortcut = "ctrl+t" | |
| 25 | + | defaultTabTimeoutMilliseconds = 3000 | |
| 26 | + | defaultTabBarPosition = "bottom" | |
| 24 | 27 | defaultPopupPlacement = "right" | |
| 25 | 28 | ||
| 26 | 29 | // Workspace styling defaults | |
| 27 | - | defaultBorderWidth = 2 | |
| 28 | - | defaultBorderColor = "@theme_selected_bg_color" | |
| 29 | - | defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator | |
| 30 | - | defaultTransitionDuration = 120 | |
| 31 | - | defaultBorderRadius = 0 | |
| 30 | + | defaultBorderWidth = 1 | |
| 31 | + | defaultBorderColor = "@theme_selected_bg_color" | |
| 32 | + | defaultInactiveBorderWidth = 1 // Same width as active to prevent layout shift | |
| 33 | + | defaultInactiveBorderColor = "@theme_unfocused_bg_color" // GTK theme variable | |
| 34 | + | defaultShowStackedTitleBorder = false // Hidden by default | |
| 35 | + | defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator | |
| 36 | + | defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator (distinct from pane mode) | |
| 37 | + | defaultTransitionDuration = 120 | |
| 38 | + | defaultBorderRadius = 0 | |
| 39 | + | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| 32 | 40 | ) | |
| 33 | 41 | ||
| 34 | 42 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| @@ -51,7 +59,8 @@ func DefaultConfig() *Config { | |||
| 51 | 59 | RetentionPeriodDays: defaultRetentionDays, // 1 year | |
| 52 | 60 | CleanupIntervalDays: 1, // daily cleanup | |
| 53 | 61 | }, | |
| 54 | - | SearchShortcuts: GetDefaultSearchShortcuts(), | |
| 62 | + | SearchShortcuts: GetDefaultSearchShortcuts(), | |
| 63 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |
| 55 | 64 | Dmenu: DmenuConfig{ | |
| 56 | 65 | MaxHistoryItems: defaultMaxHistoryItems, | |
| 57 | 66 | ShowVisitCount: true, | |
| @@ -142,22 +151,30 @@ func DefaultConfig() *Config { | |||
| 142 | 151 | UseDomZoom: false, | |
| 143 | 152 | DefaultZoom: 1.2, // 120% default zoom for better readability | |
| 144 | 153 | Workspace: WorkspaceConfig{ | |
| 145 | - | EnableZellijControls: true, | |
| 146 | 154 | PaneMode: PaneModeConfig{ | |
| 147 | 155 | ActivationShortcut: defaultPaneActivationShortcut, | |
| 148 | 156 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | |
| 149 | - | ActionBindings: map[string]string{ | |
| 150 | - | "arrowright": "split-right", | |
| 151 | - | "arrowleft": "split-left", | |
| 152 | - | "arrowup": "split-up", | |
| 153 | - | "arrowdown": "split-down", | |
| 154 | - | "r": "split-right", | |
| 155 | - | "l": "split-left", | |
| 156 | - | "u": "split-up", | |
| 157 | - | "d": "split-down", | |
| 158 | - | "x": "close-pane", | |
| 159 | - | "enter": "confirm", | |
| 160 | - | "escape": "cancel", | |
| 157 | + | Actions: map[string][]string{ | |
| 158 | + | "split-right": {"arrowright", "r"}, | |
| 159 | + | "split-left": {"arrowleft", "l"}, | |
| 160 | + | "split-up": {"arrowup", "u"}, | |
| 161 | + | "split-down": {"arrowdown", "d"}, | |
| 162 | + | "close-pane": {"x"}, | |
| 163 | + | "confirm": {"enter"}, | |
| 164 | + | "cancel": {"escape"}, | |
| 165 | + | }, | |
| 166 | + | }, | |
| 167 | + | TabMode: TabModeConfig{ | |
| 168 | + | ActivationShortcut: defaultTabActivationShortcut, | |
| 169 | + | TimeoutMilliseconds: defaultTabTimeoutMilliseconds, | |
| 170 | + | Actions: map[string][]string{ | |
| 171 | + | "new-tab": {"n", "c"}, | |
| 172 | + | "close-tab": {"x"}, | |
| 173 | + | "next-tab": {"l", "tab"}, | |
| 174 | + | "previous-tab": {"h", "shift+tab"}, | |
| 175 | + | "rename-tab": {"r"}, | |
| 176 | + | "confirm": {"enter"}, | |
| 177 | + | "cancel": {"escape"}, | |
| 161 | 178 | }, | |
| 162 | 179 | }, | |
| 163 | 180 | Tabs: TabKeyConfig{ | |
| @@ -166,21 +183,27 @@ func DefaultConfig() *Config { | |||
| 166 | 183 | NextTab: "ctrl+tab", | |
| 167 | 184 | PreviousTab: "ctrl+shift+tab", | |
| 168 | 185 | }, | |
| 186 | + | TabBarPosition: defaultTabBarPosition, | |
| 169 | 187 | Popups: PopupBehaviorConfig{ | |
| 170 | - | Behavior: PopupBehaviorSplit, // Default: open popups in split panes | |
| 188 | + | Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes | |
| 171 | 189 | Placement: defaultPopupPlacement, | |
| 172 | 190 | OpenInNewPane: true, | |
| 173 | 191 | FollowPaneContext: true, | |
| 174 | - | BlankTargetBehavior: "pane", // Default to pane, future: "tab" | |
| 192 | + | BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode | |
| 175 | 193 | EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab | |
| 176 | 194 | OAuthAutoClose: true, // Auto-close OAuth popups on success | |
| 177 | 195 | }, | |
| 178 | 196 | Styling: WorkspaceStylingConfig{ | |
| 179 | - | BorderWidth: defaultBorderWidth, | |
| 180 | - | BorderColor: defaultBorderColor, | |
| 181 | - | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 182 | - | TransitionDuration: defaultTransitionDuration, | |
| 183 | - | BorderRadius: defaultBorderRadius, | |
| 197 | + | BorderWidth: defaultBorderWidth, | |
| 198 | + | BorderColor: defaultBorderColor, | |
| 199 | + | InactiveBorderWidth: defaultInactiveBorderWidth, | |
| 200 | + | InactiveBorderColor: defaultInactiveBorderColor, | |
| 201 | + | ShowStackedTitleBorder: defaultShowStackedTitleBorder, | |
| 202 | + | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 203 | + | TabModeBorderColor: defaultTabModeBorderColor, | |
| 204 | + | TransitionDuration: defaultTransitionDuration, | |
| 205 | + | BorderRadius: defaultBorderRadius, | |
| 206 | + | UIScale: defaultUIScale, | |
| 184 | 207 | }, | |
| 185 | 208 | }, | |
| 186 | 209 | ContentFilteringWhitelist: []string{ | |
A internal/config/validation.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 1 | + | // Package config provides validation utilities for configuration values. | |
| 2 | + | package config | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // validateConfig performs comprehensive validation of configuration values | |
| 10 | + | func validateConfig(config *Config) error { | |
| 11 | + | var validationErrors []string | |
| 12 | + | ||
| 13 | + | // Validate numeric ranges | |
| 14 | + | if config.History.MaxEntries < 0 { | |
| 15 | + | validationErrors = append(validationErrors, "history.max_entries must be non-negative") | |
| 16 | + | } | |
| 17 | + | if config.History.RetentionPeriodDays < 0 { | |
| 18 | + | validationErrors = append(validationErrors, "history.retention_period_days must be non-negative") | |
| 19 | + | } | |
| 20 | + | if config.History.CleanupIntervalDays < 0 { | |
| 21 | + | validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | if config.Dmenu.MaxHistoryItems < 0 { | |
| 25 | + | validationErrors = append(validationErrors, "dmenu.max_history_items must be non-negative") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 { | |
| 29 | + | validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | if config.DefaultZoom < 0.1 || config.DefaultZoom > 5.0 { | |
| 33 | + | validationErrors = append(validationErrors, "default_zoom must be between 0.1 and 5.0") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Validate default search engine | |
| 37 | + | if config.DefaultSearchEngine == "" { | |
| 38 | + | validationErrors = append(validationErrors, "default_search_engine cannot be empty") | |
| 39 | + | } else if !strings.Contains(config.DefaultSearchEngine, "%s") { | |
| 40 | + | validationErrors = append(validationErrors, "default_search_engine must contain %s placeholder for the search query") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Validate popup behavior | |
| 44 | + | switch config.Workspace.Popups.Behavior { | |
| 45 | + | case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed: | |
| 46 | + | // Valid | |
| 47 | + | default: | |
| 48 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.Popups.Behavior)) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Validate popup placement for split behavior | |
| 52 | + | if config.Workspace.Popups.Behavior == PopupBehaviorSplit { | |
| 53 | + | switch config.Workspace.Popups.Placement { | |
| 54 | + | case "right", "left", "top", "bottom": | |
| 55 | + | // Valid | |
| 56 | + | default: | |
| 57 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.Popups.Placement)) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Validate blank target behavior | |
| 62 | + | switch config.Workspace.Popups.BlankTargetBehavior { | |
| 63 | + | case "split", "stacked", "tabbed": | |
| 64 | + | // Valid | |
| 65 | + | default: | |
| 66 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.Popups.BlankTargetBehavior)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Validate workspace styling values | |
| 70 | + | if config.Workspace.Styling.BorderWidth < 0 { | |
| 71 | + | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | |
| 72 | + | } | |
| 73 | + | if config.Workspace.Styling.InactiveBorderWidth < 0 { | |
| 74 | + | validationErrors = append(validationErrors, "workspace.styling.inactive_border_width must be non-negative") | |
| 75 | + | } | |
| 76 | + | if config.Workspace.Styling.TransitionDuration < 0 { | |
| 77 | + | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | |
| 78 | + | } | |
| 79 | + | if config.Workspace.Styling.BorderRadius < 0 { | |
| 80 | + | validationErrors = append(validationErrors, "workspace.styling.border_radius must be non-negative") | |
| 81 | + | } | |
| 82 | + | if config.Workspace.Styling.UIScale < 0.5 || config.Workspace.Styling.UIScale > 3.0 { | |
| 83 | + | validationErrors = append(validationErrors, "workspace.styling.ui_scale must be between 0.5 and 3.0") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Validate pane mode timeout | |
| 87 | + | if config.Workspace.PaneMode.TimeoutMilliseconds < 0 { | |
| 88 | + | validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Validate pane mode actions | |
| 92 | + | if len(config.Workspace.PaneMode.Actions) == 0 { | |
| 93 | + | validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Check for duplicate keys and empty action key lists in pane mode | |
| 97 | + | seenKeys := make(map[string]string) | |
| 98 | + | for action, keys := range config.Workspace.PaneMode.Actions { | |
| 99 | + | if len(keys) == 0 { | |
| 100 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action)) | |
| 101 | + | } | |
| 102 | + | for _, key := range keys { | |
| 103 | + | if existingAction, exists := seenKeys[key]; exists { | |
| 104 | + | validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", key, existingAction, action)) | |
| 105 | + | } | |
| 106 | + | seenKeys[key] = action | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Validate tab bar position | |
| 111 | + | switch config.Workspace.TabBarPosition { | |
| 112 | + | case "top", "bottom": | |
| 113 | + | // Valid | |
| 114 | + | default: | |
| 115 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Validate tab mode timeout | |
| 119 | + | if config.Workspace.TabMode.TimeoutMilliseconds < 0 { | |
| 120 | + | validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Validate tab mode actions | |
| 124 | + | if len(config.Workspace.TabMode.Actions) == 0 { | |
| 125 | + | validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Check for duplicate keys and empty action key lists in tab mode | |
| 129 | + | tabSeenKeys := make(map[string]string) | |
| 130 | + | for action, keys := range config.Workspace.TabMode.Actions { | |
| 131 | + | if len(keys) == 0 { | |
| 132 | + | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action)) | |
| 133 | + | } | |
| 134 | + | for _, key := range keys { | |
| 135 | + | if existingAction, exists := tabSeenKeys[key]; exists { | |
| 136 | + | validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", key, existingAction, action)) | |
| 137 | + | } | |
| 138 | + | tabSeenKeys[key] = action | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Validate codec buffer sizes | |
| 143 | + | if config.CodecPreferences.VideoBufferSizeMB < 0 { | |
| 144 | + | validationErrors = append(validationErrors, "codec_preferences.video_buffer_size_mb must be non-negative") | |
| 145 | + | } | |
| 146 | + | if config.CodecPreferences.QueueBufferTimeSec < 0 { | |
| 147 | + | validationErrors = append(validationErrors, "codec_preferences.queue_buffer_time_sec must be non-negative") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Validate AV1 max resolution | |
| 151 | + | if config.CodecPreferences.AV1MaxResolution != "" { | |
| 152 | + | validResolutions := map[string]bool{ | |
| 153 | + | "720p": true, "1080p": true, "1440p": true, "4k": true, "unlimited": true, | |
| 154 | + | } | |
| 155 | + | if !validResolutions[config.CodecPreferences.AV1MaxResolution] { | |
| 156 | + | validationErrors = append(validationErrors, fmt.Sprintf("codec_preferences.av1_max_resolution must be one of: 720p, 1080p, 1440p, 4k, unlimited (got: %s)", config.CodecPreferences.AV1MaxResolution)) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // Validate logging values | |
| 161 | + | if config.Logging.MaxSize < 0 { | |
| 162 | + | validationErrors = append(validationErrors, "logging.max_size must be non-negative") | |
| 163 | + | } | |
| 164 | + | if config.Logging.MaxBackups < 0 { | |
| 165 | + | validationErrors = append(validationErrors, "logging.max_backups must be non-negative") | |
| 166 | + | } | |
| 167 | + | if config.Logging.MaxAge < 0 { | |
| 168 | + | validationErrors = append(validationErrors, "logging.max_age must be non-negative") | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // If there are validation errors, return them | |
| 172 | + | if len(validationErrors) > 0 { | |
| 173 | + | return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | return nil | |
| 177 | + | } | |
M internal/config/xdg.go +1 -1
| @@ -114,7 +114,7 @@ func GetConfigFile() (string, error) { | |||
| 114 | 114 | if err != nil { | |
| 115 | 115 | return "", err | |
| 116 | 116 | } | |
| 117 | - | return filepath.Join(configDir, "config.json"), nil | |
| 117 | + | return filepath.Join(configDir, "config.toml"), nil | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | 120 | // GetDatabaseFile returns the path to the database file in the data directory. | |
A internal/db/favorites.sql.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |
| 2 | + | // versions: | |
| 3 | + | // sqlc v1.30.0 | |
| 4 | + | // source: favorites.sql | |
| 5 | + | ||
| 6 | + | package db | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "database/sql" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const CreateFavorite = `-- name: CreateFavorite :exec | |
| 14 | + | INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at) | |
| 15 | + | VALUES ( | |
| 16 | + | ?, | |
| 17 | + | ?, | |
| 18 | + | ?, | |
| 19 | + | COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), | |
| 20 | + | CURRENT_TIMESTAMP, | |
| 21 | + | CURRENT_TIMESTAMP | |
| 22 | + | ) | |
| 23 | + | ` | |
| 24 | + | ||
| 25 | + | // Insert a new favorite with auto-incremented position | |
| 26 | + | func (q *Queries) CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error { | |
| 27 | + | _, err := q.db.ExecContext(ctx, CreateFavorite, url, title, faviconUrl) | |
| 28 | + | return err | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const DeleteFavorite = `-- name: DeleteFavorite :exec | |
| 32 | + | DELETE FROM favorites | |
| 33 | + | WHERE url = ? | |
| 34 | + | ` | |
| 35 | + | ||
| 36 | + | // Delete a favorite by URL | |
| 37 | + | func (q *Queries) DeleteFavorite(ctx context.Context, url string) error { | |
| 38 | + | _, err := q.db.ExecContext(ctx, DeleteFavorite, url) | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | const GetAllFavorites = `-- name: GetAllFavorites :many | |
| 43 | + | SELECT id, url, title, favicon_url, position, created_at, updated_at | |
| 44 | + | FROM favorites | |
| 45 | + | ORDER BY position ASC | |
| 46 | + | ` | |
| 47 | + | ||
| 48 | + | // Get all favorites ordered by position | |
| 49 | + | func (q *Queries) GetAllFavorites(ctx context.Context) ([]Favorite, error) { | |
| 50 | + | rows, err := q.db.QueryContext(ctx, GetAllFavorites) | |
| 51 | + | if err != nil { | |
| 52 | + | return nil, err | |
| 53 | + | } | |
| 54 | + | defer rows.Close() | |
| 55 | + | items := []Favorite{} | |
| 56 | + | for rows.Next() { | |
| 57 | + | var i Favorite | |
| 58 | + | if err := rows.Scan( | |
| 59 | + | &i.ID, | |
| 60 | + | &i.Url, | |
| 61 | + | &i.Title, | |
| 62 | + | &i.FaviconUrl, | |
| 63 | + | &i.Position, | |
| 64 | + | &i.CreatedAt, | |
| 65 | + | &i.UpdatedAt, | |
| 66 | + | ); err != nil { | |
| 67 | + | return nil, err | |
| 68 | + | } | |
| 69 | + | items = append(items, i) | |
| 70 | + | } | |
| 71 | + | if err := rows.Close(); err != nil { | |
| 72 | + | return nil, err | |
| 73 | + | } | |
| 74 | + | if err := rows.Err(); err != nil { | |
| 75 | + | return nil, err | |
| 76 | + | } | |
| 77 | + | return items, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | const GetFavoriteByURL = `-- name: GetFavoriteByURL :one | |
| 81 | + | SELECT id, url, title, favicon_url, position, created_at, updated_at | |
| 82 | + | FROM favorites | |
| 83 | + | WHERE url = ? | |
| 84 | + | LIMIT 1 | |
| 85 | + | ` | |
| 86 | + | ||
| 87 | + | // Get a specific favorite by URL | |
| 88 | + | func (q *Queries) GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) { | |
| 89 | + | row := q.db.QueryRowContext(ctx, GetFavoriteByURL, url) | |
| 90 | + | var i Favorite | |
| 91 | + | err := row.Scan( | |
| 92 | + | &i.ID, | |
| 93 | + | &i.Url, | |
| 94 | + | &i.Title, | |
| 95 | + | &i.FaviconUrl, | |
| 96 | + | &i.Position, | |
| 97 | + | &i.CreatedAt, | |
| 98 | + | &i.UpdatedAt, | |
| 99 | + | ) | |
| 100 | + | return i, err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | const GetFavoriteCount = `-- name: GetFavoriteCount :one | |
| 104 | + | SELECT COUNT(*) as count | |
| 105 | + | FROM favorites | |
| 106 | + | ` | |
| 107 | + | ||
| 108 | + | // Get total count of favorites | |
| 109 | + | func (q *Queries) GetFavoriteCount(ctx context.Context) (int64, error) { | |
| 110 | + | row := q.db.QueryRowContext(ctx, GetFavoriteCount) | |
| 111 | + | var count int64 | |
| 112 | + | err := row.Scan(&count) | |
| 113 | + | return count, err | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | const IsFavorite = `-- name: IsFavorite :one | |
| 117 | + | SELECT COUNT(*) as count | |
| 118 | + | FROM favorites | |
| 119 | + | WHERE url = ? | |
| 120 | + | LIMIT 1 | |
| 121 | + | ` | |
| 122 | + | ||
| 123 | + | // Check if a URL is favorited | |
| 124 | + | func (q *Queries) IsFavorite(ctx context.Context, url string) (int64, error) { | |
| 125 | + | row := q.db.QueryRowContext(ctx, IsFavorite, url) | |
| 126 | + | var count int64 | |
| 127 | + | err := row.Scan(&count) | |
| 128 | + | return count, err | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | const UpdateFavorite = `-- name: UpdateFavorite :exec | |
| 132 | + | UPDATE favorites | |
| 133 | + | SET | |
| 134 | + | title = ?, | |
| 135 | + | favicon_url = ?, | |
| 136 | + | updated_at = CURRENT_TIMESTAMP | |
| 137 | + | WHERE url = ? | |
| 138 | + | ` | |
| 139 | + | ||
| 140 | + | // Update favorite metadata (title, favicon) | |
| 141 | + | func (q *Queries) UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error { | |
| 142 | + | _, err := q.db.ExecContext(ctx, UpdateFavorite, title, faviconUrl, url) | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | const UpdateFavoritePosition = `-- name: UpdateFavoritePosition :exec | |
| 147 | + | UPDATE favorites | |
| 148 | + | SET | |
| 149 | + | position = ?, | |
| 150 | + | updated_at = CURRENT_TIMESTAMP | |
| 151 | + | WHERE url = ? | |
| 152 | + | ` | |
| 153 | + | ||
| 154 | + | // Update the position of a favorite | |
| 155 | + | func (q *Queries) UpdateFavoritePosition(ctx context.Context, position int64, url string) error { | |
| 156 | + | _, err := q.db.ExecContext(ctx, UpdateFavoritePosition, position, url) | |
| 157 | + | return err | |
| 158 | + | } | |
M internal/db/interfaces.go +13 -0
| @@ -36,11 +36,24 @@ type CertificateQuerier interface { | |||
| 36 | 36 | DeleteExpiredCertificateValidations(ctx context.Context) error | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | // FavoritesQuerier defines the interface for favorites-related database operations | |
| 40 | + | type FavoritesQuerier interface { | |
| 41 | + | GetAllFavorites(ctx context.Context) ([]Favorite, error) | |
| 42 | + | GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) | |
| 43 | + | CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error | |
| 44 | + | UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error | |
| 45 | + | DeleteFavorite(ctx context.Context, url string) error | |
| 46 | + | IsFavorite(ctx context.Context, url string) (int64, error) | |
| 47 | + | UpdateFavoritePosition(ctx context.Context, position int64, url string) error | |
| 48 | + | GetFavoriteCount(ctx context.Context) (int64, error) | |
| 49 | + | } | |
| 50 | + | ||
| 39 | 51 | // DatabaseQuerier combines all database operation interfaces | |
| 40 | 52 | type DatabaseQuerier interface { | |
| 41 | 53 | ZoomQuerier | |
| 42 | 54 | HistoryQuerier | |
| 43 | 55 | CertificateQuerier | |
| 56 | + | FavoritesQuerier | |
| 44 | 57 | } | |
| 45 | 58 | ||
| 46 | 59 | // Ensure that *Queries implements DatabaseQuerier interface | |
M internal/db/models.go +10 -0
| @@ -17,6 +17,16 @@ type CertificateValidation struct { | |||
| 17 | 17 | ExpiresAt sql.NullTime `json:"expires_at"` | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | type Favorite struct { | |
| 21 | + | ID int64 `json:"id"` | |
| 22 | + | Url string `json:"url"` | |
| 23 | + | Title sql.NullString `json:"title"` | |
| 24 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |
| 25 | + | Position int64 `json:"position"` | |
| 26 | + | CreatedAt sql.NullTime `json:"created_at"` | |
| 27 | + | UpdatedAt sql.NullTime `json:"updated_at"` | |
| 28 | + | } | |
| 29 | + | ||
| 20 | 30 | type FuzzyCacheMetadatum struct { | |
| 21 | 31 | ID int64 `json:"id"` | |
| 22 | 32 | Version int64 `json:"version"` | |
M internal/db/querier.go +16 -0
| @@ -13,13 +13,23 @@ type Querier interface { | |||
| 13 | 13 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |
| 14 | 14 | // Cleanup zoom level entries older than specified days | |
| 15 | 15 | CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error | |
| 16 | + | // Insert a new favorite with auto-incremented position | |
| 17 | + | CreateFavorite(ctx context.Context, url string, title sql.NullString, faviconUrl sql.NullString) error | |
| 16 | 18 | DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error | |
| 17 | 19 | DeleteExpiredCertificateValidations(ctx context.Context) error | |
| 20 | + | // Delete a favorite by URL | |
| 21 | + | DeleteFavorite(ctx context.Context, url string) error | |
| 18 | 22 | DeleteHistory(ctx context.Context, id int64) error | |
| 19 | 23 | // Delete zoom level setting for a domain | |
| 20 | 24 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 25 | + | // Get all favorites ordered by position | |
| 26 | + | GetAllFavorites(ctx context.Context) ([]Favorite, error) | |
| 21 | 27 | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | |
| 22 | 28 | GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) | |
| 29 | + | // Get a specific favorite by URL | |
| 30 | + | GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) | |
| 31 | + | // Get total count of favorites | |
| 32 | + | GetFavoriteCount(ctx context.Context) (int64, error) | |
| 23 | 33 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 24 | 34 | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 25 | 35 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| @@ -27,6 +37,8 @@ type Querier interface { | |||
| 27 | 37 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | |
| 28 | 38 | // Get zoom level for domain with default fallback | |
| 29 | 39 | GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error) | |
| 40 | + | // Check if a URL is favorited | |
| 41 | + | IsFavorite(ctx context.Context, url string) (int64, error) | |
| 30 | 42 | ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) | |
| 31 | 43 | // List all zoom level settings ordered by most recently updated | |
| 32 | 44 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |
| @@ -34,6 +46,10 @@ type Querier interface { | |||
| 34 | 46 | // Set or update zoom level for a domain with validation | |
| 35 | 47 | SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error | |
| 36 | 48 | StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error | |
| 49 | + | // Update favorite metadata (title, favicon) | |
| 50 | + | UpdateFavorite(ctx context.Context, title sql.NullString, faviconUrl sql.NullString, url string) error | |
| 51 | + | // Update the position of a favorite | |
| 52 | + | UpdateFavoritePosition(ctx context.Context, position int64, url string) error | |
| 37 | 53 | UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error | |
| 38 | 54 | } | |
| 39 | 55 | ||
A internal/db/queries/favorites.sql +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | -- name: GetAllFavorites :many | |
| 2 | + | -- Get all favorites ordered by position | |
| 3 | + | SELECT * | |
| 4 | + | FROM favorites | |
| 5 | + | ORDER BY position ASC; | |
| 6 | + | ||
| 7 | + | -- name: GetFavoriteByURL :one | |
| 8 | + | -- Get a specific favorite by URL | |
| 9 | + | SELECT * | |
| 10 | + | FROM favorites | |
| 11 | + | WHERE url = ? | |
| 12 | + | LIMIT 1; | |
| 13 | + | ||
| 14 | + | -- name: CreateFavorite :exec | |
| 15 | + | -- Insert a new favorite with auto-incremented position | |
| 16 | + | INSERT INTO favorites (url, title, favicon_url, position, created_at, updated_at) | |
| 17 | + | VALUES ( | |
| 18 | + | ?, | |
| 19 | + | ?, | |
| 20 | + | ?, | |
| 21 | + | COALESCE((SELECT MAX(position) + 1 FROM favorites), 0), | |
| 22 | + | CURRENT_TIMESTAMP, | |
| 23 | + | CURRENT_TIMESTAMP | |
| 24 | + | ); | |
| 25 | + | ||
| 26 | + | -- name: UpdateFavorite :exec | |
| 27 | + | -- Update favorite metadata (title, favicon) | |
| 28 | + | UPDATE favorites | |
| 29 | + | SET | |
| 30 | + | title = ?, | |
| 31 | + | favicon_url = ?, | |
| 32 | + | updated_at = CURRENT_TIMESTAMP | |
| 33 | + | WHERE url = ?; | |
| 34 | + | ||
| 35 | + | -- name: DeleteFavorite :exec | |
| 36 | + | -- Delete a favorite by URL | |
| 37 | + | DELETE FROM favorites | |
| 38 | + | WHERE url = ?; | |
| 39 | + | ||
| 40 | + | -- name: IsFavorite :one | |
| 41 | + | -- Check if a URL is favorited | |
| 42 | + | SELECT COUNT(*) as count | |
| 43 | + | FROM favorites | |
| 44 | + | WHERE url = ? | |
| 45 | + | LIMIT 1; | |
| 46 | + | ||
| 47 | + | -- name: UpdateFavoritePosition :exec | |
| 48 | + | -- Update the position of a favorite | |
| 49 | + | UPDATE favorites | |
| 50 | + | SET | |
| 51 | + | position = ?, | |
| 52 | + | updated_at = CURRENT_TIMESTAMP | |
| 53 | + | WHERE url = ?; | |
| 54 | + | ||
| 55 | + | -- name: GetFavoriteCount :one | |
| 56 | + | -- Get total count of favorites | |
| 57 | + | SELECT COUNT(*) as count | |
| 58 | + | FROM favorites; | |
A internal/migrations/008_add_favorites.sql +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | -- Add favorites table for omnibox favorites feature | |
| 2 | + | -- Store user's favorited URLs with metadata for quick access | |
| 3 | + | ||
| 4 | + | CREATE TABLE IF NOT EXISTS favorites ( | |
| 5 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 6 | + | url TEXT NOT NULL UNIQUE, | |
| 7 | + | title TEXT, | |
| 8 | + | favicon_url TEXT, | |
| 9 | + | position INTEGER NOT NULL DEFAULT 0, | |
| 10 | + | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |
| 11 | + | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| 12 | + | ); | |
| 13 | + | ||
| 14 | + | -- Create index for performance on position for ordered retrieval | |
| 15 | + | CREATE INDEX IF NOT EXISTS idx_favorites_position ON favorites(position); | |
| 16 | + | ||
| 17 | + | -- Create index for URL lookups | |
| 18 | + | CREATE INDEX IF NOT EXISTS idx_favorites_url ON favorites(url); | |
M internal/parser/parser.go +28 -20
| @@ -18,7 +18,7 @@ func (p *Parser) ParseInput(input string) (*ParseResult, error) { | |||
| 18 | 18 | if input == "" { | |
| 19 | 19 | return &ParseResult{ | |
| 20 | 20 | Type: InputTypeFallbackSearch, | |
| 21 | - | URL: "https://www.google.com/search?q=", | |
| 21 | + | URL: fmt.Sprintf(p.config.DefaultSearchEngine, ""), | |
| 22 | 22 | Query: input, | |
| 23 | 23 | Confidence: 0.0, | |
| 24 | 24 | ProcessingTime: time.Since(startTime), | |
| @@ -29,19 +29,29 @@ func (p *Parser) ParseInput(input string) (*ParseResult, error) { | |||
| 29 | 29 | finalURL, err := p.parseInputUsingCLILogic(input) | |
| 30 | 30 | if err != nil { | |
| 31 | 31 | // If parsing fails, fall back to search | |
| 32 | - | finalURL = fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)) | |
| 32 | + | finalURL = fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input)) | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // Determine result type based on the URL | |
| 36 | - | var resultType InputType = InputTypeFallbackSearch | |
| 37 | - | confidence := 0.1 | |
| 36 | + | var resultType InputType | |
| 37 | + | var confidence float64 | |
| 38 | 38 | ||
| 39 | - | if strings.Contains(finalURL, "google.com/search") { | |
| 39 | + | // Check if this is a search query result | |
| 40 | + | isSearchResult := strings.Contains(finalURL, neturl.QueryEscape(input)) | |
| 41 | + | ||
| 42 | + | switch { | |
| 43 | + | case isSearchResult: | |
| 40 | 44 | resultType = InputTypeFallbackSearch | |
| 41 | 45 | confidence = 0.1 | |
| 42 | - | } else if strings.HasPrefix(finalURL, "http://") || strings.HasPrefix(finalURL, "https://") || strings.HasPrefix(finalURL, "dumb://") || strings.HasPrefix(finalURL, "file://") { | |
| 46 | + | case strings.HasPrefix(finalURL, "http://"), | |
| 47 | + | strings.HasPrefix(finalURL, "https://"), | |
| 48 | + | strings.HasPrefix(finalURL, "dumb://"), | |
| 49 | + | strings.HasPrefix(finalURL, "file://"): | |
| 43 | 50 | resultType = InputTypeDirectURL | |
| 44 | 51 | confidence = 1.0 | |
| 52 | + | default: | |
| 53 | + | resultType = InputTypeFallbackSearch | |
| 54 | + | confidence = 0.1 | |
| 45 | 55 | } | |
| 46 | 56 | ||
| 47 | 57 | // Check if it was a shortcut | |
| @@ -140,7 +150,7 @@ func (p *Parser) parseInputUsingCLILogic(input string) (string, error) { | |||
| 140 | 150 | // 8. Everything else is a search query | |
| 141 | 151 | // This includes: single words, multi-word phrases, questions, special characters, etc. | |
| 142 | 152 | // Single words without TLDs should be treated as search queries, not hostnames | |
| 143 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil | |
| 153 | + | return fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input)), nil | |
| 144 | 154 | } | |
| 145 | 155 | ||
| 146 | 156 | // parseSearchShortcut handles search shortcut inputs. | |
| @@ -256,22 +266,16 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear | |||
| 256 | 266 | // buildSearchURL builds a search URL using the default search engine. | |
| 257 | 267 | func (p *Parser) buildSearchURL(searchEngine, query string) string { | |
| 258 | 268 | q := neturl.QueryEscape(query) | |
| 259 | - | // If no specific search engine, use Google as default | |
| 269 | + | // If no specific search engine, use the configured default | |
| 260 | 270 | if searchEngine == "" { | |
| 261 | - | defaultShortcut, exists := p.config.SearchShortcuts["g"] | |
| 262 | - | if exists { | |
| 263 | - | url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q) | |
| 264 | - | url = strings.ReplaceAll(url, "%s", q) | |
| 265 | - | return url | |
| 266 | - | } | |
| 267 | - | // Fallback to Google if no "g" shortcut configured | |
| 268 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 271 | + | return fmt.Sprintf(p.config.DefaultSearchEngine, q) | |
| 269 | 272 | } | |
| 270 | 273 | ||
| 271 | 274 | // Use specified search engine | |
| 272 | 275 | shortcut, exists := p.config.SearchShortcuts[searchEngine] | |
| 273 | 276 | if !exists { | |
| 274 | - | return fmt.Sprintf("https://www.google.com/search?q=%s", q) | |
| 277 | + | // Fallback to default search engine if shortcut not found | |
| 278 | + | return fmt.Sprintf(p.config.DefaultSearchEngine, q) | |
| 275 | 279 | } | |
| 276 | 280 | ||
| 277 | 281 | u := strings.ReplaceAll(shortcut.URL, "{query}", q) | |
| @@ -433,9 +437,13 @@ func (p *Parser) ValidateConfig() error { | |||
| 433 | 437 | return fmt.Errorf("no search shortcuts configured") | |
| 434 | 438 | } | |
| 435 | 439 | ||
| 436 | - | // Check for default search shortcut | |
| 437 | - | if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle { | |
| 438 | - | // Warning: no default search configured | |
| 440 | + | // Check for default search engine | |
| 441 | + | if p.config.DefaultSearchEngine == "" { | |
| 442 | + | return fmt.Errorf("default search engine is not configured") | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | if !strings.Contains(p.config.DefaultSearchEngine, "%s") { | |
| 446 | + | return fmt.Errorf("default search engine must contain %%s placeholder") | |
| 439 | 447 | } | |
| 440 | 448 | ||
| 441 | 449 | return nil | |
M internal/services/browser.go +90 -0
| @@ -50,6 +50,7 @@ type BrowserService struct { | |||
| 50 | 50 | guiBundle string | |
| 51 | 51 | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | |
| 52 | 52 | certCache *cache.CertValidationsCache // In-memory cache for certificate validations | |
| 53 | + | favoritesCache *cache.FavoritesCache // In-memory cache for favorites | |
| 53 | 54 | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu | |
| 54 | 55 | historyQueue chan historyUpdate // Queue for batched history writes | |
| 55 | 56 | historyFlushDone chan bool // Signal when history flush is complete (buffered) | |
| @@ -97,6 +98,7 @@ func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserS | |||
| 97 | 98 | guiBundle: "", | |
| 98 | 99 | zoomCache: cache.NewZoomCache(queries), | |
| 99 | 100 | certCache: cache.NewCertValidationsCache(queries), | |
| 101 | + | favoritesCache: cache.NewFavoritesCache(queries), | |
| 100 | 102 | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | |
| 101 | 103 | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |
| 102 | 104 | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |
| @@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |||
| 576 | 578 | return nil | |
| 577 | 579 | } | |
| 578 | 580 | ||
| 581 | + | // LoadFavoritesCacheFromDB loads all favorites from the database into the cache. | |
| 582 | + | // This is typically called once at startup for fast RAM-first access. | |
| 583 | + | func (s *BrowserService) LoadFavoritesCacheFromDB(ctx context.Context) error { | |
| 584 | + | if err := s.favoritesCache.Load(ctx); err != nil { | |
| 585 | + | return fmt.Errorf("failed to load favorites: %w", err) | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | loadedCount := len(s.favoritesCache.List()) | |
| 589 | + | log.Printf("Loaded %d favorites into cache", loadedCount) | |
| 590 | + | return nil | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | // FavoriteEntry represents a favorite for the frontend (similar to HistoryEntry). | |
| 594 | + | type FavoriteEntry struct { | |
| 595 | + | ID int64 `json:"id"` | |
| 596 | + | URL string `json:"url"` | |
| 597 | + | Title string `json:"title"` | |
| 598 | + | FaviconURL string `json:"favicon_url"` | |
| 599 | + | Position int64 `json:"position"` | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | // GetFavorites returns all favorites ordered by position. | |
| 603 | + | func (s *BrowserService) GetFavorites(ctx context.Context) ([]FavoriteEntry, error) { | |
| 604 | + | favorites := s.favoritesCache.GetAll() | |
| 605 | + | ||
| 606 | + | result := make([]FavoriteEntry, len(favorites)) | |
| 607 | + | for i, fav := range favorites { | |
| 608 | + | result[i] = FavoriteEntry{ | |
| 609 | + | ID: fav.ID, | |
| 610 | + | URL: fav.Url, | |
| 611 | + | Title: fav.Title.String, | |
| 612 | + | FaviconURL: fav.FaviconUrl.String, | |
| 613 | + | Position: fav.Position, | |
| 614 | + | } | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | return result, nil | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | // IsFavorite checks if a URL is in the favorites list. | |
| 621 | + | func (s *BrowserService) IsFavorite(ctx context.Context, url string) (bool, error) { | |
| 622 | + | if url == "" { | |
| 623 | + | return false, nil | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | _, exists := s.favoritesCache.Get(url) | |
| 627 | + | return exists, nil | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // ToggleFavorite adds or removes a URL from favorites. | |
| 631 | + | // Returns true if the URL was added, false if it was removed. | |
| 632 | + | func (s *BrowserService) ToggleFavorite(ctx context.Context, url, title, faviconURL string) (bool, error) { | |
| 633 | + | if url == "" { | |
| 634 | + | return false, fmt.Errorf("URL cannot be empty") | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // Check if already favorited | |
| 638 | + | _, exists := s.favoritesCache.Get(url) | |
| 639 | + | ||
| 640 | + | if exists { | |
| 641 | + | // Remove from favorites | |
| 642 | + | if err := s.favoritesCache.Delete(url); err != nil { | |
| 643 | + | return false, fmt.Errorf("failed to remove favorite: %w", err) | |
| 644 | + | } | |
| 645 | + | log.Printf("Removed favorite: %s", url) | |
| 646 | + | return false, nil | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | // Add to favorites | |
| 650 | + | favorite := db.Favorite{ | |
| 651 | + | Url: url, | |
| 652 | + | Title: sql.NullString{String: title, Valid: title != ""}, | |
| 653 | + | FaviconUrl: sql.NullString{String: faviconURL, Valid: faviconURL != ""}, | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | if err := s.favoritesCache.Set(url, favorite); err != nil { | |
| 657 | + | return false, fmt.Errorf("failed to add favorite: %w", err) | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | log.Printf("Added favorite: %s", url) | |
| 661 | + | return true, nil | |
| 662 | + | } | |
| 663 | + | ||
| 579 | 664 | // FlushAllCaches flushes all in-memory caches to ensure pending writes complete. | |
| 580 | 665 | // This is typically called during shutdown to prevent data loss. | |
| 581 | 666 | func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |
| @@ -589,6 +674,11 @@ func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |||
| 589 | 674 | return fmt.Errorf("failed to flush cert cache: %w", err) | |
| 590 | 675 | } | |
| 591 | 676 | ||
| 677 | + | // Flush favorites cache | |
| 678 | + | if err := s.favoritesCache.Flush(); err != nil { | |
| 679 | + | return fmt.Errorf("failed to flush favorites cache: %w", err) | |
| 680 | + | } | |
| 681 | + | ||
| 592 | 682 | // Save fuzzy cache to binary file for instant dmenu access | |
| 593 | 683 | s.fuzzyCache.OnApplicationExit(ctx) | |
| 594 | 684 | ||
M internal/services/favicon_service.go +59 -10
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "log" | |
| 10 | 10 | "os" | |
| 11 | 11 | "path/filepath" | |
| 12 | + | "sync" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/dumber/internal/db" | |
| @@ -28,6 +29,14 @@ type FaviconService struct { | |||
| 28 | 29 | exportDir string // Directory for exporting favicons for CLI access | |
| 29 | 30 | dataDir string // WebKit data directory | |
| 30 | 31 | enableExport bool // Whether to export favicons for CLI tools | |
| 32 | + | ||
| 33 | + | // Deduplication: tracks recently processed (pageURL, faviconURI) pairs | |
| 34 | + | // Key: pageURL, Value: struct{faviconURI string, timestamp time.Time} | |
| 35 | + | recentChanges sync.Map | |
| 36 | + | ||
| 37 | + | // Debouncing: tracks ongoing preload operations to prevent duplicates | |
| 38 | + | // Key: pageURL, Value: bool (true if preload is in progress) | |
| 39 | + | ongoingPreloads sync.Map | |
| 31 | 40 | } | |
| 32 | 41 | ||
| 33 | 42 | const ( | |
| @@ -35,6 +44,8 @@ const ( | |||
| 35 | 44 | DefaultFaviconSize = 32 | |
| 36 | 45 | // FaviconTimeout is the maximum time to wait for a favicon to be available | |
| 37 | 46 | FaviconTimeout = 5 * time.Second | |
| 47 | + | // DeduplicationWindow is the time window for ignoring duplicate favicon changes | |
| 48 | + | DeduplicationWindow = 2 * time.Second | |
| 38 | 49 | ) | |
| 39 | 50 | ||
| 40 | 51 | // ServiceName returns the service name for identification | |
| @@ -83,28 +94,66 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue | |||
| 83 | 94 | // OnFaviconChanged handles favicon URI changes from WebKit's favicon database. | |
| 84 | 95 | // We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture. | |
| 85 | 96 | func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error { | |
| 86 | - | log.Printf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI) | |
| 87 | - | ||
| 88 | - | ctx := context.Background() | |
| 89 | - | ||
| 90 | 97 | if !fs.shouldProcessURL(pageURL) { | |
| 91 | 98 | return nil | |
| 92 | 99 | } | |
| 93 | 100 | ||
| 101 | + | // FIRST: Check if WebKit's FaviconDatabase already has this exact URI | |
| 102 | + | // WebKit's database persists across sessions and handles caching internally | |
| 103 | + | // If it's already stored, this is just a notification on refresh/reload - skip it | |
| 104 | + | existingURI := fs.faviconDB.FaviconURI(pageURL) | |
| 105 | + | if existingURI == faviconURI { | |
| 106 | + | // WebKit already has this favicon cached - nothing to do | |
| 107 | + | // This is the common case on refresh/new window | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Deduplication for rapid-fire events on initial page load | |
| 112 | + | // (e.g., GitHub fires PNG then SVG within milliseconds) | |
| 113 | + | // Keep the first one we see and ignore subsequent variants | |
| 114 | + | type recentChange struct { | |
| 115 | + | faviconURI string | |
| 116 | + | timestamp time.Time | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | if cached, ok := fs.recentChanges.Load(pageURL); ok { | |
| 120 | + | if rc, ok := cached.(recentChange); ok { | |
| 121 | + | if time.Since(rc.timestamp) < DeduplicationWindow { | |
| 122 | + | // Already processed a favicon for this URL recently | |
| 123 | + | // This prevents flip-flopping between PNG/SVG/ICO variants | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // NEW favicon detected (first time or actual change) | |
| 130 | + | log.Printf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI) | |
| 131 | + | ||
| 132 | + | ctx := context.Background() | |
| 133 | + | ||
| 134 | + | // Update our history database with the favicon URI | |
| 94 | 135 | nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""} | |
| 95 | 136 | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil { | |
| 96 | 137 | log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err) | |
| 97 | 138 | return fmt.Errorf("failed to update favicon in database: %w", err) | |
| 98 | 139 | } | |
| 99 | 140 | ||
| 100 | - | // Also update all other URLs from the same domain to propagate favicon | |
| 101 | - | // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar) | |
| 102 | - | fs.propagateFaviconToDomain(pageURL, faviconURI) | |
| 141 | + | // Store this change as recently processed | |
| 142 | + | fs.recentChanges.Store(pageURL, recentChange{faviconURI: faviconURI, timestamp: time.Now()}) | |
| 103 | 143 | ||
| 104 | - | // Proactively render and export favicon for omnibox/CLI use | |
| 105 | - | // This ensures favicons are available ASAP for suggestions | |
| 144 | + | // Propagate favicon to domain asynchronously to avoid blocking WebKit | |
| 145 | + | go fs.propagateFaviconToDomain(pageURL, faviconURI) | |
| 146 | + | ||
| 147 | + | // Proactively render and export favicon for omnibox/CLI use (debounced) | |
| 106 | 148 | if fs.enableExport { | |
| 107 | - | go fs.preloadFavicon(pageURL, faviconURI) | |
| 149 | + | // Check if a preload is already in progress for this URL | |
| 150 | + | if _, loaded := fs.ongoingPreloads.LoadOrStore(pageURL, true); !loaded { | |
| 151 | + | // No preload in progress, start one | |
| 152 | + | go func() { | |
| 153 | + | defer fs.ongoingPreloads.Delete(pageURL) | |
| 154 | + | fs.preloadFavicon(pageURL, faviconURI) | |
| 155 | + | }() | |
| 156 | + | } | |
| 108 | 157 | } | |
| 109 | 158 | ||
| 110 | 159 | return nil | |
M main.go +6 -2
| @@ -33,8 +33,12 @@ func main() { | |||
| 33 | 33 | return | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | // Initialize logging early for GUI mode using default config | |
| 37 | - | cfg := config.New() | |
| 36 | + | // Load config file first, then initialize logging with actual config | |
| 37 | + | if err := config.Init(); err != nil { | |
| 38 | + | log.Printf("Warning: failed to load config: %v, using defaults", err) | |
| 39 | + | } | |
| 40 | + | cfg := config.Get() | |
| 41 | + | ||
| 38 | 42 | if err := logging.Init( | |
| 39 | 43 | cfg.Logging.LogDir, | |
| 40 | 44 | cfg.Logging.Level, | |
M pkg/webkit/helpers.go +35 -0
| @@ -171,6 +171,23 @@ func WidgetGetBounds(w gtk.Widgetter) (x, y, width, height float64) { | |||
| 171 | 171 | return 0, 0, 0, 0 | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | // WidgetGetWindowBounds returns the widget's bounds relative to the toplevel window | |
| 175 | + | // This provides window-absolute coordinates needed for navigation geometry calculations | |
| 176 | + | func WidgetGetWindowBounds(w gtk.Widgetter) (x, y, width, height int) { | |
| 177 | + | if widget := getWidget(w); widget != nil { | |
| 178 | + | // Get the toplevel window (GtkWindow) | |
| 179 | + | root := widget.Root() | |
| 180 | + | if root != nil { | |
| 181 | + | rect, ok := widget.ComputeBounds(root) | |
| 182 | + | if ok && rect != nil { | |
| 183 | + | return int(rect.X()), int(rect.Y()), | |
| 184 | + | int(rect.Width()), int(rect.Height()) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return 0, 0, 0, 0 | |
| 189 | + | } | |
| 190 | + | ||
| 174 | 191 | // WidgetSetFocusChild sets the focus child of a widget | |
| 175 | 192 | func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) { | |
| 176 | 193 | if widget := getWidget(w); widget != nil { | |
| @@ -233,6 +250,24 @@ func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) { | |||
| 233 | 250 | } | |
| 234 | 251 | } | |
| 235 | 252 | ||
| 253 | + | // WidgetAttachClickHandler attaches a left-click handler to a widget | |
| 254 | + | func WidgetAttachClickHandler(w gtk.Widgetter, callback func()) *gtk.GestureClick { | |
| 255 | + | widget := getWidget(w) | |
| 256 | + | if widget == nil { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | gestureClick := gtk.NewGestureClick() | |
| 261 | + | gestureClick.SetButton(1) // Left click only | |
| 262 | + | ||
| 263 | + | gestureClick.ConnectPressed(func(nPress int, x, y float64) { | |
| 264 | + | callback() | |
| 265 | + | }) | |
| 266 | + | ||
| 267 | + | widget.AddController(gestureClick) | |
| 268 | + | return gestureClick | |
| 269 | + | } | |
| 270 | + | ||
| 236 | 271 | // WidgetAllocation returns the widget's allocation (alias for WidgetGetAllocation) | |
| 237 | 272 | func WidgetAllocation(w gtk.Widgetter) (x, y, width, height int) { | |
| 238 | 273 | return WidgetGetAllocation(w) | |
M pkg/webkit/keyboard_bridge.go +15 -0
| @@ -41,6 +41,9 @@ const ( | |||
| 41 | 41 | keyW = "w" | |
| 42 | 42 | keyX = "x" | |
| 43 | 43 | keyS = "s" | |
| 44 | + | keyH = "h" | |
| 45 | + | keyJ = "j" | |
| 46 | + | keyK = "k" | |
| 44 | 47 | keyEscape = "escape" | |
| 45 | 48 | keyArrowLeft = "arrowleft" | |
| 46 | 49 | keyArrowRight = "arrowright" | |
| @@ -92,6 +95,12 @@ var shortcutActions = map[string]string{ | |||
| 92 | 95 | altmod + "+" + keyArrowRight: directionRight, | |
| 93 | 96 | altmod + "+" + keyArrowDown: directionDown, | |
| 94 | 97 | altmod + "+" + keyArrowUp: directionUp, | |
| 98 | + | ||
| 99 | + | // Vim-style navigation (alt modifier) | |
| 100 | + | altmod + "+" + keyH: directionLeft, | |
| 101 | + | altmod + "+" + keyL: directionRight, | |
| 102 | + | altmod + "+" + keyK: directionUp, | |
| 103 | + | altmod + "+" + keyJ: directionDown, | |
| 95 | 104 | } | |
| 96 | 105 | ||
| 97 | 106 | // TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30) | |
| @@ -161,6 +170,12 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 161 | 170 | keyName = keyW | |
| 162 | 171 | case 'x', 'X': | |
| 163 | 172 | keyName = keyX | |
| 173 | + | case 'h', 'H': | |
| 174 | + | keyName = keyH | |
| 175 | + | case 'j', 'J': | |
| 176 | + | keyName = keyJ | |
| 177 | + | case 'k', 'K': | |
| 178 | + | keyName = keyK | |
| 164 | 179 | case 's', 'S': | |
| 165 | 180 | keyName = keyS | |
| 166 | 181 | case gdkKeyLeft: | |
M pkg/webkit/mouse_gestures.go +1 -6
| @@ -42,7 +42,6 @@ func (w *WebView) AttachMouseGestures() { | |||
| 42 | 42 | ||
| 43 | 43 | switch button { | |
| 44 | 44 | case mouseButtonBack: | |
| 45 | - | // Mouse back button - navigate backward | |
| 46 | 45 | if w.view.CanGoBack() { | |
| 47 | 46 | log.Printf("[mouse-gestures] Back button clicked - navigating backward") | |
| 48 | 47 | w.view.GoBack() | |
| @@ -51,21 +50,17 @@ func (w *WebView) AttachMouseGestures() { | |||
| 51 | 50 | } | |
| 52 | 51 | ||
| 53 | 52 | case mouseButtonForward: | |
| 54 | - | // Mouse forward button - navigate forward | |
| 55 | 53 | if w.view.CanGoForward() { | |
| 56 | 54 | log.Printf("[mouse-gestures] Forward button clicked - navigating forward") | |
| 57 | 55 | w.view.GoForward() | |
| 58 | 56 | } else { | |
| 59 | 57 | log.Printf("[mouse-gestures] Forward button clicked but cannot go forward") | |
| 60 | 58 | } | |
| 61 | - | ||
| 62 | - | default: | |
| 63 | - | // Don't handle other buttons (let WebView handle normally) | |
| 64 | - | return | |
| 65 | 59 | } | |
| 66 | 60 | }) | |
| 67 | 61 | ||
| 68 | 62 | // Attach controller to WebView | |
| 69 | 63 | w.view.AddController(gestureClick) | |
| 64 | + | ||
| 70 | 65 | log.Printf("[mouse-gestures] Mouse gesture controller attached to WebView ID %d", w.id) | |
| 71 | 66 | } | |
M pkg/webkit/shortcuts.go +6 -0
| @@ -138,6 +138,12 @@ func convertToGtkFormat(key string) string { | |||
| 138 | 138 | parts = append(parts, "Up") | |
| 139 | 139 | case "down", "arrowdown": | |
| 140 | 140 | parts = append(parts, "Down") | |
| 141 | + | case "tab": | |
| 142 | + | parts = append(parts, "Tab") | |
| 143 | + | case "escape": | |
| 144 | + | parts = append(parts, "Escape") | |
| 145 | + | case "return", "enter": | |
| 146 | + | parts = append(parts, "Return") | |
| 141 | 147 | default: | |
| 142 | 148 | // Single letter keys like l, f, c, w, etc. | |
| 143 | 149 | parts = append(parts, part) | |
M pkg/webkit/user_content.go +105 -25
| @@ -9,6 +9,13 @@ import ( | |||
| 9 | 9 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | const ( | |
| 13 | + | // DumberIsolatedWorld is the name of the isolated JavaScript world | |
| 14 | + | // where Dumber's GUI scripts run. This prevents page scripts from | |
| 15 | + | // interfering with the browser's UI components. | |
| 16 | + | DumberIsolatedWorld = "dumber-isolated" | |
| 17 | + | ) | |
| 18 | + | ||
| 12 | 19 | // SetupUserContentManager configures UserContentManager for the WebView | |
| 13 | 20 | // This injects GUI scripts at document-start and registers message handlers | |
| 14 | 21 | func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error { | |
| @@ -23,22 +30,35 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, | |||
| 23 | 30 | return nil | |
| 24 | 31 | } | |
| 25 | 32 | ||
| 26 | - | // Inject webview ID FIRST, so GUI scripts can access it immediately | |
| 33 | + | // Inject webview ID in BOTH main world and isolated world | |
| 34 | + | // The isolated world (GUI scripts) needs this, and pages might use it for communication | |
| 27 | 35 | // Note: webviewID is uint64, formatted as number (not string) to avoid any injection concerns | |
| 28 | 36 | webviewIDScript := fmt.Sprintf(` | |
| 29 | 37 | window.__dumber_webview_id = %d; | |
| 30 | 38 | console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id); | |
| 31 | 39 | `, webviewID) | |
| 40 | + | ||
| 41 | + | // Main world (for page scripts) | |
| 32 | 42 | ucm.AddScript(webkit.NewUserScript( | |
| 33 | 43 | webviewIDScript, | |
| 34 | - | webkit.UserContentInjectTopFrame, // Only top frame needs the webview ID | |
| 44 | + | webkit.UserContentInjectTopFrame, | |
| 45 | + | webkit.UserScriptInjectAtDocumentStart, | |
| 46 | + | nil, | |
| 47 | + | nil, | |
| 48 | + | )) | |
| 49 | + | ||
| 50 | + | // Isolated world (for GUI scripts) | |
| 51 | + | ucm.AddScript(webkit.NewUserScriptForWorld( | |
| 52 | + | webviewIDScript, | |
| 53 | + | webkit.UserContentInjectTopFrame, | |
| 35 | 54 | webkit.UserScriptInjectAtDocumentStart, | |
| 55 | + | DumberIsolatedWorld, | |
| 36 | 56 | nil, | |
| 37 | 57 | nil, | |
| 38 | 58 | )) | |
| 39 | - | log.Printf("[webkit] Injected webview ID script for ID: %d", webviewID) | |
| 59 | + | log.Printf("[webkit] Injected webview ID script for ID: %d (main + isolated world)", webviewID) | |
| 40 | 60 | ||
| 41 | - | // Inject GTK theme detection SECOND, before color-scheme script | |
| 61 | + | // Inject GTK theme detection in BOTH worlds | |
| 42 | 62 | // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set | |
| 43 | 63 | // Respect the ColorScheme config setting | |
| 44 | 64 | cfg := config.Get() | |
| @@ -57,71 +77,131 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, | |||
| 57 | 77 | } | |
| 58 | 78 | ||
| 59 | 79 | gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark) | |
| 80 | + | ||
| 81 | + | // Main world | |
| 60 | 82 | ucm.AddScript(webkit.NewUserScript( | |
| 61 | 83 | gtkThemeScript, | |
| 62 | - | webkit.UserContentInjectTopFrame, // Only top frame needs theme preference | |
| 84 | + | webkit.UserContentInjectTopFrame, | |
| 63 | 85 | webkit.UserScriptInjectAtDocumentStart, | |
| 64 | 86 | nil, | |
| 65 | 87 | nil, | |
| 66 | 88 | )) | |
| 67 | - | log.Printf("[webkit] Injected theme preference: prefersDark=%t", prefersDark) | |
| 68 | 89 | ||
| 69 | - | // Inject palette config SECOND, before GUI scripts | |
| 90 | + | // Isolated world | |
| 91 | + | ucm.AddScript(webkit.NewUserScriptForWorld( | |
| 92 | + | gtkThemeScript, | |
| 93 | + | webkit.UserContentInjectTopFrame, | |
| 94 | + | webkit.UserScriptInjectAtDocumentStart, | |
| 95 | + | DumberIsolatedWorld, | |
| 96 | + | nil, | |
| 97 | + | nil, | |
| 98 | + | )) | |
| 99 | + | log.Printf("[webkit] Injected theme preference: prefersDark=%t (main + isolated world)", prefersDark) | |
| 100 | + | ||
| 101 | + | // Inject palette config in BOTH worlds | |
| 70 | 102 | // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} } | |
| 71 | 103 | if appearanceConfigJSON != "" { | |
| 72 | 104 | paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON) | |
| 105 | + | ||
| 106 | + | // Main world | |
| 73 | 107 | ucm.AddScript(webkit.NewUserScript( | |
| 74 | 108 | paletteScript, | |
| 75 | - | webkit.UserContentInjectTopFrame, // Only top frame needs palette config | |
| 109 | + | webkit.UserContentInjectTopFrame, | |
| 76 | 110 | webkit.UserScriptInjectAtDocumentStart, | |
| 77 | 111 | nil, | |
| 78 | 112 | nil, | |
| 79 | 113 | )) | |
| 80 | - | log.Printf("[webkit] Injected palette config at document-start (%d bytes)", len(paletteScript)) | |
| 114 | + | ||
| 115 | + | // Isolated world | |
| 116 | + | ucm.AddScript(webkit.NewUserScriptForWorld( | |
| 117 | + | paletteScript, | |
| 118 | + | webkit.UserContentInjectTopFrame, | |
| 119 | + | webkit.UserScriptInjectAtDocumentStart, | |
| 120 | + | DumberIsolatedWorld, | |
| 121 | + | nil, | |
| 122 | + | nil, | |
| 123 | + | )) | |
| 124 | + | log.Printf("[webkit] Injected palette config (%d bytes, main + isolated world)", len(paletteScript)) | |
| 81 | 125 | } | |
| 82 | 126 | ||
| 83 | - | // Inject color-scheme script at document-start | |
| 127 | + | // Inject color-scheme script in BOTH worlds | |
| 128 | + | // Main world: manipulates document.documentElement.classList for page scripts | |
| 129 | + | // Isolated world: sets CSS variables that GUI scripts can read via getComputedStyle | |
| 84 | 130 | if assets.ColorSchemeScript != "" { | |
| 131 | + | // Main world | |
| 85 | 132 | ucm.AddScript(webkit.NewUserScript( | |
| 86 | 133 | assets.ColorSchemeScript, | |
| 87 | - | webkit.UserContentInjectTopFrame, // Only top frame needs color scheme detection | |
| 134 | + | webkit.UserContentInjectTopFrame, | |
| 88 | 135 | webkit.UserScriptInjectAtDocumentStart, | |
| 89 | - | nil, // whitelist (nil = all) | |
| 90 | - | nil, // blacklist (nil = none) | |
| 136 | + | nil, | |
| 137 | + | nil, | |
| 91 | 138 | )) | |
| 92 | - | log.Printf("[webkit] Injected color-scheme script (%d bytes)", len(assets.ColorSchemeScript)) | |
| 139 | + | ||
| 140 | + | // Isolated world - needed so GUI scripts can access CSS variables | |
| 141 | + | ucm.AddScript(webkit.NewUserScriptForWorld( | |
| 142 | + | assets.ColorSchemeScript, | |
| 143 | + | webkit.UserContentInjectTopFrame, | |
| 144 | + | webkit.UserScriptInjectAtDocumentStart, | |
| 145 | + | DumberIsolatedWorld, | |
| 146 | + | nil, | |
| 147 | + | nil, | |
| 148 | + | )) | |
| 149 | + | log.Printf("[webkit] Injected color-scheme script (%d bytes, main + isolated world)", len(assets.ColorSchemeScript)) | |
| 93 | 150 | } | |
| 94 | 151 | ||
| 95 | - | // Inject main-world script at document-start | |
| 152 | + | // Inject main-world script in MAIN world | |
| 153 | + | // This script needs webkit.messageHandlers access to forward isolated world messages | |
| 154 | + | // It also handles theme, zoom, and other main-world only APIs | |
| 96 | 155 | if assets.MainWorldScript != "" { | |
| 97 | 156 | ucm.AddScript(webkit.NewUserScript( | |
| 98 | 157 | assets.MainWorldScript, | |
| 99 | - | webkit.UserContentInjectTopFrame, // Only top frame needs bridge/UI functionality | |
| 158 | + | webkit.UserContentInjectTopFrame, | |
| 100 | 159 | webkit.UserScriptInjectAtDocumentStart, | |
| 101 | 160 | nil, | |
| 102 | 161 | nil, | |
| 103 | 162 | )) | |
| 104 | - | log.Printf("[webkit] Injected main-world script (%d bytes)", len(assets.MainWorldScript)) | |
| 163 | + | log.Printf("[webkit] Injected main-world script (%d bytes, main world)", len(assets.MainWorldScript)) | |
| 105 | 164 | } | |
| 106 | 165 | ||
| 107 | - | // Inject GUI controls script at document-start | |
| 166 | + | // Inject GUI controls script in ISOLATED world only | |
| 167 | + | // This contains Svelte components and must be protected from page interference | |
| 108 | 168 | if assets.GUIScript != "" { | |
| 109 | - | ucm.AddScript(webkit.NewUserScript( | |
| 169 | + | ucm.AddScript(webkit.NewUserScriptForWorld( | |
| 110 | 170 | assets.GUIScript, | |
| 111 | - | webkit.UserContentInjectTopFrame, // Only top frame needs GUI controls | |
| 171 | + | webkit.UserContentInjectTopFrame, | |
| 172 | + | webkit.UserScriptInjectAtDocumentStart, | |
| 173 | + | DumberIsolatedWorld, | |
| 174 | + | nil, | |
| 175 | + | nil, | |
| 176 | + | )) | |
| 177 | + | log.Printf("[webkit] Injected GUI controls script (%d bytes, isolated world)", len(assets.GUIScript)) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Inject component CSS styles as a JavaScript variable in ISOLATED world | |
| 181 | + | // These styles need to be injected into the shadow root by shadowHost.ts | |
| 182 | + | // because Shadow DOM has style encapsulation - external stylesheets don't penetrate it | |
| 183 | + | if assets.ComponentStyles != "" { | |
| 184 | + | // Use fmt.Sprintf with %q to properly escape the CSS string for JavaScript | |
| 185 | + | componentStylesScript := fmt.Sprintf(`window.__dumber_component_styles = %q;`, assets.ComponentStyles) | |
| 186 | + | ||
| 187 | + | ucm.AddScript(webkit.NewUserScriptForWorld( | |
| 188 | + | componentStylesScript, | |
| 189 | + | webkit.UserContentInjectTopFrame, | |
| 112 | 190 | webkit.UserScriptInjectAtDocumentStart, | |
| 191 | + | DumberIsolatedWorld, | |
| 113 | 192 | nil, | |
| 114 | 193 | nil, | |
| 115 | 194 | )) | |
| 116 | - | log.Printf("[webkit] Injected GUI controls script (%d bytes)", len(assets.GUIScript)) | |
| 195 | + | log.Printf("[webkit] Injected component styles string (%d bytes, isolated world)", len(assets.ComponentStyles)) | |
| 117 | 196 | } | |
| 118 | 197 | ||
| 119 | - | // Register script message handler "dumber" for communication from JS | |
| 120 | - | // Pass empty string for worldName to use the default world | |
| 198 | + | // Register script message handler "dumber" in the MAIN world | |
| 199 | + | // Isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler | |
| 200 | + | // This architecture is required because webkit.messageHandlers is only available in main world | |
| 121 | 201 | if !ucm.RegisterScriptMessageHandler("dumber", "") { | |
| 122 | - | log.Printf("[webkit] Warning: failed to register 'dumber' script message handler") | |
| 202 | + | log.Printf("[webkit] Warning: failed to register 'dumber' script message handler in main world") | |
| 123 | 203 | } else { | |
| 124 | - | log.Printf("[webkit] Registered 'dumber' script message handler") | |
| 204 | + | log.Printf("[webkit] Registered 'dumber' script message handler in main world (default)") | |
| 125 | 205 | } | |
| 126 | 206 | ||
| 127 | 207 | return nil | |
M pkg/webkit/webview.go +108 -0
| @@ -38,6 +38,7 @@ type WebView struct { | |||
| 38 | 38 | onFaviconChanged func([]byte) | |
| 39 | 39 | onFaviconURIChanged func(pageURI, faviconURI string) | |
| 40 | 40 | onZoomChanged func(float64) | |
| 41 | + | onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom) | |
| 41 | 42 | onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler | |
| 42 | 43 | onReadyToShow func() // WebKit ready-to-show signal handler | |
| 43 | 44 | onClose func() | |
| @@ -46,6 +47,7 @@ type WebView struct { | |||
| 46 | 47 | onWorkspaceNavigation func(direction string) bool // Workspace pane navigation | |
| 47 | 48 | onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit) | |
| 48 | 49 | isPaneModeActive func() bool // Check if pane mode is active | |
| 50 | + | onMiddleClickLink func(url string) bool // Middle mouse click on link handler | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 51 | 53 | // NewWebView creates a new WebView with the given configuration | |
| @@ -233,6 +235,14 @@ func (w *WebView) setupEventHandlers() { | |||
| 233 | 235 | } | |
| 234 | 236 | }) | |
| 235 | 237 | ||
| 238 | + | // Load committed - connect to load-changed signal for WEBKIT_LOAD_COMMITTED | |
| 239 | + | w.view.ConnectLoadChanged(func(loadEvent webkit.LoadEvent) { | |
| 240 | + | if loadEvent == webkit.LoadCommitted && w.onLoadCommitted != nil { | |
| 241 | + | uri := w.view.URI() | |
| 242 | + | w.onLoadCommitted(uri) | |
| 243 | + | } | |
| 244 | + | }) | |
| 245 | + | ||
| 236 | 246 | // Favicon changed - connect to FaviconDatabase | |
| 237 | 247 | w.setupFaviconHandlers() | |
| 238 | 248 | ||
| @@ -290,6 +300,85 @@ func (w *WebView) setupEventHandlers() { | |||
| 290 | 300 | } | |
| 291 | 301 | }) | |
| 292 | 302 | } | |
| 303 | + | ||
| 304 | + | // Setup navigation policy handler for middle-click and Ctrl+click interception | |
| 305 | + | w.setupNavigationPolicyHandler() | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // setupNavigationPolicyHandler sets up the decide-policy signal handler | |
| 309 | + | // This intercepts navigation actions BEFORE they occur, allowing us to detect | |
| 310 | + | // middle-click and Ctrl+click on links and open them in new panes | |
| 311 | + | func (w *WebView) setupNavigationPolicyHandler() { | |
| 312 | + | w.view.ConnectDecidePolicy(func(decision webkit.PolicyDecisioner, decisionType webkit.PolicyDecisionType) bool { | |
| 313 | + | // Only handle navigation actions (not new window or response policies) | |
| 314 | + | if decisionType != webkit.PolicyDecisionTypeNavigationAction { | |
| 315 | + | return false // Let WebKit handle | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // Cast to NavigationPolicyDecision to access NavigationAction | |
| 319 | + | navDecision, ok := decision.(*webkit.NavigationPolicyDecision) | |
| 320 | + | if !ok { | |
| 321 | + | return false | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | navAction := navDecision.NavigationAction() | |
| 325 | + | if navAction == nil { | |
| 326 | + | return false | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // Check if this is a link click | |
| 330 | + | if navAction.NavigationType() != webkit.NavigationTypeLinkClicked { | |
| 331 | + | return false // Not a link click, let WebKit handle normally | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // Get mouse button and modifiers | |
| 335 | + | mouseButton := navAction.MouseButton() | |
| 336 | + | modifiers := navAction.Modifiers() | |
| 337 | + | ||
| 338 | + | // Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl) | |
| 339 | + | isMiddleClick := (mouseButton == 2) | |
| 340 | + | isCtrlClick := (mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMask) != 0) | |
| 341 | + | ||
| 342 | + | if !isMiddleClick && !isCtrlClick { | |
| 343 | + | return false // Normal click, let WebKit handle | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // Get the link URL | |
| 347 | + | request := navAction.Request() | |
| 348 | + | if request == nil { | |
| 349 | + | return false | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | linkURL := request.URI() | |
| 353 | + | if linkURL == "" { | |
| 354 | + | return false | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | clickType := "middle-click" | |
| 358 | + | if isCtrlClick { | |
| 359 | + | clickType = "Ctrl+click" | |
| 360 | + | } | |
| 361 | + | log.Printf("[navigation-policy] Detected %s on link: %s", clickType, linkURL) | |
| 362 | + | ||
| 363 | + | // Call the registered handler | |
| 364 | + | w.mu.RLock() | |
| 365 | + | handler := w.onMiddleClickLink | |
| 366 | + | w.mu.RUnlock() | |
| 367 | + | ||
| 368 | + | if handler != nil { | |
| 369 | + | handled := handler(linkURL) | |
| 370 | + | if handled { | |
| 371 | + | log.Printf("[navigation-policy] %s handled by workspace, blocking navigation", clickType) | |
| 372 | + | // Block the navigation by calling Ignore() | |
| 373 | + | navDecision.Ignore() | |
| 374 | + | return true // Prevent default behavior | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | return false // Let WebKit handle if not handled | |
| 379 | + | }) | |
| 380 | + | ||
| 381 | + | log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id) | |
| 293 | 382 | } | |
| 294 | 383 | ||
| 295 | 384 | // LoadURL loads the given URL in the WebView | |
| @@ -525,6 +614,14 @@ func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) { | |||
| 525 | 614 | w.onZoomChanged = handler | |
| 526 | 615 | } | |
| 527 | 616 | ||
| 617 | + | // RegisterLoadCommittedHandler registers a handler for load committed events | |
| 618 | + | // This fires when the page actually starts loading new content (after URI change) | |
| 619 | + | func (w *WebView) RegisterLoadCommittedHandler(handler func(string)) { | |
| 620 | + | w.mu.Lock() | |
| 621 | + | defer w.mu.Unlock() | |
| 622 | + | w.onLoadCommitted = handler | |
| 623 | + | } | |
| 624 | + | ||
| 528 | 625 | // RegisterCloseHandler registers a handler for close requests | |
| 529 | 626 | func (w *WebView) RegisterCloseHandler(handler func()) { | |
| 530 | 627 | w.mu.Lock() | |
| @@ -565,6 +662,14 @@ func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures) | |||
| 565 | 662 | w.onWindowTypeDetected = handler | |
| 566 | 663 | } | |
| 567 | 664 | ||
| 665 | + | // RegisterMiddleClickLinkHandler registers a handler for middle mouse button clicks on links | |
| 666 | + | // The handler receives the link URL and returns true if handled | |
| 667 | + | func (w *WebView) RegisterMiddleClickLinkHandler(handler func(url string) bool) { | |
| 668 | + | w.mu.Lock() | |
| 669 | + | defer w.mu.Unlock() | |
| 670 | + | w.onMiddleClickLink = handler | |
| 671 | + | } | |
| 672 | + | ||
| 568 | 673 | // RunOnMainThread executes a function on the GTK main thread | |
| 569 | 674 | func (w *WebView) RunOnMainThread(fn func()) { | |
| 570 | 675 | RunOnMainThread(fn) | |
| @@ -643,6 +748,9 @@ func (w *WebView) InjectScript(script string) error { | |||
| 643 | 748 | } | |
| 644 | 749 | ||
| 645 | 750 | // DispatchCustomEvent dispatches a custom event via JavaScript | |
| 751 | + | // This works across JavaScript world boundaries because it uses document.dispatchEvent. | |
| 752 | + | // GUI scripts running in the isolated world (see DumberIsolatedWorld in user_content.go) | |
| 753 | + | // can listen for these events because they share the same Document object with the main world. | |
| 646 | 754 | func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error { | |
| 647 | 755 | // Serialize data to JSON | |
| 648 | 756 | jsonData, err := json.Marshal(data) | |