detached d39052c

Merge branch 'dev'

commit
d39052c37284cf3bfbe1a942ca6c47299d73bb7e
author
bnema · · 2025-11-17 22:26 UTC
committer
bnema · · 2025-11-17 18:46 UTC
merge of
657b57f + 6168897 · diff against the first parent
signature
unsigned
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 ## [Unreleased]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 ## [0.13.0] - 2025-10-2440 ## [0.13.0] - 2025-10-24
8 41
9 ### Added42 ### Added
M README.md +60 -102
@@ -4,34 +4,23 @@
4 <img src="assets/logo.svg" alt="Dumber Logo" width="256" height="256" />4 <img src="assets/logo.svg" alt="Dumber Logo" width="256" height="256" />
5 </p>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.9+## Features
10-- CLI that parses URLs, supports search shortcuts (e.g., `g:query`), and integrates with rofi/dmenu.10+- **Wayland native**: No X11/XWayland dependency, works with Sway, Hyprland, River, Niri, etc.
11-- Persistent history and per‑domain zoom levels (SQLite, XDG locations).11+- **Tabs and workspaces**: Each tab holds a workspace with split or stacked panes.
12-- Configurable via `config.json` and environment variables, with live reload.12+- **Keyboard-driven**: Complete keyboard workflow inspired by Zellij.
13-13+- **Smart popup handling**: OAuth flows, window.open(), and popup deduplication for tiling WMs.
14-## Wayland‑First14+- **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.
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.15+- **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).
16-- Uses a native Wayland WebKitGTK-6.0 window; no external browser required and no X11/XWayland dependency.16+- **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.
17-- Optimized for dmenu‑style launchers (rofi, fuzzel, wofi) and keyboard‑driven workflows.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 ## Status22 ## 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.23+Early development with regular releases. Core features work well for daily use but expect some rough edges.
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
35 24
36 ## Controls & Shortcuts25 ## Controls & Shortcuts
37 26
@@ -53,7 +42,21 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
53 | **Ctrl+Shift+R** | Hard Reload | Refresh ignoring cache |42 | **Ctrl+Shift+R** | Hard Reload | Refresh ignoring cache |
54 | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation |43 | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation |
55 44
56-#### Zellij-Inspired Pane Management45+#### 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 | Shortcut | Action | Notes |60 | Shortcut | Action | Notes |
58 |----------|--------|-------|61 |----------|--------|-------|
59 | **Ctrl+P** | Enter Pane Mode | Modal mode for pane operations |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 ### Omnibox/Find Mode (when active)83 ### Omnibox/Find Mode (when active)
81 | Shortcut | Action | Notes |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 | **Enter** | Execute/Navigate | Navigate to URL or jump to match |87 | **Enter** | Execute/Navigate | Navigate to URL or jump to match |
85 | **Shift+Enter** | Previous Match | Find mode: go to previous match |88 | **Shift+Enter** | Previous Match | Find mode: go to previous match |
86 | **Alt+Enter** | Center Match | Find mode: center on match, keep open |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 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |93 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |
88 94
89 All zoom changes are automatically persisted per-domain and restored on next visit.95 All zoom changes are automatically persisted per-domain and restored on next visit.
@@ -99,7 +105,7 @@ Prerequisites:
99 Build options:105 Build options:
100 - GUI (default, recommended):106 - GUI (default, recommended):
101 - `make build` # builds frontend + Go with `-tags=webkit_cgo`107 - `make build` # builds frontend + Go with `-tags=webkit_cgo`
102- - Run: `./dist/dumber` # opens the embedded homepage108+ - Run: `./dist/dumber` # to see all available commands.
103 - CLI‑only (no GUI, CGO disabled):109 - CLI‑only (no GUI, CGO disabled):
104 - `make build-no-gui`110 - `make build-no-gui`
105 - Run: `./dist/dumber-no-gui version`111 - Run: `./dist/dumber-no-gui version`
@@ -112,7 +118,6 @@ Development:
112 **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: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 - GUI build (default, recommended):120 - GUI build (default, recommended):
115- - Ensure WebKit2GTK/GTK dev packages are installed (see prerequisites)
116 - Clone the repo and run `make build` (builds frontend first, then Go with GUI support)121 - Clone the repo and run `make build` (builds frontend first, then Go with GUI support)
117 - The resulting `./dist/dumber` binary includes the native GUI window122 - The resulting `./dist/dumber` binary includes the native GUI window
118 - CLI‑only (no native window):123 - CLI‑only (no native window):
@@ -123,16 +128,15 @@ Development:
123 - Open a URL or search:128 - Open a URL or search:
124 - `dumber browse https://example.com`129 - `dumber browse https://example.com`
125 - `dumber browse example.com` # scheme auto‑added130 - `dumber browse example.com` # scheme auto‑added
131+ - `dumber browse dumb://home` # built-in homepage with stats
126 - `dumber browse g:golang` # Google search via shortcut132 - `dumber browse g:golang` # Google search via shortcut
127-- Launch the GUI directly:
128- - `dumber` # no args → opens GUI homepage
129 - Show version information:133 - Show version information:
130 - `dumber version` # display version, commit, and build date134 - `dumber version` # display version, commit, and build date
131 - Launcher integration (dmenu‑style examples):135 - Launcher integration (dmenu‑style examples):
132- - rofi: `dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select`136+ - rofi: `dumber dmenu | rofi -dmenu -p "🔍 " | dumber dmenu --select`
133- - fuzzel: `dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`137+ - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
134 - Manage browsing history:138 - Manage browsing history:
135- - `dumber history` # list recent history (default: 20 entries)139+ - `dumber history` # list recent history (default: 20 entries)
136 - `dumber history list -n 50` # list 50 recent entries140 - `dumber history list -n 50` # list 50 recent entries
137 - `dumber history search golang` # search history for "golang"141 - `dumber history search golang` # search history for "golang"
138 - `dumber history stats` # show history statistics142 - `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 In GUI mode the app serves an embedded homepage via `dumb://homepage`, and frontend assets under `dumb://app/...`.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 ### How it Works183 ### How it Works
165 1. **Enter Pane Mode**: Press `Ctrl+P` to enter pane mode (modal interface with timeout)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 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes191 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
173 4. **Close**: Press `X` in pane mode to close the current pane192 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 ## Configuration194 ## Configuration
203 195
204-Dumber follows the XDG Base Directory spec:196+Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML)
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-```
235 197
236-Environment variable override: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.
237-```bash
238-DUMBER_RENDERING_MODE=cpu dumber
239-DUMB_BROWSER_LOGGING_LEVEL=debug dumber
240-```
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 ## Building With WebKitGTK 6 (GTK4)203 ## Building With WebKitGTK 6 (GTK4)
@@ -279,10 +237,10 @@ I will gladly accept contributions, especially related to UI/UX enhancements. Fe
279 ## Roadmap237 ## Roadmap
280 - ✅ WebKitGTK 6 (GTK4) migration (GPU/Vulkan path complete)238 - ✅ WebKitGTK 6 (GTK4) migration (GPU/Vulkan path complete)
281 - ✅ GPU rendering (Vulkan via GTK4 renderer) with graceful CPU fallback239 - ✅ GPU rendering (Vulkan via GTK4 renderer) with graceful CPU fallback
282-- ✅ Zellij-inspired pane management with binary tree layout and keyboard-driven workflow240+- ✅ Modal pane management with binary tree layout and keyboard-driven workflow
241+- ✅ Tab management system with modal controls
283 - 🚧 UBlock-based content filtering (early stage - network blocking works, cosmetic filtering needs work)242 - 🚧 UBlock-based content filtering (early stage - network blocking works, cosmetic filtering needs work)
284 - Full Vim-style motion and keyboard navigation across pages and UI243 - Full Vim-style motion and keyboard navigation across pages and UI
285-- Tab management system (browser tabs in addition to panes)
286 - Performance work: faster startup, lower memory, snappier UI244 - Performance work: faster startup, lower memory, snappier UI
287 - Other shiny things I'm not yet aware of245 - 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 //go:embed gui/color-scheme.js13 //go:embed gui/color-scheme.js
14 var ColorSchemeScript string14 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 # Configuration Reference1 # Configuration Reference
2 2
3-**Primary format**: JSON3+**Primary format**: TOML (recommended)
4-**Location**: `~/.config/dumber/config.json`4+**Location**: `~/.config/dumber/config.toml`
5-**Also supports**: YAML (`.yaml`) and TOML (`.toml`)5+**Also supports**: JSON (`.json`) and YAML (`.yaml`)
6-**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated)6+**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated for JSON format)
7 7
8-## IDE Integration8+## 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-```json16+You can still use JSON or YAML if you prefer - dumber automatically detects the format.
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.
21 17
22 ## Database18 ## Database
23 19
@@ -33,30 +29,41 @@ The schema is automatically generated when you first create a config file.
33 | `history.retention_period_days` | int | `365` | > 0 | Days to keep history (1 year) |29 | `history.retention_period_days` | int | `365` | > 0 | Days to keep history (1 year) |
34 | `history.cleanup_interval_days` | int | `1` | > 0 | How often to run cleanup |30 | `history.cleanup_interval_days` | int | `1` | > 0 | How often to run cleanup |
35 31
36-## Search Shortcuts32+## Search Configuration
37 33
38 | Key | Type | Default | Description |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 | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs |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 **Default shortcuts:**50 **Default shortcuts:**
43-```json51+```toml
44-{52+[search_shortcuts.ddg]
45- "search_shortcuts": {53+url = "https://duckduckgo.com/?q=%s"
46- "g": {54+description = "DuckDuckGo search"
47- "url": "https://google.com/search?q=%s",55+
48- "description": "Google Search"56+[search_shortcuts.g]
49- },57+url = "https://google.com/search?q=%s"
50- "gh": {58+description = "Google Search"
51- "url": "https://github.com/search?q=%s",59+
52- "description": "GitHub"60+[search_shortcuts.gh]
53- },61+url = "https://github.com/search?q=%s"
54- "yt": {62+description = "GitHub"
55- "url": "https://youtube.com/results?search_query=%s",63+
56- "description": "YouTube"64+[search_shortcuts.yt]
57- }65+url = "https://youtube.com/results?search_query=%s"
58- }66+description = "YouTube"
59-}
60 ```67 ```
61 68
62 ## Dmenu / Launcher69 ## Dmenu / Launcher
@@ -105,37 +112,27 @@ The schema is automatically generated when you first create a config file.
105 ### Color Palettes112 ### Color Palettes
106 113
107 **Light palette:**114 **Light palette:**
108-```json115+```toml
109-{116+[appearance.light_palette]
110- "appearance": {117+background = "#f8f8f8"
111- "light_palette": {118+surface = "#f2f2f2"
112- "background": "#f8f8f8",119+surface_variant = "#ececec"
113- "surface": "#f2f2f2",120+text = "#1a1a1a"
114- "surface_variant": "#ececec",121+muted = "#6e6e6e"
115- "text": "#1a1a1a",122+accent = "#404040"
116- "muted": "#6e6e6e",123+border = "#d2d2d2"
117- "accent": "#404040",
118- "border": "#d2d2d2"
119- }
120- }
121-}
122 ```124 ```
123 125
124 **Dark palette:**126 **Dark palette:**
125-```json127+```toml
126-{128+[appearance.dark_palette]
127- "appearance": {129+background = "#0e0e0e"
128- "dark_palette": {130+surface = "#1a1a1a"
129- "background": "#0e0e0e",131+surface_variant = "#141414"
130- "surface": "#1a1a1a",132+text = "#e4e4e4"
131- "surface_variant": "#141414",133+muted = "#848484"
132- "text": "#e4e4e4",134+accent = "#a8a8a8"
133- "muted": "#848484",135+border = "#363636"
134- "accent": "#a8a8a8",
135- "border": "#363636"
136- }
137- }
138-}
139 ```136 ```
140 137
141 ## Video Acceleration138 ## Video Acceleration
@@ -191,41 +188,50 @@ The schema is automatically generated when you first create a config file.
191 188
192 ## Workspace Configuration189 ## Workspace Configuration
193 190
194-| Key | Type | Default | Description |
195-|-----|------|---------|-------------|
196-| `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings |
197-
198 ### Pane Mode191 ### Pane Mode
199 192
200 | Key | Type | Default | Description |193 | Key | Type | Default | Description |
201 |-----|------|---------|-------------|194 |-----|------|---------|-------------|
202 | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key |195 | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key |
203 | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) |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 |197+| `workspace.pane_mode.actions` | map | See below | Action→keys mappings |
205-198+
206-**Default pane mode bindings:**199+**Default pane mode actions:**
207-```json200+```toml
208-{201+[workspace.pane_mode.actions]
209- "workspace": {202+split-right = ["arrowright", "r"]
210- "pane_mode": {203+split-left = ["arrowleft", "l"]
211- "action_bindings": {204+split-up = ["arrowup", "u"]
212- "arrowright": "split-right",205+split-down = ["arrowdown", "d"]
213- "arrowleft": "split-left",206+close-pane = ["x"]
214- "arrowup": "split-up",207+confirm = ["enter"]
215- "arrowdown": "split-down",208+cancel = ["escape"]
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-}
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 ### Tab Shortcuts235 ### Tab Shortcuts
230 236
231 | Key | Type | Default | Description |237 | Key | Type | Default | Description |
@@ -251,11 +257,15 @@ The schema is automatically generated when you first create a config file.
251 257
252 | Key | Type | Default | Description |258 | Key | Type | Default | Description |
253 |-----|------|---------|-------------|259 |-----|------|---------|-------------|
254-| `workspace.styling.border_width` | int | `2` | Border width in pixels |260+| `workspace.styling.border_width` | int | `1` | Active pane border width (px) |
255-| `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Border color (CSS or theme var) |261+| `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
256-| `workspace.styling.pane_mode_border_color` | string | `"#FFA500"` | Pane mode border color (orange) |262+| `workspace.styling.inactive_border_width` | int | `1` | Inactive pane border width (px) |
257-| `workspace.styling.transition_duration` | int | `120` | Transition duration (ms) |263+| `workspace.styling.inactive_border_color` | string | `"@theme_unfocused_bg_color"` | Inactive pane border color |
258-| `workspace.styling.border_radius` | int | `0` | Border radius in pixels |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 ## Content Filtering270 ## Content Filtering
261 271
@@ -264,14 +274,12 @@ The schema is automatically generated when you first create a config file.
264 | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking |274 | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking |
265 275
266 **Default whitelist:**276 **Default whitelist:**
267-```json277+```toml
268-{278+content_filtering_whitelist = [
269- "content_filtering_whitelist": [279+ "twitch.tv",
270- "twitch.tv",280+ "passport.twitch.tv",
271- "passport.twitch.tv",281+ "gql.twitch.tv"
272- "gql.twitch.tv"282+]
273- ]
274-}
275 ```283 ```
276 284
277 ## Environment Variables285 ## 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 const surfaceMix = isDarkMode ? '55%' : '88%';180 const surfaceMix = isDarkMode ? '55%' : '88%';
181- const inputMix = isDarkMode ? '52%' : '90%';
182- const borderMix = isDarkMode ? '62%' : '85%';
183 const blurMix = isDarkMode ? '22%' : '12%';181 const blurMix = isDarkMode ? '22%' : '12%';
184 182
185 const applyFade = faded && mode === 'find';183 const applyFade = faded && mode === 'find';
186 184
185+ // GTK4-style: Container is slightly raised (use surface)
187 const containerBackground = applyFade186 const containerBackground = applyFade
188 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`187 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
189 : 'var(--dynamic-surface)';188 : 'var(--dynamic-surface)';
189+
190+ // GTK4-style: Input is recessed (darker background)
190 const inputBackground = applyFade191 const inputBackground = applyFade
191- ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`192+ ? isDarkMode
192- : 'var(--dynamic-bg)';193+ ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)`
193- const borderColor = applyFade194+ : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)`
194- ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, 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 : 'var(--dynamic-border)';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 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;212 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
197 213
198 boxElement.style.background = containerBackground;214 boxElement.style.background = containerBackground;
199 boxElement.style.color = 'var(--dynamic-text)';215 boxElement.style.color = 'var(--dynamic-text)';
200- boxElement.style.setProperty('--dumber-border-color', borderColor);216+ boxElement.style.setProperty('--dumber-border-color', containerBorderColor);
201 // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette217 // 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 inputElement.style.background = inputBackground;220 inputElement.style.background = inputBackground;
205 inputElement.style.color = 'var(--dynamic-text)';221 inputElement.style.color = 'var(--dynamic-text)';
206 inputElement.style.setProperty('--dumber-input-bg', inputBackground);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 inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');224 inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
209 225
210 blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);226 blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
@@ -250,7 +266,7 @@
250 266
251 267
252 268
253- onMount(() => {269+ onMount(async () => {
254 syncTheme();270 syncTheme();
255 observeThemeChanges();271 observeThemeChanges();
256 272
@@ -265,6 +281,17 @@
265 // Add global click listener281 // Add global click listener
266 document.addEventListener('click', handleGlobalClick, true);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 try {295 try {
269 // Set up global API for Go bridge296 // Set up global API for Go bridge
270 window.__dumber_omnibox = {297 window.__dumber_omnibox = {
@@ -399,17 +426,18 @@
399 margin-right: 0 !important;426 margin-right: 0 !important;
400 width: {responsiveStyles.width};427 width: {responsiveStyles.width};
401 padding: {responsiveStyles.padding};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 pointer-events: auto !important;430 pointer-events: auto !important;
404 box-sizing: border-box !important;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 color: var(--dynamic-text);433 color: var(--dynamic-text);
407 border: 1px solid var(--dumber-border-color, var(--dynamic-border));434 border: 1px solid var(--dumber-border-color, var(--dynamic-border));
435+ border-radius: 3px;
408 box-shadow:436 box-shadow:
409- 0 20px 60px rgba(0, 0, 0, 0.45),437+ 0 4px 12px rgba(0, 0, 0, 0.2),
410- 0 10px 30px rgba(0, 0, 0, 0.35),438+ 0 2px 6px rgba(0, 0, 0, 0.15),
411- 0 5px 15px rgba(0, 0, 0, 0.25),439+ 0 1px 3px rgba(0, 0, 0, 0.1),
412- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent);440+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
413 --dumber-border-color: var(--dynamic-border);"441 --dumber-border-color: var(--dynamic-border);"
414 onmousedown={handleBoxClick}442 onmousedown={handleBoxClick}
415 onmouseenter={handleMouseEnter}443 onmouseenter={handleMouseEnter}
@@ -443,54 +471,62 @@
443 471
444 <style>472 <style>
445 .dumber-omnibox-container {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 color: var(--dynamic-text);475 color: var(--dynamic-text);
448 border: 1px solid var(--dumber-border-color, var(--dynamic-border));476 border: 1px solid var(--dumber-border-color, var(--dynamic-border));
449 transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;477 transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
450- border-radius: 0;478+ border-radius: 3px;
451 box-shadow:479 box-shadow:
452- 0 20px 60px rgba(0, 0, 0, 0.45),480+ 0 4px 12px rgba(0, 0, 0, 0.2),
453- 0 10px 30px rgba(0, 0, 0, 0.35),481+ 0 2px 6px rgba(0, 0, 0, 0.15),
454- 0 5px 15px rgba(0, 0, 0, 0.25),482+ 0 1px 3px rgba(0, 0, 0, 0.1),
455- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent);483+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
456 }484 }
457 485
458 .dumber-omnibox-container:focus-within {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 box-shadow:488 box-shadow:
461- 0 25px 75px rgba(0, 0, 0, 0.5),489+ 0 6px 16px rgba(0, 0, 0, 0.24),
462- 0 15px 40px rgba(0, 0, 0, 0.4),490+ 0 3px 8px rgba(0, 0, 0, 0.18),
463- 0 8px 20px rgba(0, 0, 0, 0.3),491+ 0 2px 4px rgba(0, 0, 0, 0.12),
464- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 28%, transparent);492+ inset 0 1px 0 rgba(255, 255, 255, 0.06),
493+ 0 0 0 1px var(--dynamic-accent);
465 }494 }
466 495
467 :global(.dumber-omnibox-container input) {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 color: var(--dynamic-text);498 color: var(--dynamic-text);
470 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));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 font-family:505 font-family:
473- "JetBrains Mono",506+ "Fira Sans",
474- "Fira Code",507+ system-ui,
475- "SFMono-Regular",508+ -apple-system,
476- Menlo,509+ "Segoe UI",
477- monospace;510+ "Ubuntu",
511+ "Cantarell",
512+ sans-serif;
478 font-size: inherit;513 font-size: inherit;
479- letter-spacing: 0.05em;514+ letter-spacing: normal;
480 text-transform: none;515 text-transform: none;
481 }516 }
482 517
483 :global(.dumber-omnibox-container input::placeholder) {518 :global(.dumber-omnibox-container input::placeholder) {
484 color: var(--dynamic-muted);519 color: var(--dynamic-muted);
485- letter-spacing: 0.08em;520+ letter-spacing: normal;
486- text-transform: uppercase;521+ text-transform: none;
487 }522 }
488 523
489 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */524 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
525+ /* GTK4-style: Minimal blur for faded state */
490 .dumber-omnibox-blur-layer {526 .dumber-omnibox-blur-layer {
491- backdrop-filter: blur(1.5px) saturate(105%);527+ backdrop-filter: blur(0.5px) saturate(102%);
492- -webkit-backdrop-filter: blur(1.5px) saturate(105%);528+ -webkit-backdrop-filter: blur(0.5px) saturate(102%);
493- background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 12%, transparent));529+ background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent));
494 transition: opacity 140ms ease;530 transition: opacity 140ms ease;
495 will-change: opacity;531 will-change: opacity;
496 z-index: 0;532 z-index: 0;
M gui/src/components/omnibox/OmniboxInput.svelte +126 -8
@@ -10,9 +10,11 @@
10 10
11 // Reactive state11 // Reactive state
12 let mode = $derived(omniboxStore.mode);12 let mode = $derived(omniboxStore.mode);
13+ let viewMode = $derived(omniboxStore.viewMode);
13 let inputValue = $derived(omniboxStore.inputValue);14 let inputValue = $derived(omniboxStore.inputValue);
14 let selectedIndex = $derived(omniboxStore.selectedIndex);15 let selectedIndex = $derived(omniboxStore.selectedIndex);
15 let suggestions = $derived(omniboxStore.suggestions);16 let suggestions = $derived(omniboxStore.suggestions);
17+ let favorites = $derived(omniboxStore.favorites);
16 let matches = $derived(omniboxStore.matches);18 let matches = $derived(omniboxStore.matches);
17 let searchShortcuts = $derived(omniboxStore.searchShortcuts);19 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
18 20
@@ -117,8 +119,11 @@
117 omniboxStore.setFaded(false);119 omniboxStore.setFaded(false);
118 120
119 if (mode === 'omnibox') {121 if (mode === 'omnibox') {
120- // Debounced search for omnibox122+ // Only query backend if we're in history view
121- debouncedQuery(value);123+ // In favorites view, filtering happens locally via computed state
124+ if (viewMode === 'history') {
125+ debouncedQuery(value);
126+ }
122 } else if (mode === 'find') {127 } else if (mode === 'find') {
123 // Immediate find for search128 // Immediate find for search
124 findInPage(value);129 findInPage(value);
@@ -131,7 +136,19 @@
131 case 'Escape':136 case 'Escape':
132 event.preventDefault();137 event.preventDefault();
133 event.stopPropagation();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 break;152 break;
136 153
137 case 'Enter':154 case 'Enter':
@@ -140,6 +157,26 @@
140 handleEnterKey(event);157 handleEnterKey(event);
141 break;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 case 'ArrowDown':180 case 'ArrowDown':
144 case 'ArrowUp':181 case 'ArrowUp':
145 event.preventDefault();182 event.preventDefault();
@@ -148,6 +185,27 @@
148 break;185 break;
149 186
150 default: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 // For normal typing keys, only stop propagation to prevent page handlers209 // For normal typing keys, only stop propagation to prevent page handlers
152 // but don't prevent default so typing still works in the input210 // but don't prevent default so typing still works in the input
153 event.stopPropagation();211 event.stopPropagation();
@@ -160,8 +218,10 @@
160 // Handle Enter key based on mode218 // Handle Enter key based on mode
161 function handleEnterKey(event: KeyboardEvent) {219 function handleEnterKey(event: KeyboardEvent) {
162 if (mode === 'omnibox') {220 if (mode === 'omnibox') {
163- const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null;221+ // Get selected item from current view (history or favorites)
164- const url = selectedSuggestion?.url || inputValue || '';222+ const currentList = viewMode === 'history' ? suggestions : favorites;
223+ const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null;
224+ const url = selectedItem?.url || inputValue || '';
165 225
166 if (url) {226 if (url) {
167 omniboxBridge.navigate(url);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 // Handle arrow key navigation287 // Handle arrow key navigation
186 function handleArrowKeys(key: string) {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 if (totalItems > 0) {297 if (totalItems > 0) {
190 if (key === 'ArrowDown') {298 if (key === 'ArrowDown') {
@@ -325,16 +433,26 @@
325 background: var(--dumber-input-bg, var(--dynamic-bg));433 background: var(--dumber-input-bg, var(--dynamic-bg));
326 color: var(--dynamic-text);434 color: var(--dynamic-text);
327 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));435 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328- border-radius: 0;436+ border-radius: 2px;
329- transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;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 .omnibox-input-field::placeholder {445 .omnibox-input-field::placeholder {
333 color: var(--dynamic-muted);446 color: var(--dynamic-muted);
447+ letter-spacing: normal;
334 }448 }
335 449
336 .omnibox-input-field:focus {450 .omnibox-input-field:focus {
337 color: var(--dynamic-text);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 .omnibox-prefix-badge {458 .omnibox-prefix-badge {
M gui/src/components/omnibox/OmniboxSuggestions.svelte +206 -33
@@ -9,14 +9,43 @@
9 9
10 // Reactive state10 // Reactive state
11 let suggestions = $derived(omniboxStore.suggestions);11 let suggestions = $derived(omniboxStore.suggestions);
12+ let favorites = $derived(omniboxStore.favorites);
13+ let viewMode = $derived(omniboxStore.viewMode);
12 let selectedIndex = $derived(omniboxStore.selectedIndex);14 let selectedIndex = $derived(omniboxStore.selectedIndex);
15+ let inputValue = $derived(omniboxStore.inputValue);
13 let hasContent = $state(false);16 let hasContent = $state(false);
14 17
15- // Update hasContent when suggestions change18+ // Debug effect to log favorites changes
16 $effect(() => {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 // Handle suggestion item mouse enter49 // Handle suggestion item mouse enter
21 function handleItemMouseEnter(index: number) {50 function handleItemMouseEnter(index: number) {
22 omniboxStore.setSelectedIndex(index);51 omniboxStore.setSelectedIndex(index);
@@ -90,30 +119,62 @@
90 role="listbox"119 role="listbox"
91 aria-label="Search suggestions"120 aria-label="Search suggestions"
92 >121 >
93- {#each suggestions as suggestion, index (suggestion.url)}122+ <!-- View mode indicator -->
94- {@const { domain, path } = parseUrl(suggestion.url)}123+ <div class="view-mode-header">
95- {@const isSelected = index === selectedIndex}124+ <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}>
96-125+ History ({suggestions.length})
97- <div126+ </span>
98- id="omnibox-item-{index}"127+ <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}>
99- class={isSelected ? 'suggestion-item selected' : 'suggestion-item'}128+ Favorites ({favorites.length})
100- role="option"129+ </span>
101- tabindex={isSelected ? 0 : -1}130+ <span class="view-hint">Tab to switch</span>
102- aria-selected={isSelected}131+ </div>
103- onmouseenter={() => handleItemMouseEnter(index)}132+
104- onclick={() => handleItemClick(suggestion)}133+ {#if currentList.length === 0}
105- onkeydown={(e) => handleItemKeyDown(e, suggestion)}134+ <!-- Empty state -->
106- >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 <!-- Favicon chip -->169 <!-- Favicon chip -->
108- {#if suggestion.favicon}170+ {#if favicon}
109 <div class="suggestion-favicon">171 <div class="suggestion-favicon">
110 <img172 <img
111- src={suggestion.favicon}173+ src={favicon}
112 alt=""174 alt=""
113 class="suggestion-favicon-img"175 class="suggestion-favicon-img"
114 loading="lazy"176 loading="lazy"
115 onerror={handleFaviconError}177 onerror={handleFaviconError}
116- style="image-rendering: -webkit-optimize-contrast;"
117 />178 />
118 </div>179 </div>
119 {/if}180 {/if}
@@ -135,8 +196,16 @@
135 {path || '/'}196 {path || '/'}
136 </span>197 </span>
137 </div>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 </div>206 </div>
139 {/each}207 {/each}
208+ {/if}
140 </div>209 </div>
141 {/if}210 {/if}
142 211
@@ -144,36 +213,117 @@
144 213
145 <style>214 <style>
146 .suggestion-list {215 .suggestion-list {
147- margin-top: 0.75rem;216+ margin-top: 0.5rem;
148 max-height: 50vh;217 max-height: 50vh;
149 overflow-y: auto;218 overflow-y: auto;
150 border-top: 1px solid var(--dynamic-border);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 .suggestion-item {284 .suggestion-item {
155 display: flex;285 display: flex;
156 gap: 0.75rem;286 gap: 0.75rem;
157 align-items: center;287 align-items: center;
158- padding: 0.65rem 0.85rem;288+ padding: 0.85rem 0.85rem;
159- border-bottom: 1px dashed var(--dynamic-border);289+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
160 cursor: pointer;290 cursor: pointer;
161- transition: background-color 120ms ease, color 120ms ease;291+ transition: background-color 100ms ease, border-left-color 100ms ease;
162- letter-spacing: 0.04em;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 .suggestion-item:last-child {298 .suggestion-item:last-child {
166 border-bottom: none;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 .suggestion-item.selected,307 .suggestion-item.selected,
170 .suggestion-item:hover,308 .suggestion-item:hover,
171 .suggestion-item:focus-visible {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 color: var(--dynamic-text);312 color: var(--dynamic-text);
174 outline: none;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 .suggestion-item .suggestion-domain {327 .suggestion-item .suggestion-domain {
178 color: var(--dynamic-text);328 color: var(--dynamic-text);
179 opacity: 0.95;329 opacity: 0.95;
@@ -188,24 +338,23 @@
188 }338 }
189 339
190 .suggestion-favicon {340 .suggestion-favicon {
191- width: 20px;341+ width: 24px;
192- height: 20px;342+ height: 24px;
193 border: none;343 border: none;
194 border-radius: 3px;344 border-radius: 3px;
195 display: flex;345 display: flex;
196 align-items: center;346 align-items: center;
197 justify-content: center;347 justify-content: center;
198- background: var(--dynamic-bg);348+ background: transparent;
199 flex-shrink: 0;349 flex-shrink: 0;
200 overflow: hidden;350 overflow: hidden;
201 }351 }
202 352
203 .suggestion-favicon-img {353 .suggestion-favicon-img {
204- width: 16px;354+ width: 24px;
205- height: 16px;355+ height: 24px;
206 object-fit: contain;356 object-fit: contain;
207- image-rendering: -webkit-optimize-contrast;357+ image-rendering: auto;
208- -ms-interpolation-mode: nearest-neighbor;
209 }358 }
210 359
211 .suggestion-text {360 .suggestion-text {
@@ -220,4 +369,28 @@
220 mask-image: linear-gradient(90deg, black 85%, transparent 100%);369 mask-image: linear-gradient(90deg, black 85%, transparent 100%);
221 -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%);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 </style>396 </style>
M gui/src/components/omnibox/messaging.ts +76 -26
@@ -4,36 +4,29 @@
4 * TypeScript interface for Go-JavaScript communication4 * 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 import { omniboxStore } from "./stores.svelte.ts";8 import { omniboxStore } from "./stores.svelte.ts";
9 9
10 export class OmniboxBridge implements OmniboxMessageBridge {10 export class OmniboxBridge implements OmniboxMessageBridge {
11- // Forward messages to the native WebKit message handler when available11+ // Forward messages via CustomEvent to main world bridge
12- // Fallback: for navigate, perform direct location change12+ // The main world will forward to webkit.messageHandlers.dumber
13 postMessage(msg: OmniboxMessage): void {13 postMessage(msg: OmniboxMessage): void {
14- console.log("📤 [DEBUG] Posting message to backend:", msg);14+ console.log("📤 [DEBUG] Posting message to backend via bridge:", msg);
15- // NOTE: Do NOT detach postMessage from its receiver. In WKWebView,15+ try {
16- // postMessage must be called on the UserMessageHandler instance.16+ // Dispatch CustomEvent that main-world bridge will listen to
17- const bridge = window.webkit?.messageHandlers?.dumber;17+ document.dispatchEvent(new CustomEvent("dumber:isolated-message", {
18- if (bridge && typeof bridge.postMessage === "function") {18+ detail: { payload: msg }
19- try {19+ }));
20- console.log("📱 [DEBUG] Using webkit message handler");20+ console.log("✅ [DEBUG] Dispatched isolated message event");
21- bridge.postMessage(JSON.stringify(msg));21+ } catch (e) {
22- return;22+ console.error("❌ [DEBUG] Failed to dispatch message:", e);
23- } catch (e) {23+ // Fallback navigation if dispatch fails
24- console.warn(24+ if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) {
25- "postMessage to native handler failed, using fallback:",25+ try {
26- e,26+ window.location.href = msg.url;
27- );27+ } catch (navError) {
28- }28+ console.error("Fallback navigation failed:", navError);
29- }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);
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 // Suggestions are returned via setSuggestions() when native handler responds178 // 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 import type {7 import type {
8 OmniboxMode,8 OmniboxMode,
9+ ViewMode,
9 Suggestion,10 Suggestion,
11+ Favorite,
10 FindMatch,12 FindMatch,
11 HighlightNode,13 HighlightNode,
12 OmniboxConfig,14 OmniboxConfig,
@@ -20,10 +22,25 @@ const DEFAULT_CONFIG: Required<OmniboxConfig> = {
20 defaultLimit: 10,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 // Global state using Svelte 5 runes38 // Global state using Svelte 5 runes
24 let visible = $state(false);39 let visible = $state(false);
25 let mode = $state<OmniboxMode>("omnibox");40 let mode = $state<OmniboxMode>("omnibox");
41+let viewMode = $state<ViewMode>(loadViewMode());
26 let suggestions = $state<Suggestion[]>([]);42 let suggestions = $state<Suggestion[]>([]);
43+let favorites = $state<Favorite[]>([]);
27 let matches = $state<FindMatch[]>([]);44 let matches = $state<FindMatch[]>([]);
28 let selectedIndex = $state(-1);45 let selectedIndex = $state(-1);
29 let activeIndex = $state(-1);46 let activeIndex = $state(-1);
@@ -45,9 +62,15 @@ export const omniboxStore = {
45 get mode() {62 get mode() {
46 return mode;63 return mode;
47 },64 },
65+ get viewMode() {
66+ return viewMode;
67+ },
48 get suggestions() {68 get suggestions() {
49 return suggestions;69 return suggestions;
50 },70 },
71+ get favorites() {
72+ return favorites;
73+ },
51 get matches() {74 get matches() {
52 return matches;75 return matches;
53 },76 },
@@ -78,19 +101,28 @@ export const omniboxStore = {
78 101
79 // Computed getters102 // Computed getters
80 get hasContent() {103 get hasContent() {
81- return (104+ if (mode === "find") {
82- (mode === "omnibox" && suggestions.length > 0) ||105+ return matches.length > 0;
83- (mode === "find" && matches.length > 0)106+ }
84- );107+ // In omnibox mode, check the current view
108+ return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0;
85 },109 },
86 get totalItems() {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 get selectedItem() {117 get selectedItem() {
90 if (selectedIndex < 0) return null;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 ? suggestions[selectedIndex]124 ? suggestions[selectedIndex]
93- : matches[selectedIndex];125+ : favorites[selectedIndex];
94 },126 },
95 127
96 // Actions128 // Actions
@@ -242,10 +274,32 @@ export const omniboxStore = {
242 searchShortcuts = shortcuts;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 reset() {297 reset() {
246 visible = false;298 visible = false;
247 mode = "omnibox";299 mode = "omnibox";
300+ // Keep viewMode persistent - it's user preference
248 suggestions = [];301 suggestions = [];
302+ favorites = [];
249 matches = [];303 matches = [];
250 selectedIndex = -1;304 selectedIndex = -1;
251 activeIndex = -1;305 activeIndex = -1;
M gui/src/components/omnibox/types.ts +14 -1
@@ -9,16 +9,26 @@ export interface Suggestion {
9 favicon?: string;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 export interface SearchShortcut {20 export interface SearchShortcut {
13 url: string;21 url: string;
14 description: string;22 description: string;
15 }23 }
16 24
17 export interface OmniboxMessage {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 url?: string;27 url?: string;
20 q?: string;28 q?: string;
21 limit?: number;29 limit?: number;
30+ title?: string;
31+ faviconURL?: string;
22 }32 }
23 33
24 export interface FindMatch {34 export interface FindMatch {
@@ -32,6 +42,7 @@ export interface HighlightNode {
32 }42 }
33 43
34 export type OmniboxMode = "omnibox" | "find";44 export type OmniboxMode = "omnibox" | "find";
45+export type ViewMode = "history" | "favorites";
35 46
36 export interface OmniboxConfig {47 export interface OmniboxConfig {
37 maxMatches?: number;48 maxMatches?: number;
@@ -42,7 +53,9 @@ export interface OmniboxConfig {
42 export interface OmniboxState {53 export interface OmniboxState {
43 visible: boolean;54 visible: boolean;
44 mode: OmniboxMode;55 mode: OmniboxMode;
56+ viewMode: ViewMode;
45 suggestions: Suggestion[];57 suggestions: Suggestion[];
58+ favorites: Favorite[];
46 matches: FindMatch[];59 matches: FindMatch[];
47 selectedIndex: number;60 selectedIndex: number;
48 activeIndex: number;61 activeIndex: number;
M gui/src/components/toast/ToastContainer.svelte +17 -13
@@ -84,19 +84,23 @@
84 }, 1500);84 }, 1500);
85 }85 }
86 86
87- // Expose API to global window for Go integration immediately87+ // Listen for toast events from main world bridge
88- // This must happen outside onMount to ensure functions are available immediately88+ if (typeof document !== 'undefined') {
89- if (typeof window !== 'undefined') {89+ // Listen for general toast requests
90- window.__dumber_showToast = addToast;90+ document.addEventListener('dumber:toast', ((event: CustomEvent) => {
91- window.__dumber_dismissToast = dismissToast;91+ const { message, duration, type } = event.detail;
92- window.__dumber_clearToasts = clearAllToasts;92+ console.log('[ToastContainer] Received toast event:', { message, duration, type });
93- window.__dumber_showZoomToast = showZoomToast;93+ addToast(message, duration, type);
94- console.log('✅ Toast functions exposed to window:', {94+ }) as EventListener);
95- showToast: typeof window.__dumber_showToast,95+
96- dismissToast: typeof window.__dumber_dismissToast,96+ // Listen for zoom toast requests
97- clearToasts: typeof window.__dumber_clearToasts,97+ document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => {
98- showZoomToast: typeof window.__dumber_showZoomToast98+ const { level } = event.detail;
99- });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 // Effect that runs when toasts array changes106 // Effect that runs when toasts array changes
M gui/src/injected/bootstrap.ts +7 -15
@@ -181,22 +181,14 @@ export function bootstrapGUI(): void {
181 console.log("[GUI] DOM ready, starting initialization");181 console.log("[GUI] DOM ready, starting initialization");
182 let toastInitialized = false;182 let toastInitialized = false;
183 183
184- if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") {184+ try {
185- console.log("[GUI] Toast already initialized, skipping");185+ console.log("[GUI] Attempting toast initialization");
186+ await initializeToast();
186 toastInitialized = true;187 toastInitialized = true;
187- } else {188+ console.log("[GUI] Toast initialized successfully");
188- try {189+ } catch (e) {
189- console.log("[GUI] Attempting toast initialization");190+ console.error("❌ Failed to initialize Svelte toast system:", e);
190- await initializeToast();191+ throw e;
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- }
200 }192 }
201 193
202 try {194 try {
M gui/src/injected/main-world.ts +104 -21
@@ -32,6 +32,7 @@ declare global {
32 ) => number | void;32 ) => number | void;
33 __dumber_showZoomToast?: (level: number) => void;33 __dumber_showZoomToast?: (level: number) => void;
34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35+ __dumber_favorites?: (favorites: unknown[]) => void;
35 __dumber_webview_id?: string | number;36 __dumber_webview_id?: string | number;
36 __dumber_is_active?: boolean;37 __dumber_is_active?: boolean;
37 __dumber_teardown?: () => void;38 __dumber_teardown?: () => void;
@@ -292,46 +293,104 @@ interface DumberAPI {
292 );293 );
293 }294 }
294 295
295- // Omnibox suggestions bridge296+ /**
296- const omniboxQueue: Suggestion[] = [];297+ * ============================================================================
297- let omniboxReady = false;298+ * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM
298-299+ * ============================================================================
299- const omniboxDispatch = (suggestions: Suggestion[]) => {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 try {341 try {
301- document.dispatchEvent(342+ document.dispatchEvent(new CustomEvent(eventName, { detail }));
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- );
312 } catch (err) {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 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {357 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
318 if (omniboxReady) {358 if (omniboxReady) {
319- omniboxDispatch(suggestions);359+ bridgeDispatch("dumber:omnibox:suggestions", { suggestions });
360+ bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support
320 } else {361 } else {
321 try {362 try {
322 omniboxQueue.push(...suggestions);363 omniboxQueue.push(...suggestions);
323 } catch (err) {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 const handleOmniboxReady = () => {385 const handleOmniboxReady = () => {
330 omniboxReady = true;386 omniboxReady = true;
331 if (omniboxQueue && omniboxQueue.length) {387 if (omniboxQueue && omniboxQueue.length) {
332 const items = omniboxQueue.slice();388 const items = omniboxQueue.slice();
333 omniboxQueue.length = 0;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 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);399 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
341 });400 });
342 401
343- // Unified omnibox API402+ // Unified omnibox API for backward compatibility
344 window.__dumber.omnibox = window.__dumber.omnibox || {403 window.__dumber.omnibox = window.__dumber.omnibox || {
345 suggestions: (suggestions: Suggestion[]) => {404 suggestions: (suggestions: Suggestion[]) => {
346 window.__dumber_omnibox_suggestions!(suggestions);405 window.__dumber_omnibox_suggestions!(suggestions);
@@ -824,6 +883,7 @@ interface DumberAPI {
824 window.__dumber_page_bridge_installed = false;883 window.__dumber_page_bridge_installed = false;
825 window.__dumber_teardown = undefined;884 window.__dumber_teardown = undefined;
826 window.__dumber_omnibox_suggestions = undefined;885 window.__dumber_omnibox_suggestions = undefined;
886+ window.__dumber_favorites = undefined;
827 window.__dumber_showToast = undefined;887 window.__dumber_showToast = undefined;
828 window.__dumber_showZoomToast = undefined;888 window.__dumber_showZoomToast = undefined;
829 window.__dumber_applyDomZoom = undefined;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 // Initialize console capture995 // Initialize console capture
913 setupConsoleCapture();996 setupConsoleCapture();
914 } catch (err) {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 shadowRoot.appendChild(tokensStyle);183 shadowRoot.appendChild(tokensStyle);
184 184
185- // Inject the global GUI stylesheet so components rendered inside the shadow185+ // Inject the global GUI stylesheet (Tailwind) into shadow root
186- // root (e.g., toasts, omnibox) receive their styles
187 try {186 try {
188- // Prefer constructable stylesheets when available187+ // Use <style> tag injection for Tailwind global styles
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
209 const styleTag = document.createElement("style");188 const styleTag = document.createElement("style");
189+ styleTag.setAttribute("data-source", "dumber-tailwind-styles");
210 styleTag.textContent = globalStyles;190 styleTag.textContent = globalStyles;
211 shadowRoot.appendChild(styleTag);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 shadowResetApplied.add(shadowRoot);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 "info",26 "info",
27 "frontend",27 "frontend",
28 "init",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 export default defineConfig({5 export default defineConfig({
6 plugins: [6 plugins: [
7 svelte({7 svelte({
8- emitCss: false, // Inline component CSS into JS bundle8+ emitCss: true, // Extract component CSS to separate file for isolated world injection
9 }),9 }),
10 ],10 ],
11 build: {11 build: {
@@ -18,7 +18,7 @@ export default defineConfig({
18 name: "__dumberGUI",18 name: "__dumberGUI",
19 inlineDynamicImports: true,19 inlineDynamicImports: true,
20 manualChunks: undefined, // Prevent chunk splitting20 manualChunks: undefined, // Prevent chunk splitting
21- assetFileNames: "[name].[ext]", // Don't hash asset names since they're inlined21+ assetFileNames: "gui.min.[ext]", // CSS will be named gui.min.css
22 },22 },
23 },23 },
24 emptyOutDir: false,24 emptyOutDir: false,
M internal/app/browser/browser.go +32 -20
@@ -48,7 +48,11 @@ type BrowserApp struct {
48 navigationController *control.NavigationController48 navigationController *control.NavigationController
49 clipboardController *control.ClipboardController49 clipboardController *control.ClipboardController
50 50
51- // Pane workspace management51+ // 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 panes []*BrowserPane56 panes []*BrowserPane
53 activePane *BrowserPane57 activePane *BrowserPane
54 workspace *WorkspaceManager58 workspace *WorkspaceManager
@@ -186,6 +190,15 @@ func (app *BrowserApp) loadCachesParallel(ctx context.Context) error {
186 return nil190 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 // Load fuzzy search cache in parallel for instant dmenu access202 // Load fuzzy search cache in parallel for instant dmenu access
190 g.Go(func() error {203 g.Go(func() error {
191 if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {204 if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {
@@ -239,8 +252,19 @@ func (app *BrowserApp) Run() {
239 // Continue without content blocking252 // Continue without content blocking
240 }253 }
241 254
242- // Handle browse command if present255+ // Handle browse command if present (must use active tab's navigation controller)
243- app.navigationController.HandleBrowseCommand()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 // Setup signal handling269 // Setup signal handling
246 app.setupSignalHandling()270 app.setupSignalHandling()
@@ -260,23 +284,11 @@ func (app *BrowserApp) cleanup() {
260 app.windowShortcutHandler = nil284 app.windowShortcutHandler = nil
261 }285 }
262 286
263- // Cleanup all panes287+ // Cleanup tab manager (which cleans up all tabs and their workspaces)
264- if app.panes != nil {288+ if app.tabManager != nil {
265- log.Printf("Cleaning up %d panes", len(app.panes))289+ log.Printf("Cleaning up tab manager")
266- for i, pane := range app.panes {290+ app.tabManager.Cleanup()
267- if pane != nil {291+ app.tabManager = 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
280 }292 }
281 293
282 // Flush all caches to ensure pending writes complete before database closes294 // 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 "sync/atomic"7 "sync/atomic"
8 "time"8 "time"
9 9
10- "github.com/bnema/dumber/pkg/webkit"
11 "github.com/diamondburned/gotk4/pkg/gdk/v4"10 "github.com/diamondburned/gotk4/pkg/gdk/v4"
12 "github.com/diamondburned/gotk4/pkg/gtk/v4"11 "github.com/diamondburned/gotk4/pkg/gtk/v4"
12+
13+ "github.com/bnema/dumber/pkg/webkit"
13 )14 )
14 15
15 const (16 const (
@@ -306,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
306 // detachFromParent removes a pane from its current parent307 // detachFromParent removes a pane from its current parent
307 func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {308 func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
308 if parent == nil {309 if parent == nil {
309- // Target is the root - remove it from window310+ // Target is the root - clear it (handles both tab and non-tab environments)
310- if spm.wm.window != nil {311+ spm.wm.clearRootContainer()
311- spm.wm.window.SetChild(nil)
312- }
313 // Unparent if it has a GTK parent312 // Unparent if it has a GTK parent
314 if target.container != nil {313 if target.container != nil {
315 if webkit.WidgetGetParent(target.container) != nil {314 if webkit.WidgetGetParent(target.container) != nil {
@@ -329,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
329 // reattachToParent attaches a container to the parent328 // reattachToParent attaches a container to the parent
330 func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {329 func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
331 if parent == nil {330 if parent == nil {
332- if spm.wm.window != nil {331+ // Attach as root (handles both tab and non-tab environments)
333- spm.wm.window.SetChild(container)332+ spm.wm.setRootContainer(container)
334- }
335 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {333 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
336 if parent.left == target {334 if parent.left == target {
337 paned.SetStartChild(container)335 paned.SetStartChild(container)
@@ -478,12 +476,14 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
478 case DirectionUp:476 case DirectionUp:
479 newIndex = currentIndex - 1477 newIndex = currentIndex - 1
480 if newIndex < 0 {478 if newIndex < 0 {
481- newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last479+ // At the top of the stack - allow navigation outside
480+ return false
482 }481 }
483 case DirectionDown:482 case DirectionDown:
484 newIndex = currentIndex + 1483 newIndex = currentIndex + 1
485 if newIndex >= len(stackNode.stackedPanes) {484 if newIndex >= len(stackNode.stackedPanes) {
486- newIndex = 0 // Wrap to first485+ // At the bottom of the stack - allow navigation outside
486+ return false
487 }487 }
488 default:488 default:
489 return false489 return false
@@ -538,10 +538,9 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
538 spm.titleBarToPane[titleBarID] = pane538 spm.titleBarToPane[titleBarID] = pane
539 539
540 // Attach click handler540 // Attach click handler
541- // TODO: Implement click handler with gtk.GestureClick541+ webkit.WidgetAttachClickHandler(titleBar, func() {
542- // webkit.WidgetAttachClickHandler(titleBar, func() {542+ spm.handleTitleBarClick(titleBarID)
543- // spm.handleTitleBarClick(titleBarID)543+ })
544- // })
545 }544 }
546 545
547 return titleBar546 return titleBar
@@ -610,10 +609,9 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
610 spm.titleBarToPane[titleBarID] = node609 spm.titleBarToPane[titleBarID] = node
611 610
612 // Attach click handler611 // Attach click handler
613- // TODO: Implement with gtk.GestureClick612+ webkit.WidgetAttachClickHandler(newTitleBar, func() {
614- // webkit.WidgetAttachClickHandler(newTitleBar, func() {613+ spm.handleTitleBarClick(titleBarID)
615- // spm.handleTitleBarClick(titleBarID)614+ })
616- // })
617 615
618 // Replace the old title bar in the stack616 // Replace the old title bar in the stack
619 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {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 if texture != nil && titleBox != nil {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 image680+ // Create favicon image with UI scaling
684 faviconImg := gtk.NewImageFromPaintable(texture)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 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")691 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
687 692
688 // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent693 // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
@@ -776,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
776 webkit.WidgetUnparent(stackNode.container)781 webkit.WidgetUnparent(stackNode.container)
777 }782 }
778 }783 }
779- if spm.wm.window != nil {784+ // Clear root container (handles both tab and non-tab environments)
780- spm.wm.window.SetChild(nil)785+ spm.wm.clearRootContainer()
781- }
782 _, err := spm.wm.cleanupAndExit(node)786 _, err := spm.wm.cleanupAndExit(node)
783 return err787 return err
784 }788 }
@@ -802,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
802 grandparent := parent.parent806 grandparent := parent.parent
803 807
804 spm.wm.promoteSibling(grandparent, parent, sibling)808 spm.wm.promoteSibling(grandparent, parent, sibling)
805- spm.wm.swapContainers(grandparent, sibling)809+ spm.wm.swapContainers(grandparent, parent, sibling)
806 810
807 generation := spm.wm.nextCleanupGeneration()811 generation := spm.wm.nextCleanupGeneration()
808 spm.wm.cleanupPane(node, generation)812 spm.wm.cleanupPane(node, generation)
@@ -840,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
840 }844 }
841 845
842 if parent == nil {846 if parent == nil {
847+ // Stack was root - make last pane the new root
843 spm.wm.root = lastPane848 spm.wm.root = lastPane
844 lastPane.parent = nil849 lastPane.parent = nil
845- if spm.wm.window != nil && lastPaneContainer != nil {850+ if lastPaneContainer != nil {
846- spm.wm.window.SetChild(lastPaneContainer)851+ spm.wm.setRootContainer(lastPaneContainer)
847 webkit.WidgetQueueAllocate(lastPaneContainer)852 webkit.WidgetQueueAllocate(lastPaneContainer)
848 webkit.WidgetShow(lastPaneContainer)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 app.clipboardController = pane.clipboardController249 app.clipboardController = pane.clipboardController
250 app.messageHandler = pane.messageHandler250 app.messageHandler = pane.messageHandler
251 app.shortcutHandler = pane.shortcutHandler251 app.shortcutHandler = pane.shortcutHandler
252- app.panes = []*BrowserPane{pane}
253- app.activePane = pane
254 252
255- if app.workspace == nil {253+ // Initialize tab manager (which manages workspaces)
256- app.workspace = NewWorkspaceManager(app, pane)254+ window := view.Window()
257- // CRITICAL: Register navigation handler for the root webview255+ if window != nil && app.tabManager == nil {
258- // This is needed because buildPane() couldn't register it when workspace was nil256+ app.tabManager = NewTabManager(app, window)
259- app.workspace.RegisterNavigationHandler(view)
260 257
261- // CRITICAL: Setup popup handling for the root webview258+ // Determine initial URL
262- // This must be done after workspace is created so WebKit's create signal is connected259+ initialURL := "about:blank"
263- node := app.workspace.GetNodeForWebView(view)260+
264- if node != nil {261+ // Initialize tab system (creates root container with tab bar and first tab)
265- app.workspace.setupPopupHandling(view, node)262+ if err := app.tabManager.Initialize(initialURL); err != nil {
266- log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())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 } else {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 up278+ // Initialize window-level global shortcuts AFTER tab manager is set up
273- if window := view.Window(); window != nil {279+ if window != nil {
274 app.windowShortcutHandler = NewWindowShortcutHandler(window, app)280 app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
275 if app.windowShortcutHandler != nil {281 if app.windowShortcutHandler != nil {
276 log.Printf("Window-level global shortcuts initialized")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 "github.com/bnema/dumber/internal/app/messaging"9 "github.com/bnema/dumber/internal/app/messaging"
10 "github.com/bnema/dumber/pkg/webkit"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 // WindowShortcutHandler manages global shortcuts at the window level15 // WindowShortcutHandler manages global shortcuts at the window level
@@ -47,6 +49,11 @@ func (h *WindowShortcutHandler) initialize() error {
47 return ErrFailedToInitialize49 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 return h.registerGlobalShortcuts()57 return h.registerGlobalShortcuts()
51 }58 }
52 59
@@ -60,9 +67,23 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
60 {"ctrl+f", h.handleFindToggle, "Find in page"},67 {"ctrl+f", h.handleFindToggle, "Find in page"},
61 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},68 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
62 {"ctrl+shift+p", h.handlePrint, "Print page"},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 {"F12", h.handleDevTools, "Developer tools"},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 // Zoom shortcuts - global level for proper active pane targeting87 // Zoom shortcuts - global level for proper active pane targeting
67 {"ctrl+plus", h.handleZoomIn, "Zoom in"},88 {"ctrl+plus", h.handleZoomIn, "Zoom in"},
68 {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},89 {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
@@ -73,6 +94,11 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
73 {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},94 {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},
74 {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},95 {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},
75 {"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"},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 for _, shortcut := range shortcuts {104 for _, shortcut := range shortcuts {
@@ -81,6 +107,142 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
81 shortcut.key, shortcut.desc)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 return nil246 return nil
85 }247 }
86 248
@@ -112,7 +274,7 @@ func (h *WindowShortcutHandler) handleCopyURL() {
112 script := `274 script := `
113 (async function() {275 (async function() {
114 const toast = (message, type) => {276 const toast = (message, type) => {
115- document.dispatchEvent(new CustomEvent('dumber:showToast', {277+ document.dispatchEvent(new CustomEvent('dumber:toast', {
116 detail: { message, duration: 2000, type }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 // handleZoomIn increases zoom level by 10%373 // handleZoomIn increases zoom level by 10%
180 func (h *WindowShortcutHandler) handleZoomIn() {374 func (h *WindowShortcutHandler) handleZoomIn() {
181 h.handleZoom("in", 1.1)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+T564+// handlePaneMode enters pane mode for modal pane management.
371-// This prevents the default browser behavior and allows for future implementation565+func (h *WindowShortcutHandler) handlePaneMode() {
372-func (h *WindowShortcutHandler) handleNewTab() {566+ if h.app == nil || h.app.workspace == nil {
373- log.Printf("[window-shortcuts] Ctrl+T pressed (no-op - tabs not yet implemented)")567+ log.Printf("[window-shortcuts] Cannot enter pane mode: workspace not available")
374- // TODO: Implement new tab functionality568+ 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 // Cleanup releases resources645 // Cleanup releases resources
M internal/app/browser/workspace_css.go +174 -28
@@ -36,6 +36,22 @@ func getStackTitleTextColor(isDark bool) string {
36 return "#333333"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 // getActivePaneBorderColor returns the border color for active panes based on config and theme55 // getActivePaneBorderColor returns the border color for active panes based on config and theme
40 func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {56 func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {
41 // Use configured border color if set57 // Use configured border color if set
@@ -55,30 +71,68 @@ func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool
55 return "#87CEEB" // Sky blue pastel for light theme71 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 // generateWorkspaceCSS generates the complete CSS for workspace panes and stacked panes93 // generateWorkspaceCSS generates the complete CSS for workspace panes and stacked panes
59 func (wm *WorkspaceManager) generateWorkspaceCSS() string {94 func (wm *WorkspaceManager) generateWorkspaceCSS() string {
60 cfg := config.Get()95 cfg := config.Get()
61 styling := cfg.Workspace.Styling96 styling := cfg.Workspace.Styling
62 97
63 // Get appropriate colors based on GTK theme preference98 // Get appropriate colors based on GTK theme preference
64- var inactiveBorderColor, windowBackgroundColor string99+ var windowBackgroundColor string
65 isDark := webkit.PrefersDarkTheme()100 isDark := webkit.PrefersDarkTheme()
66 if isDark {101 if isDark {
67- inactiveBorderColor = "#333333" // Dark border for dark theme
68 windowBackgroundColor = "#2b2b2b" // Dark window background102 windowBackgroundColor = "#2b2b2b" // Dark window background
69 } else {103 } else {
70- inactiveBorderColor = "#dddddd" // Light border for light theme
71 windowBackgroundColor = "#ffffff" // Light window background104 windowBackgroundColor = "#ffffff" // Light window background
72 }105 }
73 106
74 activeBorderColor := getActivePaneBorderColor(styling, isDark)107 activeBorderColor := getActivePaneBorderColor(styling, isDark)
108+ inactiveBorderColor := getInactivePaneBorderColor(styling, isDark)
75 109
76- // Pane mode border color - use configured value or fallback to orange110+ // Pane mode border color - use configured value or fallback to blue
77 paneModeColor := styling.PaneModeBorderColor111 paneModeColor := styling.PaneModeBorderColor
78 if paneModeColor == "" {112 if paneModeColor == "" {
79- paneModeColor = "#FFA500" // Fallback to orange if not configured113+ 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 css := fmt.Sprintf(`window {136 css := fmt.Sprintf(`window {
83 background-color: %s;137 background-color: %s;
84 padding: 0;138 padding: 0;
@@ -96,16 +150,21 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
96 transition: margin 150ms ease-in-out;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 .workspace-pane, .stacked-pane-container {154 .workspace-pane, .stacked-pane-container {
101- border: 2px solid %s;155+ border-width: %dpx;
156+ border-style: solid;
157+ border-color: %s;
102 border-radius: %dpx;158 border-radius: %dpx;
103- transition: border-color %dms ease-in-out;
104 margin: 0;159 margin: 0;
160+ transition-property: border-color;
161+ transition-duration: %dms;
162+ transition-timing-function: ease-in-out;
105 }163 }
106 164
107 /* Active pane border styling */165 /* Active pane border styling */
108 .workspace-pane-active {166 .workspace-pane-active {
167+ border-width: %dpx;
109 border-color: %s;168 border-color: %s;
110 }169 }
111 170
@@ -114,16 +173,17 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
114 background-color: %s;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 .stacked-pane-container.workspace-pane-active {177 .stacked-pane-container.workspace-pane-active {
178+ border-width: %dpx;
119 border-color: %s;179 border-color: %s;
120 }180 }
121 181
122 .stacked-pane-title {182 .stacked-pane-title {
123 background-color: %s;183 background-color: %s;
124- border-bottom: 1px solid %s;184+ border-bottom: %s;
125- padding: 4px 8px;185+ padding: %dpx %dpx;
126- min-height: 24px;186+ min-height: %dpx;
127 transition: background-color %dms ease-in-out;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 .stacked-pane-title-text {194 .stacked-pane-title-text {
135- font-size: 12px;195+ font-size: %dpx;
136 color: %s;196 color: %s;
137 font-weight: 500;197 font-weight: 500;
138 }198 }
139 199
140 .stacked-pane-favicon {200 .stacked-pane-favicon {
141- margin-right: 4px;201+ margin-right: %dpx;
142 }202 }
143 203
144 .stacked-pane-active {204 .stacked-pane-active {
@@ -147,21 +207,107 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
147 207
148 .stacked-pane-collapsed {208 .stacked-pane-collapsed {
149 /* Collapsed panes are hidden - handled in code via widget visibility */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 background272+ windowBackgroundColor, // window background
152- paneModeColor, // window.pane-mode-active background (border color)273+ paneModeColor, // window.pane-mode-active background (border color)
153- windowBackgroundColor, // paned, box background274+ windowBackgroundColor, // paned, box background
154- inactiveBorderColor, // base pane border color (inactive)275+ styling.InactiveBorderWidth, // base pane border-width (inactive)
155- styling.BorderRadius, // base pane border radius276+ inactiveBorderColor, // base pane border-color (inactive)
156- styling.TransitionDuration, // base pane border transition277+ styling.BorderRadius, // base pane border radius
157- activeBorderColor, // workspace-pane-active border color278+ styling.TransitionDuration, // transition-duration
158- windowBackgroundColor, // stacked-pane-container background279+ styling.BorderWidth, // workspace-pane-active border-width
159- inactiveBorderColor, // stacked-pane-container.workspace-pane-active border color (keep subtle)280+ activeBorderColor, // workspace-pane-active border color
160- getStackTitleBg(isDark), // stacked-pane-title background281+ windowBackgroundColor, // stacked-pane-container background
161- inactiveBorderColor, // stacked-pane-title border-bottom282+ styling.InactiveBorderWidth, // stacked-pane-container.active border-width
162- styling.TransitionDuration, // stacked-pane-title transition283+ inactiveBorderColor, // stacked-pane-container.active border-color
163- getStackTitleHoverBg(isDark), // stacked-pane-title:hover background284+ getStackTitleBg(isDark), // stacked-pane-title background
164- getStackTitleTextColor(isDark), // stacked-pane-title-text color285+ 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 return css313 return css
M internal/app/browser/workspace_manager.go +259 -14
@@ -73,6 +73,34 @@ type WorkspaceManager struct {
73 pendingIdle map[uintptr][]*paneNode73 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 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler104 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
77 105
78 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.106 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.
@@ -149,7 +177,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
149 handler.SetWorkspaceObserver(manager)177 handler.SetWorkspaceObserver(manager)
150 }178 }
151 179
152- app.workspace = manager180+ // 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 // Configure debug settings from app config189 // Configure debug settings from app config
155 if app.config != nil {190 if app.config != nil {
@@ -414,15 +449,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
414 return wm.FocusNeighbor(direction)449 return wm.FocusNeighbor(direction)
415 })450 })
416 451
417- // Register pane mode callbacks for Ctrl+P and pane mode actions452+ // Register pane mode callbacks for Ctrl+P and pane mode action keys
418 webView.RegisterPaneModeHandler(func(action string) bool {453 webView.RegisterPaneModeHandler(func(action string) bool {
419 if wm == nil {454 if wm == nil {
420 return false455 return false
421 }456 }
422 457
423 if action == "enter" {458 if action == "enter" {
424- wm.EnterPaneMode()459+ // CRITICAL FIX: Use app.workspace instead of closure's wm
425- return true460+ // 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 if action == "exit" {468 if action == "exit" {
@@ -438,18 +478,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
438 return false // No mode active, pass through to DOM478 return false // No mode active, pass through to DOM
439 }479 }
440 480
441- // For other actions, only handle if pane mode is active481+ // For other actions, use active workspace (same pattern as "enter")
442- wm.paneMutex.Lock()482+ // This ensures action keys work on the correct tab
443- isActive := wm.paneModeActive483+ if wm.app != nil && wm.app.workspace != nil {
444- wm.paneMutex.Unlock()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 {494+ // Pane mode is active, handle the action on active workspace
447- return false // Not in pane mode, allow normal behavior495+ activeWs.HandlePaneAction(action)
496+ return true
448 }497 }
449-498+ return false
450- // Pane mode is active, handle the action
451- wm.HandlePaneAction(action)
452- return true
453 }, func() bool {499 }, func() bool {
454 // Check if pane mode is currently active500 // Check if pane mode is currently active
455 wm.paneMutex.Lock()501 wm.paneMutex.Lock()
@@ -457,5 +503,204 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
457 return wm.paneModeActive503 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 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())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 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {210 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
211+ log.Printf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction)
211 wm.SetActivePane(neighbor, SourceKeyboard)212 wm.SetActivePane(neighbor, SourceKeyboard)
212 return true213 return true
213 }214 }
214 215
215- curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)216+ curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused)
216 cx := float64(curX) + float64(curWidth)/2.0217 cx := float64(curX) + float64(curWidth)/2.0
217 cy := float64(curY) + float64(curHeight)/2.0218 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 leaves := wm.collectLeaves()221 leaves := wm.collectLeaves()
220 bestScore := math.MaxFloat64222 bestScore := math.MaxFloat64
@@ -226,7 +228,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
226 continue228 continue
227 }229 }
228 230
229- x, y, width, height := webkit.WidgetGetAllocation(candidate.container)231+ x, y, width, height := wm.getNavigationAllocation(candidate)
230 tx := float64(x) + float64(width)/2.0232 tx := float64(x) + float64(width)/2.0
231 ty := float64(y) + float64(height)/2.0233 ty := float64(y) + float64(height)/2.0
232 234
@@ -247,18 +249,18 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
247 score = math.Abs(dx)*1000 + math.Abs(dy)249 score = math.Abs(dx)*1000 + math.Abs(dy)
248 case DirectionUp:250 case DirectionUp:
249 if dy >= -focusEpsilon {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 continue253 continue
251 }254 }
252 score = math.Abs(dy)*1000 + math.Abs(dx)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 case DirectionDown:257 case DirectionDown:
254 if dy <= focusEpsilon {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 continue260 continue
256 }261 }
257 score = math.Abs(dy)*1000 + math.Abs(dx)262 score = math.Abs(dy)*1000 + math.Abs(dx)
258- }263+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
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))
262 }264 }
263 265
264 if score < bestScore {266 if score < bestScore {
@@ -268,39 +270,83 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
268 }270 }
269 271
270 if best != nil {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 wm.SetActivePane(best, SourceKeyboard)275 wm.SetActivePane(best, SourceKeyboard)
272 return true276 return true
273 }277 }
274 278
275 if len(debugCandidates) > 0 {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 return false282 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 // structuralNeighbor finds neighbors based on the tree structure rather than geometry303 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
282 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {304 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
283 if node == nil || node.container == nil {305 if node == nil || node.container == nil {
284 return nil306 return nil
285 }307 }
286 308
287- refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)309+ refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node)
288 cx := float64(refX) + float64(refWidth)/2.0310 cx := float64(refX) + float64(refWidth)/2.0
289 cy := float64(refY) + float64(refHeight)/2.0311 cy := float64(refY) + float64(refHeight)/2.0
290 axisVertical := direction == DirectionUp || direction == DirectionDown312 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 switch direction {331 switch direction {
294 case DirectionUp:332 case DirectionUp:
295 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {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 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {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 return leaf338 return leaf
298 }339 }
299 }340 }
300 case DirectionDown:341 case DirectionDown:
301 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {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 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {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 return leaf347 return leaf
348+ } else {
349+ log.Printf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown")
304 }350 }
305 }351 }
306 case DirectionLeft:352 case DirectionLeft:
@@ -323,7 +369,8 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string)
323 369
324 // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction370 // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
325 func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {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 bestScore := math.MaxFloat64374 bestScore := math.MaxFloat64
328 var best *paneNode375 var best *paneNode
329 for _, leaf := range leaves {376 for _, leaf := range leaves {
@@ -331,44 +378,52 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
331 continue378 continue
332 }379 }
333 380
334- x, y, width, height := webkit.WidgetGetAllocation(leaf.container)381+ x, y, width, height := wm.getNavigationAllocation(leaf)
335 tx := float64(x) + float64(width)/2.0382 tx := float64(x) + float64(width)/2.0
336 ty := float64(y) + float64(height)/2.0383 ty := float64(y) + float64(height)/2.0
337 dx := tx - cx384 dx := tx - cx
338 dy := ty - cy385 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 var score float64387 var score float64
340 switch direction {388 switch direction {
341 case DirectionLeft:389 case DirectionLeft:
342 if dx >= -focusEpsilon {390 if dx >= -focusEpsilon {
391+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx)
343 continue392 continue
344 }393 }
345 score = math.Abs(dx)*1000 + math.Abs(dy)394 score = math.Abs(dx)*1000 + math.Abs(dy)
346 case DirectionRight:395 case DirectionRight:
347 if dx <= focusEpsilon {396 if dx <= focusEpsilon {
397+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx)
348 continue398 continue
349 }399 }
350 score = math.Abs(dx)*1000 + math.Abs(dy)400 score = math.Abs(dx)*1000 + math.Abs(dy)
351 case DirectionUp:401 case DirectionUp:
352 if dy >= -focusEpsilon {402 if dy >= -focusEpsilon {
403+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy)
353 continue404 continue
354 }405 }
355 score = math.Abs(dy)*1000 + math.Abs(dx)406 score = math.Abs(dy)*1000 + math.Abs(dx)
356 case DirectionDown:407 case DirectionDown:
357 if dy <= focusEpsilon {408 if dy <= focusEpsilon {
409+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy)
358 continue410 continue
359 }411 }
360 score = math.Abs(dy)*1000 + math.Abs(dx)412 score = math.Abs(dy)*1000 + math.Abs(dx)
361 default:413 default:
362 continue414 continue
363 }415 }
416+ log.Printf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score)
364 if score < bestScore {417 if score < bestScore {
365 bestScore = score418 bestScore = score
366 best = leaf419 best = leaf
367 }420 }
368 }421 }
369 if best == nil {422 if best == nil {
423+ log.Printf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback")
370 return wm.boundaryFallback(node, direction)424 return wm.boundaryFallback(node, direction)
371 }425 }
426+ log.Printf("[workspace] closestLeafFromSubtree: returning best=%p", best.container)
372 return best427 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 "log"5 "log"
6 6
7 "github.com/bnema/dumber/pkg/webkit"7 "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
8 )9 )
9 10
10 // EnterPaneMode activates pane mode on the currently focused pane11 // EnterPaneMode activates pane mode on the currently focused pane
11 // This is called directly from the keyboard bridge, not from JavaScript12 // This is called directly from the keyboard bridge, not from JavaScript
12 func (wm *WorkspaceManager) EnterPaneMode() {13 func (wm *WorkspaceManager) EnterPaneMode() {
14+ // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab)
15+
13 wm.paneMutex.Lock()16 wm.paneMutex.Lock()
14 defer wm.paneMutex.Unlock()17 defer wm.paneMutex.Unlock()
15 18
@@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() {
26 return29 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 wm.paneModeActive = true34 wm.paneModeActive = true
32 wm.paneModeActivePane = activeNode35 wm.paneModeActivePane = activeNode
@@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) {
67 // HandlePaneAction processes pane mode actions (close, split, etc.)70 // HandlePaneAction processes pane mode actions (close, split, etc.)
68 // Called from keyboard bridge when user presses action keys in pane mode71 // Called from keyboard bridge when user presses action keys in pane mode
69 func (wm *WorkspaceManager) HandlePaneAction(action string) {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 wm.paneMutex.Lock()76 wm.paneMutex.Lock()
71 77
72 if !wm.paneModeActive {78 if !wm.paneModeActive {
@@ -201,34 +207,59 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) {
201 207
202 // applyPaneModeBorder applies pane mode visual indicator using GTK margins208 // applyPaneModeBorder applies pane mode visual indicator using GTK margins
203 func (wm *WorkspaceManager) applyPaneModeBorder() {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 return236 return
206 }237 }
207 238
208 // Save the container reference so we can remove margins from it later239 // Save the container reference so we can remove margins from it later
209- // (even if wm.root.container changes due to splits)240+ wm.paneModeContainer = targetContainer
210- wm.paneModeContainer = wm.root.container
211 241
212- // Apply 4px margins to root container to create space for the border242+ // Apply 4px margins to create space for the border
213- webkit.WidgetSetMargin(wm.root.container, 4)243+ webkit.WidgetSetMargin(targetContainer, 4)
214 244
215 // Add CSS class to window for background color (the "border" color shows in the margin space)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 // Force resize/allocation to apply margin changes immediately248 // Force resize/allocation to apply margin changes immediately
219- webkit.WidgetQueueResize(wm.root.container)249+ webkit.WidgetQueueResize(targetContainer)
220- webkit.WidgetQueueAllocate(wm.root.container)250+ webkit.WidgetQueueAllocate(targetContainer)
221 251
222 // Queue redraw to show changes252 // Queue redraw to show changes
223- webkit.WidgetQueueDraw(wm.window.AsWindow())253+ webkit.WidgetQueueDraw(window.AsWindow())
224- webkit.WidgetQueueDraw(wm.root.container)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 // removePaneModeBorder removes the pane mode visual indicator259 // removePaneModeBorder removes the pane mode visual indicator
230 func (wm *WorkspaceManager) removePaneModeBorder() {260 func (wm *WorkspaceManager) removePaneModeBorder() {
231- if wm.window == nil {261+ window := wm.resolveWindow()
262+ if window == nil {
232 return263 return
233 }264 }
234 265
@@ -247,14 +278,14 @@ func (wm *WorkspaceManager) removePaneModeBorder() {
247 webkit.WidgetSetMargin(container, 0)278 webkit.WidgetSetMargin(container, 0)
248 279
249 // Remove CSS class from window280 // 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 // Force resize/allocation to apply margin changes immediately283 // Force resize/allocation to apply margin changes immediately
253 webkit.WidgetQueueResize(container)284 webkit.WidgetQueueResize(container)
254 webkit.WidgetQueueAllocate(container)285 webkit.WidgetQueueAllocate(container)
255 286
256 // Queue redraw to show changes287 // Queue redraw to show changes
257- webkit.WidgetQueueDraw(wm.window.AsWindow())288+ webkit.WidgetQueueDraw(window.AsWindow())
258 webkit.WidgetQueueDraw(container)289 webkit.WidgetQueueDraw(container)
259 290
260 // Clear the saved container reference291 // Clear the saved container reference
M internal/app/browser/workspace_pane_ops.go +132 -27
@@ -3,11 +3,13 @@ package browser
3 3
4 import (4 import (
5 "errors"5 "errors"
6+ "fmt"
6 "log"7 "log"
7 8
8- "github.com/bnema/dumber/pkg/webkit"
9 "github.com/diamondburned/gotk4/pkg/gdk/v4"9 "github.com/diamondburned/gotk4/pkg/gdk/v4"
10 "github.com/diamondburned/gotk4/pkg/gtk/v4"10 "github.com/diamondburned/gotk4/pkg/gtk/v4"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
11 )13 )
12 14
13 // closeContext manages the context for a pane close operation15 // closeContext manages the context for a pane close operation
@@ -52,6 +54,60 @@ func (wm *WorkspaceManager) collectLeaves() []*paneNode {
52 return wm.collectLeavesFrom(wm.root)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 // collectLeavesFrom collects all leaf nodes from a given subtree111 // collectLeavesFrom collects all leaf nodes from a given subtree
56 func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {112 func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
57 var leaves []*paneNode113 var leaves []*paneNode
@@ -475,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi
475 531
476 // GTK4 handles widget operations automatically - no need to force redraw532 // 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 reparenting534+ // Handle root split: attach paned to ContentArea (tab system is always present)
479 if parent == nil {535 if parent == nil {
480- // ROOT SPLIT: Attach empty paned to window, then add children536+ // ROOT SPLIT: Manipulate TabManager.ContentArea
481- // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild537+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
482- if wm.window != nil {538+ log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized")
483- // Clear any existing window child (the splitTargetContainer)539+ return nil, fmt.Errorf("tab manager not initialized")
484- wm.window.SetChild(nil)540+ }
485-541+
486- // Attach the new paned to window FIRST542+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
487- wm.window.SetChild(paned)543+ if !ok || contentBox == nil {
488- log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)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 // Now add children to the paned - GTK will handle reparenting automatically556 // Now add children to the paned - GTK will handle reparenting automatically
492 if existingFirst {557 if existingFirst {
493 paned.SetStartChild(splitTargetContainer)558 paned.SetStartChild(splitTargetContainer)
@@ -656,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si
656 }721 }
657 722
658 // swapContainers updates GTK widget hierarchy for promoted siblings723 // 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 if grand == nil {725 if grand == nil {
726+ // Sibling is being promoted to root
661 // When TreeRebalancer is enabled, skip GTK attachment here as it will be727 // When TreeRebalancer is enabled, skip GTK attachment here as it will be
662 // handled by the rebalancer's promotion transaction to avoid double-attachment728 // handled by the rebalancer's promotion transaction to avoid double-attachment
663 if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {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 return760 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 root870+// 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 func (wm *WorkspaceManager) attachRoot(root *paneNode) {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 return875 return
780 }876 }
781 877
782 // Note: In gotk4, reference counting is handled automatically by Go's GC878 // Note: In gotk4, reference counting is handled automatically by Go's GC
783 // No need for manual Ref()/Unref() calls879 // No need for manual Ref()/Unref() calls
784 880
785- // Detach from previous container (paned, stack, etc.) before replacing the window child.881+ // Detach from previous container (paned, stack, etc.) before attaching to ContentArea
786- // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent882+ // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
787 if parent := webkit.WidgetGetParent(root.container); parent != nil {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 webkit.WidgetUnparent(root.container)885 webkit.WidgetUnparent(root.container)
790 // Note: In gotk4, the Go object reference remains valid after unparent886 // Note: In gotk4, the Go object reference remains valid after unparent
791 // Only the GTK parent relationship is cleared887 // 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 if root.container != nil {890 if root.container != nil {
798 webkit.WidgetSetHExpand(root.container, true)891 webkit.WidgetSetHExpand(root.container, true)
799 webkit.WidgetSetVExpand(root.container, true)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 here895+ // Add to ContentArea (the old root will be orphaned and garbage collected)
803- wm.window.SetChild(root.container)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 if root.container != nil {909 if root.container != nil {
805 webkit.WidgetQueueAllocate(root.container)910 webkit.WidgetQueueAllocate(root.container)
806 webkit.WidgetShow(root.container)911 webkit.WidgetShow(root.container)
@@ -997,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
997 1102
998 // STEP 7: GTK widget reparenting1103 // STEP 7: GTK widget reparenting
999 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent1104 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1000- // For window: we already manually unparented above, so now we can safely attach1105+ // For root promotion: we need to remove the old paned from ContentArea
1001- wm.swapContainers(grandparent, sibling)1106+ wm.swapContainers(grandparent, parent, sibling)
1002 1107
1003 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.1108 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1004 if sibling != nil && sibling.container != nil {1109 if sibling != nil && sibling.container != nil {
@@ -1083,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
1083 }1188 }
1084 }1189 }
1085 } else {1190 } else {
1086- // Stack was root1191+ // Stack was root - attach remaining pane as new root
1087 wm.root = remaining1192 wm.root = remaining
1088 if remaining.container != nil {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 "github.com/bnema/dumber/internal/config"9 "github.com/bnema/dumber/internal/config"
10 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
11 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"11 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12- "github.com/diamondburned/gotk4/pkg/glib/v2"
13 )12 )
14 13
15 const (14 const (
@@ -342,34 +341,26 @@ func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNo
342 }341 }
343 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")342 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344 343
345- // CRITICAL: Do NOT connect signals before returning to WebKit344+ // Connect signals immediately BEFORE returning to WebKit
346- // Connecting signals might trigger WebKit to access WindowFeatures before it's set345+ // This is safe because we're only connecting signal handlers, not accessing properties
347- // Instead, we'll connect them after WebKit has processed the return346+ // WebKit will configure WindowFeatures AFTER this callback returns, which is fine
348- log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")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 returns349+ // Connect ready-to-show signal
351- // This ensures WebKit has finished configuring WindowFeatures before we touch the view350+ barePopupView.ConnectReadyToShow(func() {
352- glib.IdleAdd(func() bool {351+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
353- log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)352+ wm.handlePopupReadyToShow(popupID)
354-353+ })
355- // Connect ready-to-show signal NOW (after WebKit has configured the view)354+ log.Printf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID)
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")
368 355
369- return false // Don't repeat356+ // 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 log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")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 log.Printf("[workspace] Created popup pane, now inserting into workspace")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 behavior412+ // Determine window type from node (set during setupPopupHandling)
422- behavior := wm.app.config.Workspace.Popups.Behavior413+ node := wm.viewToNode[wrappedView]
423- log.Printf("[workspace] Popup behavior: %s", behavior)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 switch behavior {446 switch behavior {
426 case config.PopupBehaviorSplit:447 case config.PopupBehaviorSplit:
@@ -490,12 +511,13 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
490 }511 }
491 512
492 // Configure the popup node513 // 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 if node != nil {517 if node != nil {
495- node.windowType = webkit.WindowTypePopup
496 node.isRelated = true518 node.isRelated = true
497 node.parentPane = info.parentNode519 node.parentPane = info.parentNode
498- node.isPopup = true520+ node.isPopup = true // Mark as popup to prevent browser exit when parent closes
499 node.autoClose = wm.shouldAutoClose(info.url)521 node.autoClose = wm.shouldAutoClose(info.url)
500 node.popupID = popupID // Store popupID for close signal lookup522 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 // mapDirection maps a direction string to GTK orientation and positioning35 // 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 func mapDirection(direction string) (gtk.Orientation, bool) {39 func mapDirection(direction string) (gtk.Orientation, bool) {
37 switch direction {40 switch direction {
38 case DirectionLeft:41 case DirectionLeft:
42+ // New pane goes to left, existing to right
39 return gtk.OrientationHorizontal, false43 return gtk.OrientationHorizontal, false
40 case DirectionUp:44 case DirectionUp:
45+ // New pane goes to top (start), existing to bottom (end)
46+ // So existingFirst = false
41 return gtk.OrientationVertical, false47 return gtk.OrientationVertical, false
42 case DirectionDown:48 case DirectionDown:
49+ // New pane goes to bottom (end), existing to top (start)
50+ // So existingFirst = true
43 return gtk.OrientationVertical, true51 return gtk.OrientationVertical, true
44 default:52 default:
53+ // Right: new pane to right, existing to left
45 return gtk.OrientationHorizontal, true54 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 detail["type"] = toastType51 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 log.Printf("Failed to dispatch toast event: %v", err)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 InjectScript(script string) error26 InjectScript(script string) error
27 RegisterZoomChangedHandler(handler func(float64))27 RegisterZoomChangedHandler(handler func(float64))
28 RegisterURIChangedHandler(handler func(string))28 RegisterURIChangedHandler(handler func(string))
29+ RegisterLoadCommittedHandler(handler func(string))
29 }30 }
30 31
31 // NewZoomController creates a new zoom controller32 // NewZoomController creates a new zoom controller
@@ -41,11 +42,17 @@ func NewZoomController(browserService *services.BrowserService, webView *webkit.
41 func (z *ZoomController) RegisterHandlers() {42 func (z *ZoomController) RegisterHandlers() {
42 z.webView.RegisterZoomChangedHandler(z.handleZoomChange)43 z.webView.RegisterZoomChangedHandler(z.handleZoomChange)
43 z.webView.RegisterURIChangedHandler(z.handleURIChange)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 levels48+// handleURIChange tracks URL changes but doesn't apply zoom (wait for load-committed)
47 func (z *ZoomController) handleURIChange(url string) {49 func (z *ZoomController) handleURIChange(url string) {
48 z.currentURL = url50 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 if url == "" {56 if url == "" {
50 return57 return
51 }58 }
M internal/app/environment/locale.go +34 -24
@@ -12,38 +12,21 @@ import (
12 func DetectAndSetKeyboardLocale() {12 func DetectAndSetKeyboardLocale() {
13 const colonSplitParts = 2 // Number of parts when splitting on colon13 const colonSplitParts = 2 // Number of parts when splitting on colon
14 14
15+ var locale string
16+
15 // 0) Explicit override17 // 0) Explicit override
16- locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")18+ locale = os.Getenv("DUMBER_KEYBOARD_LOCALE")
17 if locale == "" {19 if locale == "" {
18 locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix20 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 override23+ // 1) XKB env override (actual keyboard layout)
42 if locale == "" {24 if locale == "" {
43 locale = os.Getenv("XKB_DEFAULT_LAYOUT")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 if locale == "" {30 if locale == "" {
48 if out, err := exec.Command("localectl", "status").Output(); err == nil {31 if out, err := exec.Command("localectl", "status").Output(); err == nil {
49 s := string(out)32 s := string(out)
@@ -55,6 +38,7 @@ func DetectAndSetKeyboardLocale() {
55 cand := strings.TrimSpace(parts[1])38 cand := strings.TrimSpace(parts[1])
56 if cand != "" {39 if cand != "" {
57 locale = cand40 locale = cand
41+ log.Printf("[locale] keyboard layout from localectl: %s", locale)
58 }42 }
59 }43 }
60 break44 break
@@ -73,6 +57,7 @@ func DetectAndSetKeyboardLocale() {
73 cand := strings.TrimSpace(parts[1])57 cand := strings.TrimSpace(parts[1])
74 if cand != "" {58 if cand != "" {
75 locale = cand59 locale = cand
60+ log.Printf("[locale] keyboard layout from setxkbmap: %s", locale)
76 }61 }
77 }62 }
78 break63 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 if locale == "" {93 if locale == "" {
85 locale = "en"94 locale = "en"
95+ log.Printf("[locale] keyboard layout defaulted to: %s", locale)
86 }96 }
87- // No layout-specific remaps; log for diagnostics only97+
88 log.Printf("[locale] keyboard locale detected: %s", locale)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 log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)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 failures18+ // Optionally disable media stream features that can engage complex pipelines (webcam/mic)
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
25 if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {19 if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
26 if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {20 if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
27 log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)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 // Wails fetch bridge50 // Wails fetch bridge
51 ID string `json:"id"`51 ID string `json:"id"`
52 Payload json.RawMessage `json:"payload"`52 Payload json.RawMessage `json:"payload"`
53+ // Favorites operations
54+ Title string `json:"title"`
55+ FaviconURL string `json:"faviconURL"`
53 }56 }
54 57
55 // NewHandler creates a new message handler58 // NewHandler creates a new message handler
@@ -110,6 +113,12 @@ func (h *Handler) Handle(payload string) {
110 h.handleGetSearchShortcuts(msg)113 h.handleGetSearchShortcuts(msg)
111 case "get_color_palettes":114 case "get_color_palettes":
112 h.handleGetColorPalettes(msg)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 log.Printf("[messaging] Sending color palettes to JavaScript")577 log.Printf("[messaging] Sending color palettes to JavaScript")
569 _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")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 Database DatabaseConfig `mapstructure:"database" yaml:"database"`26 Database DatabaseConfig `mapstructure:"database" yaml:"database"`
27 History HistoryConfig `mapstructure:"history" yaml:"history"`27 History HistoryConfig `mapstructure:"history" yaml:"history"`
28 SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`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 Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`32 Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
31 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`33 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
32 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`34 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
@@ -67,8 +69,8 @@ type HistoryConfig struct {
67 69
68 // SearchShortcut represents a search shortcut configuration.70 // SearchShortcut represents a search shortcut configuration.
69 type SearchShortcut struct {71 type SearchShortcut struct {
70- URL string `mapstructure:"url" yaml:"url" json:"url"`72+ URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
71- Description string `mapstructure:"description" yaml:"description" json:"description"`73+ Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
72 }74 }
73 75
74 // DmenuConfig holds dmenu/rofi integration configuration.76 // DmenuConfig holds dmenu/rofi integration configuration.
@@ -214,12 +216,14 @@ type DebugConfig struct {
214 216
215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.217 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
216 type WorkspaceConfig struct {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 // PaneMode defines modal pane behaviour and bindings.219 // PaneMode defines modal pane behaviour and bindings.
220 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`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 // Tabs holds classic browser tab shortcuts.223 // Tabs holds classic browser tab shortcuts.
222 Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" json:"tabs"`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 // Popups configures default popup placement rules.227 // Popups configures default popup placement rules.
224 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`228 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`
225 // Styling configures workspace visual appearance.229 // Styling configures workspace visual appearance.
@@ -228,9 +232,40 @@ type WorkspaceConfig struct {
228 232
229 // PaneModeConfig defines modal behaviour for pane management.233 // PaneModeConfig defines modal behaviour for pane management.
230 type PaneModeConfig struct {234 type PaneModeConfig struct {
231- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`235+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
232- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`236+ 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"`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 // TabKeyConfig defines Zellij-inspired tab shortcuts.271 // TabKeyConfig defines Zellij-inspired tab shortcuts.
@@ -270,8 +305,9 @@ type PopupBehaviorConfig struct {
270 // FollowPaneContext determines if popup placement follows parent pane context305 // FollowPaneContext determines if popup placement follows parent pane context
271 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`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") intents308+ // BlankTargetBehavior determines how target="_blank" links are opened
274- // Accepted values: "pane" (default) or "tab" (future support)309+ // Accepted values: "split", "stacked" (default), "tabbed"
310+ // This is separate from Behavior which controls JavaScript popups
275 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`311 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
276 312
277 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents313 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
@@ -283,17 +319,28 @@ type PopupBehaviorConfig struct {
283 319
284 // WorkspaceStylingConfig defines visual styling for workspace panes.320 // WorkspaceStylingConfig defines visual styling for workspace panes.
285 type WorkspaceStylingConfig struct {321 type WorkspaceStylingConfig struct {
286- // BorderWidth in pixels for pane borders322+ // BorderWidth in pixels for active pane borders
287 BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`323 BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`
288 // BorderColor for focused panes (CSS color value or theme variable)324 // BorderColor for focused panes (CSS color value or theme variable)
289 BorderColor string `mapstructure:"border_color" yaml:"border_color" json:"border_color"`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 // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)332 // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
291- // Defaults to "#FFA500" (orange) if not set333+ // Defaults to "#4A90E2" (blue) if not set
292 PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" json:"pane_mode_border_color"`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 // TransitionDuration in milliseconds for border animations338 // TransitionDuration in milliseconds for border animations
294 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`339 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`
295 // BorderRadius in pixels for pane border corners340 // BorderRadius in pixels for pane border corners
296 BorderRadius int `mapstructure:"border_radius" yaml:"border_radius" json:"border_radius"`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 // Manager handles configuration loading, watching, and reloading.346 // Manager handles configuration loading, watching, and reloading.
@@ -315,7 +362,7 @@ func NewManager() (*Manager, error) {
315 // Add config paths362 // Add config paths
316 configDir, err := GetConfigDir()363 configDir, err := GetConfigDir()
317 if err != nil {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 v.AddConfigPath(configDir)367 v.AddConfigPath(configDir)
321 v.AddConfigPath(".") // Current directory for development368 v.AddConfigPath(".") // Current directory for development
@@ -325,36 +372,11 @@ func NewManager() (*Manager, error) {
325 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))372 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
326 v.AutomaticEnv()373 v.AutomaticEnv()
327 374
328- // Bind specific environment variables375+ // Note: Most environment variables are handled automatically via AutomaticEnv()
329- bindings := map[string]string{376+ // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
330- "database.path": "DATABASE_PATH",377+ // The explicit bindings below are only for special cases with different naming patterns.
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- }
356 378
357- // Explicit binding for rendering mode via dedicated env var379+ // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
358 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {380 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
359 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)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 // Video acceleration environment variable bindings390 // Video acceleration environment variable bindings
391+ // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
369 videoAccelEnvBindings := map[string]string{392 videoAccelEnvBindings := map[string]string{
370 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",393 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
371 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",394 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
@@ -381,6 +404,7 @@ func NewManager() (*Manager, error) {
381 }404 }
382 405
383 // Codec preferences environment variable bindings406 // Codec preferences environment variable bindings
407+ // These use DUMBER_* prefix for shorter, more convenient env var names
384 codecEnvBindings := map[string]string{408 codecEnvBindings := map[string]string{
385 "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",409 "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
386 "codec_preferences.force_av1": "DUMBER_FORCE_AV1",410 "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
@@ -426,21 +450,28 @@ func (m *Manager) Load() error {
426 if errors.As(err, &configFileNotFoundError) {450 if errors.As(err, &configFileNotFoundError) {
427 // Config file not found, create default one451 // Config file not found, create default one
428 if err := m.createDefaultConfig(); err != nil {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 // Re-read the newly created config file456 // Re-read the newly created config file
432 if err := m.viper.ReadInConfig(); err != nil {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 } else {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 // Unmarshal into config struct470 // Unmarshal into config struct
441 config := &Config{}471 config := &Config{}
442 if err := m.viper.Unmarshal(config); err != nil {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 // Set database path if not specified477 // Set database path if not specified
@@ -478,6 +509,11 @@ func (m *Manager) Load() error {
478 // Validate ColorScheme setting509 // Validate ColorScheme setting
479 m.validateColorScheme(config)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 m.config = config517 m.config = config
482 return nil518 return nil
483 }519 }
@@ -579,6 +615,11 @@ func (m *Manager) reload() error {
579 // Validate ColorScheme setting615 // Validate ColorScheme setting
580 m.validateColorScheme(config)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 m.config = config623 m.config = config
583 return nil624 return nil
584 }625 }
@@ -598,6 +639,7 @@ func (m *Manager) setDefaults() {
598 639
599 // Search shortcuts defaults640 // Search shortcuts defaults
600 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)641 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
642+ m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
601 643
602 // Dmenu defaults644 // Dmenu defaults
603 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)645 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
@@ -674,14 +716,17 @@ func (m *Manager) setDefaults() {
674 m.viper.SetDefault("default_zoom", defaults.DefaultZoom)716 m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
675 717
676 // Workspace defaults718 // Workspace defaults
677- m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
678 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)719 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
679 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)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 m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)725 m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
682 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)726 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
683 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)727 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
684 m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)728 m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
729+ m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
685 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))730 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
686 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)731 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
687 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)732 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -692,9 +737,13 @@ func (m *Manager) setDefaults() {
692 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)737 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
693 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)738 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
694 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)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 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)743 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
696 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)744 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
697 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)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 // createDefaultConfig creates a default configuration file.749 // createDefaultConfig creates a default configuration file.
@@ -714,12 +763,14 @@ func (m *Manager) createDefaultConfig() error {
714 return fmt.Errorf("failed to write config file: %w", err)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 file768+ // Generate JSON schema file only for JSON configs (used for IDE autocompletion)
720- if err := GenerateSchemaFile(); err != nil {769+ if filepath.Ext(configFile) == ".json" {
721- // Log error but don't fail config creation770+ if err := GenerateSchemaFile(); err != nil {
722- fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)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 return nil776 return nil
M internal/config/defaults.go +49 -26
@@ -21,14 +21,22 @@ const (
21 // Workspace defaults21 // Workspace defaults
22 defaultPaneActivationShortcut = "ctrl+p"22 defaultPaneActivationShortcut = "ctrl+p"
23 defaultPaneTimeoutMilliseconds = 300023 defaultPaneTimeoutMilliseconds = 3000
24+ defaultTabActivationShortcut = "ctrl+t"
25+ defaultTabTimeoutMilliseconds = 3000
26+ defaultTabBarPosition = "bottom"
24 defaultPopupPlacement = "right"27 defaultPopupPlacement = "right"
25 28
26 // Workspace styling defaults29 // Workspace styling defaults
27- defaultBorderWidth = 230+ defaultBorderWidth = 1
28- defaultBorderColor = "@theme_selected_bg_color"31+ defaultBorderColor = "@theme_selected_bg_color"
29- defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator32+ defaultInactiveBorderWidth = 1 // Same width as active to prevent layout shift
30- defaultTransitionDuration = 12033+ defaultInactiveBorderColor = "@theme_unfocused_bg_color" // GTK theme variable
31- defaultBorderRadius = 034+ 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 // getDefaultLogDir returns the default log directory, falls back to empty string on error42 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -51,7 +59,8 @@ func DefaultConfig() *Config {
51 RetentionPeriodDays: defaultRetentionDays, // 1 year59 RetentionPeriodDays: defaultRetentionDays, // 1 year
52 CleanupIntervalDays: 1, // daily cleanup60 CleanupIntervalDays: 1, // daily cleanup
53 },61 },
54- SearchShortcuts: GetDefaultSearchShortcuts(),62+ SearchShortcuts: GetDefaultSearchShortcuts(),
63+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
55 Dmenu: DmenuConfig{64 Dmenu: DmenuConfig{
56 MaxHistoryItems: defaultMaxHistoryItems,65 MaxHistoryItems: defaultMaxHistoryItems,
57 ShowVisitCount: true,66 ShowVisitCount: true,
@@ -142,22 +151,30 @@ func DefaultConfig() *Config {
142 UseDomZoom: false,151 UseDomZoom: false,
143 DefaultZoom: 1.2, // 120% default zoom for better readability152 DefaultZoom: 1.2, // 120% default zoom for better readability
144 Workspace: WorkspaceConfig{153 Workspace: WorkspaceConfig{
145- EnableZellijControls: true,
146 PaneMode: PaneModeConfig{154 PaneMode: PaneModeConfig{
147 ActivationShortcut: defaultPaneActivationShortcut,155 ActivationShortcut: defaultPaneActivationShortcut,
148 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,156 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
149- ActionBindings: map[string]string{157+ Actions: map[string][]string{
150- "arrowright": "split-right",158+ "split-right": {"arrowright", "r"},
151- "arrowleft": "split-left",159+ "split-left": {"arrowleft", "l"},
152- "arrowup": "split-up",160+ "split-up": {"arrowup", "u"},
153- "arrowdown": "split-down",161+ "split-down": {"arrowdown", "d"},
154- "r": "split-right",162+ "close-pane": {"x"},
155- "l": "split-left",163+ "confirm": {"enter"},
156- "u": "split-up",164+ "cancel": {"escape"},
157- "d": "split-down",165+ },
158- "x": "close-pane",166+ },
159- "enter": "confirm",167+ TabMode: TabModeConfig{
160- "escape": "cancel",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 Tabs: TabKeyConfig{180 Tabs: TabKeyConfig{
@@ -166,21 +183,27 @@ func DefaultConfig() *Config {
166 NextTab: "ctrl+tab",183 NextTab: "ctrl+tab",
167 PreviousTab: "ctrl+shift+tab",184 PreviousTab: "ctrl+shift+tab",
168 },185 },
186+ TabBarPosition: defaultTabBarPosition,
169 Popups: PopupBehaviorConfig{187 Popups: PopupBehaviorConfig{
170- Behavior: PopupBehaviorSplit, // Default: open popups in split panes188+ Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
171 Placement: defaultPopupPlacement,189 Placement: defaultPopupPlacement,
172 OpenInNewPane: true,190 OpenInNewPane: true,
173 FollowPaneContext: true,191 FollowPaneContext: true,
174- BlankTargetBehavior: "pane", // Default to pane, future: "tab"192+ BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
175 EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab193 EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
176 OAuthAutoClose: true, // Auto-close OAuth popups on success194 OAuthAutoClose: true, // Auto-close OAuth popups on success
177 },195 },
178 Styling: WorkspaceStylingConfig{196 Styling: WorkspaceStylingConfig{
179- BorderWidth: defaultBorderWidth,197+ BorderWidth: defaultBorderWidth,
180- BorderColor: defaultBorderColor,198+ BorderColor: defaultBorderColor,
181- PaneModeBorderColor: defaultPaneModeBorderColor,199+ InactiveBorderWidth: defaultInactiveBorderWidth,
182- TransitionDuration: defaultTransitionDuration,200+ InactiveBorderColor: defaultInactiveBorderColor,
183- BorderRadius: defaultBorderRadius,201+ ShowStackedTitleBorder: defaultShowStackedTitleBorder,
202+ PaneModeBorderColor: defaultPaneModeBorderColor,
203+ TabModeBorderColor: defaultTabModeBorderColor,
204+ TransitionDuration: defaultTransitionDuration,
205+ BorderRadius: defaultBorderRadius,
206+ UIScale: defaultUIScale,
184 },207 },
185 },208 },
186 ContentFilteringWhitelist: []string{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 if err != nil {114 if err != nil {
115 return "", err115 return "", err
116 }116 }
117- return filepath.Join(configDir, "config.json"), nil117+ return filepath.Join(configDir, "config.toml"), nil
118 }118 }
119 119
120 // GetDatabaseFile returns the path to the database file in the data directory.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 DeleteExpiredCertificateValidations(ctx context.Context) error36 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 // DatabaseQuerier combines all database operation interfaces51 // DatabaseQuerier combines all database operation interfaces
40 type DatabaseQuerier interface {52 type DatabaseQuerier interface {
41 ZoomQuerier53 ZoomQuerier
42 HistoryQuerier54 HistoryQuerier
43 CertificateQuerier55 CertificateQuerier
56+ FavoritesQuerier
44 }57 }
45 58
46 // Ensure that *Queries implements DatabaseQuerier interface59 // Ensure that *Queries implements DatabaseQuerier interface
M internal/db/models.go +10 -0
@@ -17,6 +17,16 @@ type CertificateValidation struct {
17 ExpiresAt sql.NullTime `json:"expires_at"`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 type FuzzyCacheMetadatum struct {30 type FuzzyCacheMetadatum struct {
21 ID int64 `json:"id"`31 ID int64 `json:"id"`
22 Version int64 `json:"version"`32 Version int64 `json:"version"`
M internal/db/querier.go +16 -0
@@ -13,13 +13,23 @@ type Querier interface {
13 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error13 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
14 // Cleanup zoom level entries older than specified days14 // Cleanup zoom level entries older than specified days
15 CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error15 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 DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error18 DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error
17 DeleteExpiredCertificateValidations(ctx context.Context) error19 DeleteExpiredCertificateValidations(ctx context.Context) error
20+ // Delete a favorite by URL
21+ DeleteFavorite(ctx context.Context, url string) error
18 DeleteHistory(ctx context.Context, id int64) error22 DeleteHistory(ctx context.Context, id int64) error
19 // Delete zoom level setting for a domain23 // Delete zoom level setting for a domain
20 DeleteZoomLevel(ctx context.Context, domain string) error24 DeleteZoomLevel(ctx context.Context, domain string) error
25+ // Get all favorites ordered by position
26+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)27 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22 GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)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 GetHistory(ctx context.Context, limit int64) ([]History, error)33 GetHistory(ctx context.Context, limit int64) ([]History, error)
24 GetHistoryEntry(ctx context.Context, url string) (History, error)34 GetHistoryEntry(ctx context.Context, url string) (History, error)
25 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)35 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
@@ -27,6 +37,8 @@ type Querier interface {
27 GetZoomLevel(ctx context.Context, domain string) (float64, error)37 GetZoomLevel(ctx context.Context, domain string) (float64, error)
28 // Get zoom level for domain with default fallback38 // Get zoom level for domain with default fallback
29 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)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 ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)42 ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
31 // List all zoom level settings ordered by most recently updated43 // List all zoom level settings ordered by most recently updated
32 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)44 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
@@ -34,6 +46,10 @@ type Querier interface {
34 // Set or update zoom level for a domain with validation46 // Set or update zoom level for a domain with validation
35 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error47 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
36 StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error48 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 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error53 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 if input == "" {18 if input == "" {
19 return &ParseResult{19 return &ParseResult{
20 Type: InputTypeFallbackSearch,20 Type: InputTypeFallbackSearch,
21- URL: "https://www.google.com/search?q=",21+ URL: fmt.Sprintf(p.config.DefaultSearchEngine, ""),
22 Query: input,22 Query: input,
23 Confidence: 0.0,23 Confidence: 0.0,
24 ProcessingTime: time.Since(startTime),24 ProcessingTime: time.Since(startTime),
@@ -29,19 +29,29 @@ func (p *Parser) ParseInput(input string) (*ParseResult, error) {
29 finalURL, err := p.parseInputUsingCLILogic(input)29 finalURL, err := p.parseInputUsingCLILogic(input)
30 if err != nil {30 if err != nil {
31 // If parsing fails, fall back to search31 // 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 // Determine result type based on the URL35 // Determine result type based on the URL
36- var resultType InputType = InputTypeFallbackSearch36+ var resultType InputType
37- confidence := 0.137+ 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 resultType = InputTypeFallbackSearch44 resultType = InputTypeFallbackSearch
41 confidence = 0.145 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 resultType = InputTypeDirectURL50 resultType = InputTypeDirectURL
44 confidence = 1.051 confidence = 1.0
52+ default:
53+ resultType = InputTypeFallbackSearch
54+ confidence = 0.1
45 }55 }
46 56
47 // Check if it was a shortcut57 // Check if it was a shortcut
@@ -140,7 +150,7 @@ func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
140 // 8. Everything else is a search query150 // 8. Everything else is a search query
141 // This includes: single words, multi-word phrases, questions, special characters, etc.151 // This includes: single words, multi-word phrases, questions, special characters, etc.
142 // Single words without TLDs should be treated as search queries, not hostnames152 // 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)), nil153+ return fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input)), nil
144 }154 }
145 155
146 // parseSearchShortcut handles search shortcut inputs.156 // parseSearchShortcut handles search shortcut inputs.
@@ -256,22 +266,16 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear
256 // buildSearchURL builds a search URL using the default search engine.266 // buildSearchURL builds a search URL using the default search engine.
257 func (p *Parser) buildSearchURL(searchEngine, query string) string {267 func (p *Parser) buildSearchURL(searchEngine, query string) string {
258 q := neturl.QueryEscape(query)268 q := neturl.QueryEscape(query)
259- // If no specific search engine, use Google as default269+ // If no specific search engine, use the configured default
260 if searchEngine == "" {270 if searchEngine == "" {
261- defaultShortcut, exists := p.config.SearchShortcuts["g"]271+ return fmt.Sprintf(p.config.DefaultSearchEngine, q)
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)
269 }272 }
270 273
271 // Use specified search engine274 // Use specified search engine
272 shortcut, exists := p.config.SearchShortcuts[searchEngine]275 shortcut, exists := p.config.SearchShortcuts[searchEngine]
273 if !exists {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 u := strings.ReplaceAll(shortcut.URL, "{query}", q)281 u := strings.ReplaceAll(shortcut.URL, "{query}", q)
@@ -433,9 +437,13 @@ func (p *Parser) ValidateConfig() error {
433 return fmt.Errorf("no search shortcuts configured")437 return fmt.Errorf("no search shortcuts configured")
434 }438 }
435 439
436- // Check for default search shortcut440+ // Check for default search engine
437- if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle {441+ if p.config.DefaultSearchEngine == "" {
438- // Warning: no default search configured442+ 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 return nil449 return nil
M internal/services/browser.go +90 -0
@@ -50,6 +50,7 @@ type BrowserService struct {
50 guiBundle string50 guiBundle string
51 zoomCache *cache.ZoomCache // In-memory cache for zoom levels51 zoomCache *cache.ZoomCache // In-memory cache for zoom levels
52 certCache *cache.CertValidationsCache // In-memory cache for certificate validations52 certCache *cache.CertValidationsCache // In-memory cache for certificate validations
53+ favoritesCache *cache.FavoritesCache // In-memory cache for favorites
53 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu54 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu
54 historyQueue chan historyUpdate // Queue for batched history writes55 historyQueue chan historyUpdate // Queue for batched history writes
55 historyFlushDone chan bool // Signal when history flush is complete (buffered)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 guiBundle: "",98 guiBundle: "",
98 zoomCache: cache.NewZoomCache(queries),99 zoomCache: cache.NewZoomCache(queries),
99 certCache: cache.NewCertValidationsCache(queries),100 certCache: cache.NewCertValidationsCache(queries),
101+ favoritesCache: cache.NewFavoritesCache(queries),
100 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults102 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults
101 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates103 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
102 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking104 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking
@@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error {
576 return nil578 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 // FlushAllCaches flushes all in-memory caches to ensure pending writes complete.664 // FlushAllCaches flushes all in-memory caches to ensure pending writes complete.
580 // This is typically called during shutdown to prevent data loss.665 // This is typically called during shutdown to prevent data loss.
581 func (s *BrowserService) FlushAllCaches(ctx context.Context) error {666 func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
@@ -589,6 +674,11 @@ func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
589 return fmt.Errorf("failed to flush cert cache: %w", err)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 // Save fuzzy cache to binary file for instant dmenu access682 // Save fuzzy cache to binary file for instant dmenu access
593 s.fuzzyCache.OnApplicationExit(ctx)683 s.fuzzyCache.OnApplicationExit(ctx)
594 684
M internal/services/favicon_service.go +59 -10
@@ -9,6 +9,7 @@ import (
9 "log"9 "log"
10 "os"10 "os"
11 "path/filepath"11 "path/filepath"
12+ "sync"
12 "time"13 "time"
13 14
14 "github.com/bnema/dumber/internal/db"15 "github.com/bnema/dumber/internal/db"
@@ -28,6 +29,14 @@ type FaviconService struct {
28 exportDir string // Directory for exporting favicons for CLI access29 exportDir string // Directory for exporting favicons for CLI access
29 dataDir string // WebKit data directory30 dataDir string // WebKit data directory
30 enableExport bool // Whether to export favicons for CLI tools31 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 const (42 const (
@@ -35,6 +44,8 @@ const (
35 DefaultFaviconSize = 3244 DefaultFaviconSize = 32
36 // FaviconTimeout is the maximum time to wait for a favicon to be available45 // FaviconTimeout is the maximum time to wait for a favicon to be available
37 FaviconTimeout = 5 * time.Second46 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 // ServiceName returns the service name for identification51 // ServiceName returns the service name for identification
@@ -83,28 +94,66 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue
83 // OnFaviconChanged handles favicon URI changes from WebKit's favicon database.94 // OnFaviconChanged handles favicon URI changes from WebKit's favicon database.
84 // We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture.95 // We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture.
85 func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error {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 if !fs.shouldProcessURL(pageURL) {97 if !fs.shouldProcessURL(pageURL) {
91 return nil98 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 nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""}135 nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""}
95 if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil {136 if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil {
96 log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err)137 log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err)
97 return fmt.Errorf("failed to update favicon in database: %w", err)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 favicon141+ // Store this change as recently processed
101- // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar)142+ fs.recentChanges.Store(pageURL, recentChange{faviconURI: faviconURI, timestamp: time.Now()})
102- fs.propagateFaviconToDomain(pageURL, faviconURI)
103 143
104- // Proactively render and export favicon for omnibox/CLI use144+ // Propagate favicon to domain asynchronously to avoid blocking WebKit
105- // This ensures favicons are available ASAP for suggestions145+ go fs.propagateFaviconToDomain(pageURL, faviconURI)
146+
147+ // Proactively render and export favicon for omnibox/CLI use (debounced)
106 if fs.enableExport {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 return nil159 return nil
M main.go +6 -2
@@ -33,8 +33,12 @@ func main() {
33 return33 return
34 }34 }
35 35
36- // Initialize logging early for GUI mode using default config36+ // Load config file first, then initialize logging with actual config
37- cfg := config.New()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 if err := logging.Init(42 if err := logging.Init(
39 cfg.Logging.LogDir,43 cfg.Logging.LogDir,
40 cfg.Logging.Level,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 return 0, 0, 0, 0171 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 // WidgetSetFocusChild sets the focus child of a widget191 // WidgetSetFocusChild sets the focus child of a widget
175 func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {192 func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {
176 if widget := getWidget(w); widget != nil {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 // WidgetAllocation returns the widget's allocation (alias for WidgetGetAllocation)271 // WidgetAllocation returns the widget's allocation (alias for WidgetGetAllocation)
237 func WidgetAllocation(w gtk.Widgetter) (x, y, width, height int) {272 func WidgetAllocation(w gtk.Widgetter) (x, y, width, height int) {
238 return WidgetGetAllocation(w)273 return WidgetGetAllocation(w)
M pkg/webkit/keyboard_bridge.go +15 -0
@@ -41,6 +41,9 @@ const (
41 keyW = "w"41 keyW = "w"
42 keyX = "x"42 keyX = "x"
43 keyS = "s"43 keyS = "s"
44+ keyH = "h"
45+ keyJ = "j"
46+ keyK = "k"
44 keyEscape = "escape"47 keyEscape = "escape"
45 keyArrowLeft = "arrowleft"48 keyArrowLeft = "arrowleft"
46 keyArrowRight = "arrowright"49 keyArrowRight = "arrowright"
@@ -92,6 +95,12 @@ var shortcutActions = map[string]string{
92 altmod + "+" + keyArrowRight: directionRight,95 altmod + "+" + keyArrowRight: directionRight,
93 altmod + "+" + keyArrowDown: directionDown,96 altmod + "+" + keyArrowDown: directionDown,
94 altmod + "+" + keyArrowUp: directionUp,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 // TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30)106 // TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30)
@@ -161,6 +170,12 @@ func (w *WebView) AttachKeyboardBridge() {
161 keyName = keyW170 keyName = keyW
162 case 'x', 'X':171 case 'x', 'X':
163 keyName = keyX172 keyName = keyX
173+ case 'h', 'H':
174+ keyName = keyH
175+ case 'j', 'J':
176+ keyName = keyJ
177+ case 'k', 'K':
178+ keyName = keyK
164 case 's', 'S':179 case 's', 'S':
165 keyName = keyS180 keyName = keyS
166 case gdkKeyLeft:181 case gdkKeyLeft:
M pkg/webkit/mouse_gestures.go +1 -6
@@ -42,7 +42,6 @@ func (w *WebView) AttachMouseGestures() {
42 42
43 switch button {43 switch button {
44 case mouseButtonBack:44 case mouseButtonBack:
45- // Mouse back button - navigate backward
46 if w.view.CanGoBack() {45 if w.view.CanGoBack() {
47 log.Printf("[mouse-gestures] Back button clicked - navigating backward")46 log.Printf("[mouse-gestures] Back button clicked - navigating backward")
48 w.view.GoBack()47 w.view.GoBack()
@@ -51,21 +50,17 @@ func (w *WebView) AttachMouseGestures() {
51 }50 }
52 51
53 case mouseButtonForward:52 case mouseButtonForward:
54- // Mouse forward button - navigate forward
55 if w.view.CanGoForward() {53 if w.view.CanGoForward() {
56 log.Printf("[mouse-gestures] Forward button clicked - navigating forward")54 log.Printf("[mouse-gestures] Forward button clicked - navigating forward")
57 w.view.GoForward()55 w.view.GoForward()
58 } else {56 } else {
59 log.Printf("[mouse-gestures] Forward button clicked but cannot go forward")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 // Attach controller to WebView62 // Attach controller to WebView
69 w.view.AddController(gestureClick)63 w.view.AddController(gestureClick)
64+
70 log.Printf("[mouse-gestures] Mouse gesture controller attached to WebView ID %d", w.id)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 parts = append(parts, "Up")138 parts = append(parts, "Up")
139 case "down", "arrowdown":139 case "down", "arrowdown":
140 parts = append(parts, "Down")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 default:147 default:
142 // Single letter keys like l, f, c, w, etc.148 // Single letter keys like l, f, c, w, etc.
143 parts = append(parts, part)149 parts = append(parts, part)
M pkg/webkit/user_content.go +105 -25
@@ -9,6 +9,13 @@ import (
9 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"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 // SetupUserContentManager configures UserContentManager for the WebView19 // SetupUserContentManager configures UserContentManager for the WebView
13 // This injects GUI scripts at document-start and registers message handlers20 // This injects GUI scripts at document-start and registers message handlers
14 func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error {21 func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error {
@@ -23,22 +30,35 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string,
23 return nil30 return nil
24 }31 }
25 32
26- // Inject webview ID FIRST, so GUI scripts can access it immediately33+ // 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 // Note: webviewID is uint64, formatted as number (not string) to avoid any injection concerns35 // Note: webviewID is uint64, formatted as number (not string) to avoid any injection concerns
28 webviewIDScript := fmt.Sprintf(`36 webviewIDScript := fmt.Sprintf(`
29 window.__dumber_webview_id = %d;37 window.__dumber_webview_id = %d;
30 console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id);38 console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id);
31 `, webviewID)39 `, webviewID)
40+
41+ // Main world (for page scripts)
32 ucm.AddScript(webkit.NewUserScript(42 ucm.AddScript(webkit.NewUserScript(
33 webviewIDScript,43 webviewIDScript,
34- webkit.UserContentInjectTopFrame, // Only top frame needs the webview ID44+ 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 webkit.UserScriptInjectAtDocumentStart,54 webkit.UserScriptInjectAtDocumentStart,
55+ DumberIsolatedWorld,
36 nil,56 nil,
37 nil,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 script61+ // Inject GTK theme detection in BOTH worlds
42 // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set62 // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
43 // Respect the ColorScheme config setting63 // Respect the ColorScheme config setting
44 cfg := config.Get()64 cfg := config.Get()
@@ -57,71 +77,131 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string,
57 }77 }
58 78
59 gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)79 gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
80+
81+ // Main world
60 ucm.AddScript(webkit.NewUserScript(82 ucm.AddScript(webkit.NewUserScript(
61 gtkThemeScript,83 gtkThemeScript,
62- webkit.UserContentInjectTopFrame, // Only top frame needs theme preference84+ webkit.UserContentInjectTopFrame,
63 webkit.UserScriptInjectAtDocumentStart,85 webkit.UserScriptInjectAtDocumentStart,
64 nil,86 nil,
65 nil,87 nil,
66 ))88 ))
67- log.Printf("[webkit] Injected theme preference: prefersDark=%t", prefersDark)
68 89
69- // Inject palette config SECOND, before GUI scripts90+ // 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 // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} }102 // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} }
71 if appearanceConfigJSON != "" {103 if appearanceConfigJSON != "" {
72 paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON)104 paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON)
105+
106+ // Main world
73 ucm.AddScript(webkit.NewUserScript(107 ucm.AddScript(webkit.NewUserScript(
74 paletteScript,108 paletteScript,
75- webkit.UserContentInjectTopFrame, // Only top frame needs palette config109+ webkit.UserContentInjectTopFrame,
76 webkit.UserScriptInjectAtDocumentStart,110 webkit.UserScriptInjectAtDocumentStart,
77 nil,111 nil,
78 nil,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-start127+ // 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 if assets.ColorSchemeScript != "" {130 if assets.ColorSchemeScript != "" {
131+ // Main world
85 ucm.AddScript(webkit.NewUserScript(132 ucm.AddScript(webkit.NewUserScript(
86 assets.ColorSchemeScript,133 assets.ColorSchemeScript,
87- webkit.UserContentInjectTopFrame, // Only top frame needs color scheme detection134+ webkit.UserContentInjectTopFrame,
88 webkit.UserScriptInjectAtDocumentStart,135 webkit.UserScriptInjectAtDocumentStart,
89- nil, // whitelist (nil = all)136+ nil,
90- nil, // blacklist (nil = none)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-start152+ // 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 if assets.MainWorldScript != "" {155 if assets.MainWorldScript != "" {
97 ucm.AddScript(webkit.NewUserScript(156 ucm.AddScript(webkit.NewUserScript(
98 assets.MainWorldScript,157 assets.MainWorldScript,
99- webkit.UserContentInjectTopFrame, // Only top frame needs bridge/UI functionality158+ webkit.UserContentInjectTopFrame,
100 webkit.UserScriptInjectAtDocumentStart,159 webkit.UserScriptInjectAtDocumentStart,
101 nil,160 nil,
102 nil,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-start166+ // Inject GUI controls script in ISOLATED world only
167+ // This contains Svelte components and must be protected from page interference
108 if assets.GUIScript != "" {168 if assets.GUIScript != "" {
109- ucm.AddScript(webkit.NewUserScript(169+ ucm.AddScript(webkit.NewUserScriptForWorld(
110 assets.GUIScript,170 assets.GUIScript,
111- webkit.UserContentInjectTopFrame, // Only top frame needs GUI controls171+ 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 webkit.UserScriptInjectAtDocumentStart,190 webkit.UserScriptInjectAtDocumentStart,
191+ DumberIsolatedWorld,
113 nil,192 nil,
114 nil,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 JS198+ // Register script message handler "dumber" in the MAIN world
120- // Pass empty string for worldName to use the default world199+ // 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 if !ucm.RegisterScriptMessageHandler("dumber", "") {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 } else {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 return nil207 return nil
M pkg/webkit/webview.go +108 -0
@@ -38,6 +38,7 @@ type WebView struct {
38 onFaviconChanged func([]byte)38 onFaviconChanged func([]byte)
39 onFaviconURIChanged func(pageURI, faviconURI string)39 onFaviconURIChanged func(pageURI, faviconURI string)
40 onZoomChanged func(float64)40 onZoomChanged func(float64)
41+ onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
41 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler42 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
42 onReadyToShow func() // WebKit ready-to-show signal handler43 onReadyToShow func() // WebKit ready-to-show signal handler
43 onClose func()44 onClose func()
@@ -46,6 +47,7 @@ type WebView struct {
46 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation47 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
47 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)48 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
48 isPaneModeActive func() bool // Check if pane mode is active49 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 // NewWebView creates a new WebView with the given configuration53 // 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 // Favicon changed - connect to FaviconDatabase246 // Favicon changed - connect to FaviconDatabase
237 w.setupFaviconHandlers()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 // LoadURL loads the given URL in the WebView384 // LoadURL loads the given URL in the WebView
@@ -525,6 +614,14 @@ func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) {
525 w.onZoomChanged = handler614 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 // RegisterCloseHandler registers a handler for close requests625 // RegisterCloseHandler registers a handler for close requests
529 func (w *WebView) RegisterCloseHandler(handler func()) {626 func (w *WebView) RegisterCloseHandler(handler func()) {
530 w.mu.Lock()627 w.mu.Lock()
@@ -565,6 +662,14 @@ func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)
565 w.onWindowTypeDetected = handler662 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 // RunOnMainThread executes a function on the GTK main thread673 // RunOnMainThread executes a function on the GTK main thread
569 func (w *WebView) RunOnMainThread(fn func()) {674 func (w *WebView) RunOnMainThread(fn func()) {
570 RunOnMainThread(fn)675 RunOnMainThread(fn)
@@ -643,6 +748,9 @@ func (w *WebView) InjectScript(script string) error {
643 }748 }
644 749
645 // DispatchCustomEvent dispatches a custom event via JavaScript750 // 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 func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {754 func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
647 // Serialize data to JSON755 // Serialize data to JSON
648 jsonData, err := json.Marshal(data)756 jsonData, err := json.Marshal(data)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close