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.
44
55 ## [Unreleased]
66
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+
740 ## [0.13.0] - 2025-10-24
841
942 ### Added
M README.md +60 -102
@@ -4,34 +4,23 @@
44 <img src="assets/logo.svg" alt="Dumber Logo" width="256" height="256" />
55 </p>
66
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.
88
9-- Native GUI via WebKitGTK 6 (GTK4, CGO) with an embedded homepage UI.
10-- CLI that parses URLs, supports search shortcuts (e.g., `g:query`), and integrates with rofi/dmenu.
11-- Persistent history and per‑domain zoom levels (SQLite, XDG locations).
12-- Configurable via `config.json` and environment variables, with live reload.
13-
14-## Wayland‑First
15-- Dumb on purpose, designed for Wayland window managers (Sway, Hyprland, River, Niri, etc..) it's a tiny companion that interact nicely with your launcher.
16-- Uses a native Wayland WebKitGTK-6.0 window; no external browser required and no X11/XWayland dependency.
17-- Optimized for dmenu‑style launchers (rofi, fuzzel, wofi) and keyboard‑driven workflows.
9+## Features
10+- **Wayland native**: No X11/XWayland dependency, works with Sway, Hyprland, River, Niri, etc.
11+- **Tabs and workspaces**: Each tab holds a workspace with split or stacked panes.
12+- **Keyboard-driven**: Complete keyboard workflow inspired by Zellij.
13+- **Smart popup handling**: OAuth flows, window.open(), and popup deduplication for tiling WMs.
14+- **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.
15+- **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).
16+- **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.
17+- **Search shortcuts**: Quick search via aliases (e.g., `g:golang` for Google).
18+- **Customizable themes**: Light and dark palettes with semantic color tokens.
19+- **Persistent storage**: SQLite for history, zoom levels, and settings.
20+- **Live configuration**: Single config file with hot reload when possible.
1821
1922 ## Status
20-- Early development stage with regular releases. The workspace management system is fully functional and the browser works well for daily use. Expect some rough edges but core features are stable.
21-
22-## Features
23-- **Complete workspace management**: Zellij-inspired pane splitting with binary tree layout, stacked panes with title bars, focus tracking, and modal keyboard controls
24-- **Multi-pane WebView architecture**: Independent browsing sessions per pane with proper lifecycle management
25-- **Advanced popup handling**: Intelligent popup management for tiling WMs - OAuth flows, window.open(), and popup deduplication
26-- **Window-level global shortcuts**: Centralized shortcut handling to prevent conflicts between multiple WebView instances
27-- **GPU rendering and hardware video acceleration**: Automatic GPU detection with VA-API/VDPAU support
28-- **Built-in ad blocker**: UBlock-based content filtering (network blocking functional, cosmetic filtering in progress)
29-- **Configurable color palettes**: Customizable light and dark themes with semantic color tokens injected as CSS custom properties
30-- **Terminal-style UI design**: Monospace fonts, sharp corners, dashed borders with configurable appearance settings
31-- **dmenu‑style launcher integration**: rofi, fuzzel integration with favicon display, history and search shortcut suggestions
32-- **Comprehensive keyboard controls**: Complete keyboard-driven workflow with shortcuts and gestures
33-- **Persistent history and zoom**: SQLite-based history with configurable default zoom and per‑domain zoom persistence
34-- **Fully configurable**: Single config file with live reload and environment variable overrides
23+Early development with regular releases. Core features work well for daily use but expect some rough edges.
3524
3625 ## Controls & Shortcuts
3726
@@ -53,7 +42,21 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
5342 | **Ctrl+Shift+R** | Hard Reload | Refresh ignoring cache |
5443 | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation |
5544
56-#### Zellij-Inspired Pane Management
45+#### Tab Management
46+| Shortcut | Action | Notes |
47+|----------|--------|-------|
48+| **Ctrl+T** | Enter Tab Mode | Modal mode for tab operations |
49+| **Ctrl+Tab** | Next Tab | Quick tab switching |
50+| **Ctrl+Shift+Tab** | Previous Tab | Quick tab switching |
51+| **Ctrl+W** | Close Tab | Close current tab |
52+| **N** / **C** (in tab mode) | New Tab | Create new tab |
53+| **X** (in tab mode) | Close Tab | Close current tab |
54+| **L** / **Tab** (in tab mode) | Next Tab | Navigate to next tab |
55+| **H** / **Shift+Tab** (in tab mode) | Previous Tab | Navigate to previous tab |
56+| **R** (in tab mode) | Rename Tab | Rename current tab |
57+| **Escape** (in tab mode) | Exit Tab Mode | Return to normal navigation |
58+
59+#### Pane Management
5760 | Shortcut | Action | Notes |
5861 |----------|--------|-------|
5962 | **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
8083 ### Omnibox/Find Mode (when active)
8184 | Shortcut | Action | Notes |
8285 |----------|--------|-------|
83-| **Escape** | Close overlay | Exit omnibox or find mode |
86+| **Escape** | Close/Clear | Clear input if text present, close if empty |
8487 | **Enter** | Execute/Navigate | Navigate to URL or jump to match |
8588 | **Shift+Enter** | Previous Match | Find mode: go to previous match |
8689 | **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) |
8793 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |
8894
8995 All zoom changes are automatically persisted per-domain and restored on next visit.
@@ -99,7 +105,7 @@ Prerequisites:
99105 Build options:
100106 - GUI (default, recommended):
101107 - `make build` # builds frontend + Go with `-tags=webkit_cgo`
102- - Run: `./dist/dumber` # opens the embedded homepage
108+ - Run: `./dist/dumber` # to see all available commands.
103109 - CLI‑only (no GUI, CGO disabled):
104110 - `make build-no-gui`
105111 - Run: `./dist/dumber-no-gui version`
@@ -112,7 +118,6 @@ Development:
112118 **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:
113119
114120 - GUI build (default, recommended):
115- - Ensure WebKit2GTK/GTK dev packages are installed (see prerequisites)
116121 - Clone the repo and run `make build` (builds frontend first, then Go with GUI support)
117122 - The resulting `./dist/dumber` binary includes the native GUI window
118123 - CLI‑only (no native window):
@@ -123,16 +128,15 @@ Development:
123128 - Open a URL or search:
124129 - `dumber browse https://example.com`
125130 - `dumber browse example.com` # scheme auto‑added
131+ - `dumber browse dumb://home` # built-in homepage with stats
126132 - `dumber browse g:golang` # Google search via shortcut
127-- Launch the GUI directly:
128- - `dumber` # no args → opens GUI homepage
129133 - Show version information:
130134 - `dumber version` # display version, commit, and build date
131135 - Launcher integration (dmenu‑style examples):
132- - rofi: `dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select`
133- - fuzzel: `dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`
136+ - rofi: `dumber dmenu | rofi -dmenu -p "🔍 " | dumber dmenu --select`
137+ - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
134138 - Manage browsing history:
135- - `dumber history` # list recent history (default: 20 entries)
139+ - `dumber history` # list recent history (default: 20 entries)
136140 - `dumber history list -n 50` # list 50 recent entries
137141 - `dumber history search golang` # search history for "golang"
138142 - `dumber history stats` # show history statistics
@@ -157,9 +161,24 @@ Note: The root flag path only generates options; for processing a selection (`--
157161
158162 In GUI mode the app serves an embedded homepage via `dumb://homepage`, and frontend assets under `dumb://app/...`.
159163
160-## Pane Management (Zellij-Inspired)
164+## Tab Management
161165
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.
163182
164183 ### How it Works
165184 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
172191 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
173192 4. **Close**: Press `X` in pane mode to close the current pane
174193
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-
202194 ## Configuration
203195
204-Dumber follows the XDG Base Directory spec:
205-- Config: `~/.config/dumber/config.json`
206-- Data: `~/.local/share/dumber`
207-- State: `~/.local/state/dumber` (default DB lives here)
208-
209-A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible.
210-
211-### How Configuration Works
212-
213-- **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too)
214-- **Format**: JSON/YAML/TOML - auto-detected
215-- **JSON Schema**: Auto-generated for IDE autocompletion and validation
216-- **Live reload**: Changes are automatically applied without restarting
217-- **Environment variables**: Override any setting with `DUMB_BROWSER_*` or `DUMBER_*` prefix
218-- **Default values**: Sensible defaults for all settings
219-
220-### Quick Examples
221-
222-```json
223-{
224- "default_zoom": 1.2,
225- "rendering_mode": "gpu",
226- "search_shortcuts": {
227- "g": { "url": "https://google.com/search?q=%s", "description": "Google" }
228- },
229- "workspace": {
230- "enable_zellij_controls": true,
231- "popups": { "behavior": "split" }
232- }
233-}
234-```
196+Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML)
235197
236-Environment variable override:
237-```bash
238-DUMBER_RENDERING_MODE=cpu dumber
239-DUMB_BROWSER_LOGGING_LEVEL=debug dumber
240-```
198+A default config is created on first run. Changes are applied automatically (hot reload). Override any setting with `DUMB_BROWSER_*` or `DUMBER_*` environment variables.
241199
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.
243201
244202
245203 ## Building With WebKitGTK 6 (GTK4)
@@ -279,10 +237,10 @@ I will gladly accept contributions, especially related to UI/UX enhancements. Fe
279237 ## Roadmap
280238 - ✅ WebKitGTK 6 (GTK4) migration (GPU/Vulkan path complete)
281239 - ✅ GPU rendering (Vulkan via GTK4 renderer) with graceful CPU fallback
282-- ✅ Zellij-inspired pane management with binary tree layout and keyboard-driven workflow
240+- ✅ Modal pane management with binary tree layout and keyboard-driven workflow
241+- ✅ Tab management system with modal controls
283242 - 🚧 UBlock-based content filtering (early stage - network blocking works, cosmetic filtering needs work)
284243 - Full Vim-style motion and keyboard navigation across pages and UI
285-- Tab management system (browser tabs in addition to panes)
286244 - Performance work: faster startup, lower memory, snappier UI
287245 - Other shiny things I'm not yet aware of
288246
M assets/gui_embed.go +6 -0
@@ -12,3 +12,9 @@ var GUIScript string
1212
1313 //go:embed gui/color-scheme.js
1414 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 @@
11 # Configuration Reference
22
3-**Primary format**: JSON
4-**Location**: `~/.config/dumber/config.json`
5-**Also supports**: YAML (`.yaml`) and TOML (`.toml`)
6-**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated)
3+**Primary format**: TOML (recommended)
4+**Location**: `~/.config/dumber/config.toml`
5+**Also supports**: JSON (`.json`) and YAML (`.yaml`)
6+**JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated for JSON format)
77
8-## IDE Integration
8+## Why TOML?
99
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
1115
12-```json
13-{
14- "$schema": "./config.schema.json",
15- "default_zoom": 1.2,
16- ...
17-}
18-```
19-
20-The schema is automatically generated when you first create a config file.
16+You can still use JSON or YAML if you prefer - dumber automatically detects the format.
2117
2218 ## Database
2319
@@ -33,30 +29,41 @@ The schema is automatically generated when you first create a config file.
3329 | `history.retention_period_days` | int | `365` | > 0 | Days to keep history (1 year) |
3430 | `history.cleanup_interval_days` | int | `1` | > 0 | How often to run cleanup |
3531
36-## Search Shortcuts
32+## Search Configuration
3733
3834 | Key | Type | Default | Description |
3935 |-----|------|---------|-------------|
36+| `default_search_engine` | string | `"https://duckduckgo.com/?q=%s"` | Default search engine URL template (must contain `%s` placeholder) |
4037 | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs |
4138
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+
4250 **Default shortcuts:**
43-```json
44-{
45- "search_shortcuts": {
46- "g": {
47- "url": "https://google.com/search?q=%s",
48- "description": "Google Search"
49- },
50- "gh": {
51- "url": "https://github.com/search?q=%s",
52- "description": "GitHub"
53- },
54- "yt": {
55- "url": "https://youtube.com/results?search_query=%s",
56- "description": "YouTube"
57- }
58- }
59-}
51+```toml
52+[search_shortcuts.ddg]
53+url = "https://duckduckgo.com/?q=%s"
54+description = "DuckDuckGo search"
55+
56+[search_shortcuts.g]
57+url = "https://google.com/search?q=%s"
58+description = "Google Search"
59+
60+[search_shortcuts.gh]
61+url = "https://github.com/search?q=%s"
62+description = "GitHub"
63+
64+[search_shortcuts.yt]
65+url = "https://youtube.com/results?search_query=%s"
66+description = "YouTube"
6067 ```
6168
6269 ## Dmenu / Launcher
@@ -105,37 +112,27 @@ The schema is automatically generated when you first create a config file.
105112 ### Color Palettes
106113
107114 **Light palette:**
108-```json
109-{
110- "appearance": {
111- "light_palette": {
112- "background": "#f8f8f8",
113- "surface": "#f2f2f2",
114- "surface_variant": "#ececec",
115- "text": "#1a1a1a",
116- "muted": "#6e6e6e",
117- "accent": "#404040",
118- "border": "#d2d2d2"
119- }
120- }
121-}
115+```toml
116+[appearance.light_palette]
117+background = "#f8f8f8"
118+surface = "#f2f2f2"
119+surface_variant = "#ececec"
120+text = "#1a1a1a"
121+muted = "#6e6e6e"
122+accent = "#404040"
123+border = "#d2d2d2"
122124 ```
123125
124126 **Dark palette:**
125-```json
126-{
127- "appearance": {
128- "dark_palette": {
129- "background": "#0e0e0e",
130- "surface": "#1a1a1a",
131- "surface_variant": "#141414",
132- "text": "#e4e4e4",
133- "muted": "#848484",
134- "accent": "#a8a8a8",
135- "border": "#363636"
136- }
137- }
138-}
127+```toml
128+[appearance.dark_palette]
129+background = "#0e0e0e"
130+surface = "#1a1a1a"
131+surface_variant = "#141414"
132+text = "#e4e4e4"
133+muted = "#848484"
134+accent = "#a8a8a8"
135+border = "#363636"
139136 ```
140137
141138 ## Video Acceleration
@@ -191,41 +188,50 @@ The schema is automatically generated when you first create a config file.
191188
192189 ## Workspace Configuration
193190
194-| Key | Type | Default | Description |
195-|-----|------|---------|-------------|
196-| `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings |
197-
198191 ### Pane Mode
199192
200193 | Key | Type | Default | Description |
201194 |-----|------|---------|-------------|
202195 | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key |
203196 | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) |
204-| `workspace.pane_mode.action_bindings` | map | See below | Pane mode key bindings |
205-
206-**Default pane mode bindings:**
207-```json
208-{
209- "workspace": {
210- "pane_mode": {
211- "action_bindings": {
212- "arrowright": "split-right",
213- "arrowleft": "split-left",
214- "arrowup": "split-up",
215- "arrowdown": "split-down",
216- "r": "split-right",
217- "l": "split-left",
218- "u": "split-up",
219- "d": "split-down",
220- "x": "close-pane",
221- "enter": "confirm",
222- "escape": "cancel"
223- }
224- }
225- }
226-}
197+| `workspace.pane_mode.actions` | map | See below | Action→keys mappings |
198+
199+**Default pane mode actions:**
200+```toml
201+[workspace.pane_mode.actions]
202+split-right = ["arrowright", "r"]
203+split-left = ["arrowleft", "l"]
204+split-up = ["arrowup", "u"]
205+split-down = ["arrowdown", "d"]
206+close-pane = ["x"]
207+confirm = ["enter"]
208+cancel = ["escape"]
227209 ```
228210
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+
229235 ### Tab Shortcuts
230236
231237 | Key | Type | Default | Description |
@@ -251,11 +257,15 @@ The schema is automatically generated when you first create a config file.
251257
252258 | Key | Type | Default | Description |
253259 |-----|------|---------|-------------|
254-| `workspace.styling.border_width` | int | `2` | Border width in pixels |
255-| `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Border color (CSS or theme var) |
256-| `workspace.styling.pane_mode_border_color` | string | `"#FFA500"` | Pane mode border color (orange) |
257-| `workspace.styling.transition_duration` | int | `120` | Transition duration (ms) |
258-| `workspace.styling.border_radius` | int | `0` | Border radius in pixels |
260+| `workspace.styling.border_width` | int | `1` | Active pane border width (px) |
261+| `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
262+| `workspace.styling.inactive_border_width` | int | `1` | Inactive pane border width (px) |
263+| `workspace.styling.inactive_border_color` | string | `"@theme_unfocused_bg_color"` | Inactive pane border color |
264+| `workspace.styling.show_stacked_title_border` | bool | `false` | Show separator on stacked pane titles |
265+| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |
266+| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
267+| `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
268+| `workspace.styling.border_radius` | int | `0` | Border radius (px) |
259269
260270 ## Content Filtering
261271
@@ -264,14 +274,12 @@ The schema is automatically generated when you first create a config file.
264274 | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking |
265275
266276 **Default whitelist:**
267-```json
268-{
269- "content_filtering_whitelist": [
270- "twitch.tv",
271- "passport.twitch.tv",
272- "gql.twitch.tv"
273- ]
274-}
277+```toml
278+content_filtering_whitelist = [
279+ "twitch.tv",
280+ "passport.twitch.tv",
281+ "gql.twitch.tv"
282+]
275283 ```
276284
277285 ## 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 @@
178178 }
179179
180180 const surfaceMix = isDarkMode ? '55%' : '88%';
181- const inputMix = isDarkMode ? '52%' : '90%';
182- const borderMix = isDarkMode ? '62%' : '85%';
183181 const blurMix = isDarkMode ? '22%' : '12%';
184182
185183 const applyFade = faded && mode === 'find';
186184
185+ // GTK4-style: Container is slightly raised (use surface)
187186 const containerBackground = applyFade
188187 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
189188 : 'var(--dynamic-surface)';
189+
190+ // GTK4-style: Input is recessed (darker background)
190191 const inputBackground = applyFade
191- ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`
192- : 'var(--dynamic-bg)';
193- const borderColor = applyFade
194- ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`
192+ ? isDarkMode
193+ ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)`
194+ : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)`
195+ : isDarkMode
196+ ? 'color-mix(in srgb, var(--dynamic-bg) 85%, black 15%)'
197+ : 'color-mix(in srgb, var(--dynamic-bg) 95%, black 5%)';
198+
199+ // GTK4-style: Use palette border directly, slightly darker for input
200+ const containerBorderColor = applyFade
201+ ? `color-mix(in srgb, var(--dynamic-border) ${surfaceMix}, transparent)`
195202 : '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+
196212 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
197213
198214 boxElement.style.background = containerBackground;
199215 boxElement.style.color = 'var(--dynamic-text)';
200- boxElement.style.setProperty('--dumber-border-color', borderColor);
216+ boxElement.style.setProperty('--dumber-border-color', containerBorderColor);
201217 // 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);
203219
204220 inputElement.style.background = inputBackground;
205221 inputElement.style.color = 'var(--dynamic-text)';
206222 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);
208224 inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
209225
210226 blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
@@ -250,7 +266,7 @@
250266
251267
252268
253- onMount(() => {
269+ onMount(async () => {
254270 syncTheme();
255271 observeThemeChanges();
256272
@@ -265,6 +281,17 @@
265281 // Add global click listener
266282 document.addEventListener('click', handleGlobalClick, true);
267283
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+
268295 try {
269296 // Set up global API for Go bridge
270297 window.__dumber_omnibox = {
@@ -399,17 +426,18 @@
399426 margin-right: 0 !important;
400427 width: {responsiveStyles.width};
401428 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;
403430 pointer-events: auto !important;
404431 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));
406433 color: var(--dynamic-text);
407434 border: 1px solid var(--dumber-border-color, var(--dynamic-border));
435+ border-radius: 3px;
408436 box-shadow:
409- 0 20px 60px rgba(0, 0, 0, 0.45),
410- 0 10px 30px rgba(0, 0, 0, 0.35),
411- 0 5px 15px rgba(0, 0, 0, 0.25),
412- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent);
437+ 0 4px 12px rgba(0, 0, 0, 0.2),
438+ 0 2px 6px rgba(0, 0, 0, 0.15),
439+ 0 1px 3px rgba(0, 0, 0, 0.1),
440+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
413441 --dumber-border-color: var(--dynamic-border);"
414442 onmousedown={handleBoxClick}
415443 onmouseenter={handleMouseEnter}
@@ -443,54 +471,62 @@
443471
444472 <style>
445473 .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));
447475 color: var(--dynamic-text);
448476 border: 1px solid var(--dumber-border-color, var(--dynamic-border));
449477 transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
450- border-radius: 0;
478+ border-radius: 3px;
451479 box-shadow:
452- 0 20px 60px rgba(0, 0, 0, 0.45),
453- 0 10px 30px rgba(0, 0, 0, 0.35),
454- 0 5px 15px rgba(0, 0, 0, 0.25),
455- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent);
480+ 0 4px 12px rgba(0, 0, 0, 0.2),
481+ 0 2px 6px rgba(0, 0, 0, 0.15),
482+ 0 1px 3px rgba(0, 0, 0, 0.1),
483+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
456484 }
457485
458486 .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);
460488 box-shadow:
461- 0 25px 75px rgba(0, 0, 0, 0.5),
462- 0 15px 40px rgba(0, 0, 0, 0.4),
463- 0 8px 20px rgba(0, 0, 0, 0.3),
464- inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 28%, transparent);
489+ 0 6px 16px rgba(0, 0, 0, 0.24),
490+ 0 3px 8px rgba(0, 0, 0, 0.18),
491+ 0 2px 4px rgba(0, 0, 0, 0.12),
492+ inset 0 1px 0 rgba(255, 255, 255, 0.06),
493+ 0 0 0 1px var(--dynamic-accent);
465494 }
466495
467496 :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));
469498 color: var(--dynamic-text);
470499 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;
472505 font-family:
473- "JetBrains Mono",
474- "Fira Code",
475- "SFMono-Regular",
476- Menlo,
477- monospace;
506+ "Fira Sans",
507+ system-ui,
508+ -apple-system,
509+ "Segoe UI",
510+ "Ubuntu",
511+ "Cantarell",
512+ sans-serif;
478513 font-size: inherit;
479- letter-spacing: 0.05em;
514+ letter-spacing: normal;
480515 text-transform: none;
481516 }
482517
483518 :global(.dumber-omnibox-container input::placeholder) {
484519 color: var(--dynamic-muted);
485- letter-spacing: 0.08em;
486- text-transform: uppercase;
520+ letter-spacing: normal;
521+ text-transform: none;
487522 }
488523
489524 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
525+ /* GTK4-style: Minimal blur for faded state */
490526 .dumber-omnibox-blur-layer {
491- backdrop-filter: blur(1.5px) saturate(105%);
492- -webkit-backdrop-filter: blur(1.5px) saturate(105%);
493- background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 12%, transparent));
527+ backdrop-filter: blur(0.5px) saturate(102%);
528+ -webkit-backdrop-filter: blur(0.5px) saturate(102%);
529+ background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent));
494530 transition: opacity 140ms ease;
495531 will-change: opacity;
496532 z-index: 0;
M gui/src/components/omnibox/OmniboxInput.svelte +126 -8
@@ -10,9 +10,11 @@
1010
1111 // Reactive state
1212 let mode = $derived(omniboxStore.mode);
13+ let viewMode = $derived(omniboxStore.viewMode);
1314 let inputValue = $derived(omniboxStore.inputValue);
1415 let selectedIndex = $derived(omniboxStore.selectedIndex);
1516 let suggestions = $derived(omniboxStore.suggestions);
17+ let favorites = $derived(omniboxStore.favorites);
1618 let matches = $derived(omniboxStore.matches);
1719 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
1820
@@ -117,8 +119,11 @@
117119 omniboxStore.setFaded(false);
118120
119121 if (mode === 'omnibox') {
120- // Debounced search for omnibox
121- debouncedQuery(value);
122+ // Only query backend if we're in history view
123+ // In favorites view, filtering happens locally via computed state
124+ if (viewMode === 'history') {
125+ debouncedQuery(value);
126+ }
122127 } else if (mode === 'find') {
123128 // Immediate find for search
124129 findInPage(value);
@@ -131,7 +136,19 @@
131136 case 'Escape':
132137 event.preventDefault();
133138 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+ }
135152 break;
136153
137154 case 'Enter':
@@ -140,6 +157,26 @@
140157 handleEnterKey(event);
141158 break;
142159
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+
143180 case 'ArrowDown':
144181 case 'ArrowUp':
145182 event.preventDefault();
@@ -148,6 +185,27 @@
148185 break;
149186
150187 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+
151209 // For normal typing keys, only stop propagation to prevent page handlers
152210 // but don't prevent default so typing still works in the input
153211 event.stopPropagation();
@@ -160,8 +218,10 @@
160218 // Handle Enter key based on mode
161219 function handleEnterKey(event: KeyboardEvent) {
162220 if (mode === 'omnibox') {
163- const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null;
164- const url = selectedSuggestion?.url || inputValue || '';
221+ // Get selected item from current view (history or favorites)
222+ const currentList = viewMode === 'history' ? suggestions : favorites;
223+ const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null;
224+ const url = selectedItem?.url || inputValue || '';
165225
166226 if (url) {
167227 omniboxBridge.navigate(url);
@@ -182,9 +242,57 @@
182242 }
183243 }
184244
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+
185287 // Handle arrow key navigation
186288 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+ }
188296
189297 if (totalItems > 0) {
190298 if (key === 'ArrowDown') {
@@ -325,16 +433,26 @@
325433 background: var(--dumber-input-bg, var(--dynamic-bg));
326434 color: var(--dynamic-text);
327435 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328- border-radius: 0;
329- transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
436+ border-radius: 2px;
437+ box-shadow:
438+ inset 0 1px 2px rgba(0, 0, 0, 0.15),
439+ inset 0 0 0 1px rgba(0, 0, 0, 0.03);
440+ transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
441+ font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
442+ letter-spacing: normal;
330443 }
331444
332445 .omnibox-input-field::placeholder {
333446 color: var(--dynamic-muted);
447+ letter-spacing: normal;
334448 }
335449
336450 .omnibox-input-field:focus {
337451 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);
338456 }
339457
340458 .omnibox-prefix-badge {
M gui/src/components/omnibox/OmniboxSuggestions.svelte +206 -33
@@ -9,14 +9,43 @@
99
1010 // Reactive state
1111 let suggestions = $derived(omniboxStore.suggestions);
12+ let favorites = $derived(omniboxStore.favorites);
13+ let viewMode = $derived(omniboxStore.viewMode);
1214 let selectedIndex = $derived(omniboxStore.selectedIndex);
15+ let inputValue = $derived(omniboxStore.inputValue);
1316 let hasContent = $state(false);
1417
15- // Update hasContent when suggestions change
18+ // Debug effect to log favorites changes
1619 $effect(() => {
17- hasContent = omniboxStore.mode === 'omnibox' && suggestions.length > 0;
20+ console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length);
1821 });
1922
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+
2049 // Handle suggestion item mouse enter
2150 function handleItemMouseEnter(index: number) {
2251 omniboxStore.setSelectedIndex(index);
@@ -90,30 +119,62 @@
90119 role="listbox"
91120 aria-label="Search suggestions"
92121 >
93- {#each suggestions as suggestion, index (suggestion.url)}
94- {@const { domain, path } = parseUrl(suggestion.url)}
95- {@const isSelected = index === selectedIndex}
96-
97- <div
98- id="omnibox-item-{index}"
99- class={isSelected ? 'suggestion-item selected' : 'suggestion-item'}
100- role="option"
101- tabindex={isSelected ? 0 : -1}
102- aria-selected={isSelected}
103- onmouseenter={() => handleItemMouseEnter(index)}
104- onclick={() => handleItemClick(suggestion)}
105- onkeydown={(e) => handleItemKeyDown(e, suggestion)}
106- >
122+ <!-- View mode indicator -->
123+ <div class="view-mode-header">
124+ <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}>
125+ History ({suggestions.length})
126+ </span>
127+ <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}>
128+ Favorites ({favorites.length})
129+ </span>
130+ <span class="view-hint">Tab to switch</span>
131+ </div>
132+
133+ {#if currentList.length === 0}
134+ <!-- Empty state -->
135+ {#if viewMode === 'favorites'}
136+ <!-- Always show empty state for favorites -->
137+ <div class="empty-state">
138+ <span class="empty-icon">⭐</span>
139+ <span class="empty-text">No favorites yet</span>
140+ <span class="empty-hint">Press Space on any item in History to add it here</span>
141+ </div>
142+ {:else if inputValue && inputValue.trim()}
143+ <!-- Only show "No results found" for history when user has typed something -->
144+ <div class="empty-state">
145+ <span class="empty-icon">🔍</span>
146+ <span class="empty-text">No results found</span>
147+ </div>
148+ {/if}
149+ {:else}
150+ {#each currentList as item, index (item.url)}
151+ {@const { domain, path } = parseUrl(item.url)}
152+ {@const isSelected = index === selectedIndex}
153+ {@const favicon = item.favicon || item.favicon_url || ''}
154+ {@const isFav = viewMode === 'history' && isFavorited(item.url)}
155+ {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null}
156+
157+ <div
158+ id="omnibox-item-{index}"
159+ class:suggestion-item={true}
160+ class:selected={isSelected}
161+ class:favorited={isFav}
162+ role="option"
163+ tabindex={isSelected ? 0 : -1}
164+ aria-selected={isSelected}
165+ onmouseenter={() => handleItemMouseEnter(index)}
166+ onclick={() => handleItemClick(item)}
167+ onkeydown={(e) => handleItemKeyDown(e, item)}
168+ >
107169 <!-- Favicon chip -->
108- {#if suggestion.favicon}
170+ {#if favicon}
109171 <div class="suggestion-favicon">
110172 <img
111- src={suggestion.favicon}
173+ src={favicon}
112174 alt=""
113175 class="suggestion-favicon-img"
114176 loading="lazy"
115177 onerror={handleFaviconError}
116- style="image-rendering: -webkit-optimize-contrast;"
117178 />
118179 </div>
119180 {/if}
@@ -135,8 +196,16 @@
135196 {path || '/'}
136197 </span>
137198 </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}
138206 </div>
139207 {/each}
208+ {/if}
140209 </div>
141210 {/if}
142211
@@ -144,36 +213,117 @@
144213
145214 <style>
146215 .suggestion-list {
147- margin-top: 0.75rem;
216+ margin-top: 0.5rem;
148217 max-height: 50vh;
149218 overflow-y: auto;
150219 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;
152282 }
153283
154284 .suggestion-item {
155285 display: flex;
156286 gap: 0.75rem;
157287 align-items: center;
158- padding: 0.65rem 0.85rem;
159- border-bottom: 1px dashed var(--dynamic-border);
288+ padding: 0.85rem 0.85rem;
289+ border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
160290 cursor: pointer;
161- transition: background-color 120ms ease, color 120ms ease;
162- letter-spacing: 0.04em;
291+ transition: background-color 100ms ease, border-left-color 100ms ease;
292+ letter-spacing: normal;
293+ border-left: 3px solid transparent;
294+ position: relative;
295+ font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
163296 }
164297
165298 .suggestion-item:last-child {
166299 border-bottom: none;
167300 }
168301
302+ /* Favorited items get a permanent yellow left border */
303+ .suggestion-item.favorited {
304+ border-left-color: #f59e0b;
305+ }
306+
169307 .suggestion-item.selected,
170308 .suggestion-item:hover,
171309 .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);
173312 color: var(--dynamic-text);
174313 outline: none;
175314 }
176315
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+
177327 .suggestion-item .suggestion-domain {
178328 color: var(--dynamic-text);
179329 opacity: 0.95;
@@ -188,24 +338,23 @@
188338 }
189339
190340 .suggestion-favicon {
191- width: 20px;
192- height: 20px;
341+ width: 24px;
342+ height: 24px;
193343 border: none;
194344 border-radius: 3px;
195345 display: flex;
196346 align-items: center;
197347 justify-content: center;
198- background: var(--dynamic-bg);
348+ background: transparent;
199349 flex-shrink: 0;
200350 overflow: hidden;
201351 }
202352
203353 .suggestion-favicon-img {
204- width: 16px;
205- height: 16px;
354+ width: 24px;
355+ height: 24px;
206356 object-fit: contain;
207- image-rendering: -webkit-optimize-contrast;
208- -ms-interpolation-mode: nearest-neighbor;
357+ image-rendering: auto;
209358 }
210359
211360 .suggestion-text {
@@ -220,4 +369,28 @@
220369 mask-image: linear-gradient(90deg, black 85%, transparent 100%);
221370 -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%);
222371 }
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+ }
223396 </style>
M gui/src/components/omnibox/messaging.ts +76 -26
@@ -4,36 +4,29 @@
44 * TypeScript interface for Go-JavaScript communication
55 */
66
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, SearchShortcut } from "./types";
7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, Favorite, SearchShortcut } from "./types";
88 import { omniboxStore } from "./stores.svelte.ts";
99
1010 export class OmniboxBridge implements OmniboxMessageBridge {
11- // Forward messages to the native WebKit message handler when available
12- // Fallback: for navigate, perform direct location change
11+ // Forward messages via CustomEvent to main world bridge
12+ // The main world will forward to webkit.messageHandlers.dumber
1313 postMessage(msg: OmniboxMessage): void {
14- console.log("📤 [DEBUG] Posting message to backend:", msg);
15- // NOTE: Do NOT detach postMessage from its receiver. In WKWebView,
16- // postMessage must be called on the UserMessageHandler instance.
17- const bridge = window.webkit?.messageHandlers?.dumber;
18- if (bridge && typeof bridge.postMessage === "function") {
19- try {
20- console.log("📱 [DEBUG] Using webkit message handler");
21- bridge.postMessage(JSON.stringify(msg));
22- return;
23- } catch (e) {
24- console.warn(
25- "postMessage to native handler failed, using fallback:",
26- e,
27- );
28- }
29- }
30- console.log("⚠️ [DEBUG] No webkit bridge available");
31- // Fallback navigation if no native bridge is available
32- if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) {
33- try {
34- window.location.href = msg.url;
35- } catch (e) {
36- console.error("Fallback navigation failed:", e);
14+ console.log("📤 [DEBUG] Posting message to backend via bridge:", msg);
15+ try {
16+ // Dispatch CustomEvent that main-world bridge will listen to
17+ document.dispatchEvent(new CustomEvent("dumber:isolated-message", {
18+ detail: { payload: msg }
19+ }));
20+ console.log("✅ [DEBUG] Dispatched isolated message event");
21+ } catch (e) {
22+ console.error("❌ [DEBUG] Failed to dispatch message:", e);
23+ // Fallback navigation if dispatch fails
24+ if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) {
25+ try {
26+ window.location.href = msg.url;
27+ } catch (navError) {
28+ console.error("Fallback navigation failed:", navError);
29+ }
3730 }
3831 }
3932 }
@@ -125,6 +118,63 @@ export class OmniboxBridge implements OmniboxMessageBridge {
125118 });
126119 }
127120
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+
128178 // Suggestions are returned via setSuggestions() when native handler responds
129179 }
130180
M gui/src/components/omnibox/stores.svelte.ts +61 -7
@@ -6,7 +6,9 @@
66
77 import type {
88 OmniboxMode,
9+ ViewMode,
910 Suggestion,
11+ Favorite,
1012 FindMatch,
1113 HighlightNode,
1214 OmniboxConfig,
@@ -20,10 +22,25 @@ const DEFAULT_CONFIG: Required<OmniboxConfig> = {
2022 defaultLimit: 10,
2123 };
2224
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+
2338 // Global state using Svelte 5 runes
2439 let visible = $state(false);
2540 let mode = $state<OmniboxMode>("omnibox");
41+let viewMode = $state<ViewMode>(loadViewMode());
2642 let suggestions = $state<Suggestion[]>([]);
43+let favorites = $state<Favorite[]>([]);
2744 let matches = $state<FindMatch[]>([]);
2845 let selectedIndex = $state(-1);
2946 let activeIndex = $state(-1);
@@ -45,9 +62,15 @@ export const omniboxStore = {
4562 get mode() {
4663 return mode;
4764 },
65+ get viewMode() {
66+ return viewMode;
67+ },
4868 get suggestions() {
4969 return suggestions;
5070 },
71+ get favorites() {
72+ return favorites;
73+ },
5174 get matches() {
5275 return matches;
5376 },
@@ -78,19 +101,28 @@ export const omniboxStore = {
78101
79102 // Computed getters
80103 get hasContent() {
81- return (
82- (mode === "omnibox" && suggestions.length > 0) ||
83- (mode === "find" && matches.length > 0)
84- );
104+ if (mode === "find") {
105+ return matches.length > 0;
106+ }
107+ // In omnibox mode, check the current view
108+ return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0;
85109 },
86110 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;
88116 },
89117 get selectedItem() {
90118 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"
92124 ? suggestions[selectedIndex]
93- : matches[selectedIndex];
125+ : favorites[selectedIndex];
94126 },
95127
96128 // Actions
@@ -242,10 +274,32 @@ export const omniboxStore = {
242274 searchShortcuts = shortcuts;
243275 },
244276
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+
245297 reset() {
246298 visible = false;
247299 mode = "omnibox";
300+ // Keep viewMode persistent - it's user preference
248301 suggestions = [];
302+ favorites = [];
249303 matches = [];
250304 selectedIndex = -1;
251305 activeIndex = -1;
M gui/src/components/omnibox/types.ts +14 -1
@@ -9,16 +9,26 @@ export interface Suggestion {
99 favicon?: string;
1010 }
1111
12+export interface Favorite {
13+ id: number;
14+ url: string;
15+ title: string;
16+ favicon_url: string;
17+ position: number;
18+}
19+
1220 export interface SearchShortcut {
1321 url: string;
1422 description: string;
1523 }
1624
1725 export interface OmniboxMessage {
18- type: "navigate" | "query" | "get_search_shortcuts";
26+ type: "navigate" | "query" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite";
1927 url?: string;
2028 q?: string;
2129 limit?: number;
30+ title?: string;
31+ faviconURL?: string;
2232 }
2333
2434 export interface FindMatch {
@@ -32,6 +42,7 @@ export interface HighlightNode {
3242 }
3343
3444 export type OmniboxMode = "omnibox" | "find";
45+export type ViewMode = "history" | "favorites";
3546
3647 export interface OmniboxConfig {
3748 maxMatches?: number;
@@ -42,7 +53,9 @@ export interface OmniboxConfig {
4253 export interface OmniboxState {
4354 visible: boolean;
4455 mode: OmniboxMode;
56+ viewMode: ViewMode;
4557 suggestions: Suggestion[];
58+ favorites: Favorite[];
4659 matches: FindMatch[];
4760 selectedIndex: number;
4861 activeIndex: number;
M gui/src/components/toast/ToastContainer.svelte +17 -13
@@ -84,19 +84,23 @@
8484 }, 1500);
8585 }
8686
87- // Expose API to global window for Go integration immediately
88- // This must happen outside onMount to ensure functions are available immediately
89- if (typeof window !== 'undefined') {
90- window.__dumber_showToast = addToast;
91- window.__dumber_dismissToast = dismissToast;
92- window.__dumber_clearToasts = clearAllToasts;
93- window.__dumber_showZoomToast = showZoomToast;
94- console.log('✅ Toast functions exposed to window:', {
95- showToast: typeof window.__dumber_showToast,
96- dismissToast: typeof window.__dumber_dismissToast,
97- clearToasts: typeof window.__dumber_clearToasts,
98- showZoomToast: typeof window.__dumber_showZoomToast
99- });
87+ // Listen for toast events from main world bridge
88+ if (typeof document !== 'undefined') {
89+ // Listen for general toast requests
90+ document.addEventListener('dumber:toast', ((event: CustomEvent) => {
91+ const { message, duration, type } = event.detail;
92+ console.log('[ToastContainer] Received toast event:', { message, duration, type });
93+ addToast(message, duration, type);
94+ }) as EventListener);
95+
96+ // Listen for zoom toast requests
97+ document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => {
98+ const { level } = event.detail;
99+ console.log('[ToastContainer] Received zoom toast event:', level);
100+ showZoomToast(level);
101+ }) as EventListener);
102+
103+ console.log('✅ Toast event listeners set up for CustomEvents');
100104 }
101105
102106 // Effect that runs when toasts array changes
M gui/src/injected/bootstrap.ts +7 -15
@@ -181,22 +181,14 @@ export function bootstrapGUI(): void {
181181 console.log("[GUI] DOM ready, starting initialization");
182182 let toastInitialized = false;
183183
184- if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") {
185- console.log("[GUI] Toast already initialized, skipping");
184+ try {
185+ console.log("[GUI] Attempting toast initialization");
186+ await initializeToast();
186187 toastInitialized = true;
187- } else {
188- try {
189- console.log("[GUI] Attempting toast initialization");
190- await initializeToast();
191- if (typeof window.__dumber_showToast !== "function") {
192- throw new Error("Toast functions not exposed after initialization");
193- }
194- toastInitialized = true;
195- console.log("[GUI] Toast initialized successfully");
196- } catch (e) {
197- console.error("❌ Failed to initialize Svelte toast system:", e);
198- throw e;
199- }
188+ console.log("[GUI] Toast initialized successfully");
189+ } catch (e) {
190+ console.error("❌ Failed to initialize Svelte toast system:", e);
191+ throw e;
200192 }
201193
202194 try {
M gui/src/injected/main-world.ts +104 -21
@@ -32,6 +32,7 @@ declare global {
3232 ) => number | void;
3333 __dumber_showZoomToast?: (level: number) => void;
3434 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35+ __dumber_favorites?: (favorites: unknown[]) => void;
3536 __dumber_webview_id?: string | number;
3637 __dumber_is_active?: boolean;
3738 __dumber_teardown?: () => void;
@@ -292,46 +293,104 @@ interface DumberAPI {
292293 );
293294 }
294295
295- // Omnibox suggestions bridge
296- const omniboxQueue: Suggestion[] = [];
297- let omniboxReady = false;
298-
299- const omniboxDispatch = (suggestions: Suggestion[]) => {
296+ /**
297+ * ============================================================================
298+ * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM
299+ * ============================================================================
300+ *
301+ * Architecture Overview:
302+ * ----------------------
303+ * WebKit runs scripts in different "worlds" for security:
304+ *
305+ * - MAIN WORLD: Where page scripts run
306+ * - Can access window.webkit.messageHandlers (backend communication)
307+ * - Can be accessed/modified by page JavaScript
308+ * - Security risk: page can interfere with our code
309+ *
310+ * - ISOLATED WORLD: Where our UI components run (Svelte)
311+ * - Cannot access window.webkit.messageHandlers directly
312+ * - Protected from page JavaScript interference
313+ * - Security benefit: page cannot read/modify our UI state
314+ *
315+ * Communication Patterns:
316+ * -----------------------
317+ *
318+ * 1. WINDOW FUNCTIONS (require bridge):
319+ * Backend → window.__dumber_xxx() in main world
320+ * Main world → CustomEvent to isolated world
321+ * Example: __dumber_omnibox_suggestions(), __dumber_favorites()
322+ *
323+ * 2. CUSTOMEVENT DISPATCH (no bridge needed):
324+ * Backend → document.dispatchEvent() directly
325+ * Both worlds can listen (shared DOM)
326+ * Example: dumber:ui:shortcut (keyboard shortcuts)
327+ *
328+ * 3. ISOLATED TO BACKEND (reverse bridge):
329+ * Isolated world → CustomEvent "dumber:isolated-message"
330+ * Main world listener → window.webkit.messageHandlers.dumber
331+ * Example: navigation, omnibox queries
332+ *
333+ * ============================================================================
334+ */
335+
336+ /**
337+ * Unified dispatcher for forwarding main-world window calls to isolated world
338+ * Only use this for window function bridges, NOT for events backend already dispatches
339+ */
340+ const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => {
300341 try {
301- document.dispatchEvent(
302- new CustomEvent("dumber:omnibox:suggestions", {
303- detail: { suggestions },
304- }),
305- );
306- // Legacy compatibility
307- document.dispatchEvent(
308- new CustomEvent("dumber:omnibox-suggestions", {
309- detail: { suggestions },
310- }),
311- );
342+ document.dispatchEvent(new CustomEvent(eventName, { detail }));
312343 } catch (err) {
313- console.error("[dumber] Omnibox dispatch error", err);
344+ console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err);
314345 }
315346 };
316347
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+
317357 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
318358 if (omniboxReady) {
319- omniboxDispatch(suggestions);
359+ bridgeDispatch("dumber:omnibox:suggestions", { suggestions });
360+ bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support
320361 } else {
321362 try {
322363 omniboxQueue.push(...suggestions);
323364 } catch (err) {
324- console.error("[dumber] Omnibox queue error", err);
365+ console.error("[dumber-bridge] Omnibox queue error", err);
325366 }
326367 }
327368 };
328369
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
329385 const handleOmniboxReady = () => {
330386 omniboxReady = true;
331387 if (omniboxQueue && omniboxQueue.length) {
332388 const items = omniboxQueue.slice();
333389 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+ });
335394 }
336395 };
337396
@@ -340,7 +399,7 @@ interface DumberAPI {
340399 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
341400 });
342401
343- // Unified omnibox API
402+ // Unified omnibox API for backward compatibility
344403 window.__dumber.omnibox = window.__dumber.omnibox || {
345404 suggestions: (suggestions: Suggestion[]) => {
346405 window.__dumber_omnibox_suggestions!(suggestions);
@@ -824,6 +883,7 @@ interface DumberAPI {
824883 window.__dumber_page_bridge_installed = false;
825884 window.__dumber_teardown = undefined;
826885 window.__dumber_omnibox_suggestions = undefined;
886+ window.__dumber_favorites = undefined;
827887 window.__dumber_showToast = undefined;
828888 window.__dumber_showZoomToast = undefined;
829889 window.__dumber_applyDomZoom = undefined;
@@ -909,6 +969,29 @@ interface DumberAPI {
909969 });
910970 };
911971
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+
912995 // Initialize console capture
913996 setupConsoleCapture();
914997 } 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 {
182182 `;
183183 shadowRoot.appendChild(tokensStyle);
184184
185- // Inject the global GUI stylesheet so components rendered inside the shadow
186- // root (e.g., toasts, omnibox) receive their styles
185+ // Inject the global GUI stylesheet (Tailwind) into shadow root
187186 try {
188- // Prefer constructable stylesheets when available
189- const hasConstructable = typeof CSSStyleSheet !== "undefined";
190- const supportsAdopted = "adoptedStyleSheets" in shadowRoot;
191- if (supportsAdopted && hasConstructable) {
192- const sheet = new CSSStyleSheet();
193- sheet.replaceSync(globalStyles);
194- const rootWithSheets = shadowRoot as ShadowRoot & {
195- adoptedStyleSheets: CSSStyleSheet[];
196- };
197- rootWithSheets.adoptedStyleSheets = [
198- ...(rootWithSheets.adoptedStyleSheets ?? []),
199- sheet,
200- ];
201- } else {
202- // Fallback: append a <style> element with the global CSS
203- const styleTag = document.createElement("style");
204- styleTag.textContent = globalStyles;
205- shadowRoot.appendChild(styleTag);
206- }
207- } catch {
208- // Final fallback if anything above fails
187+ // Use <style> tag injection for Tailwind global styles
209188 const styleTag = document.createElement("style");
189+ styleTag.setAttribute("data-source", "dumber-tailwind-styles");
210190 styleTag.textContent = globalStyles;
211191 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+ }
212228 }
213229 shadowResetApplied.add(shadowRoot);
214230 }
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 {
2626 "info",
2727 "frontend",
2828 "init",
29- "BrowserControlsService initialized ✓",
29+ "BrowserControlsService initialized",
3030 );
3131 }
3232
M gui/vite.config.gui.ts +2 -2
@@ -5,7 +5,7 @@ import { resolve } from "path";
55 export default defineConfig({
66 plugins: [
77 svelte({
8- emitCss: false, // Inline component CSS into JS bundle
8+ emitCss: true, // Extract component CSS to separate file for isolated world injection
99 }),
1010 ],
1111 build: {
@@ -18,7 +18,7 @@ export default defineConfig({
1818 name: "__dumberGUI",
1919 inlineDynamicImports: true,
2020 manualChunks: undefined, // Prevent chunk splitting
21- assetFileNames: "[name].[ext]", // Don't hash asset names since they're inlined
21+ assetFileNames: "gui.min.[ext]", // CSS will be named gui.min.css
2222 },
2323 },
2424 emptyOutDir: false,
M internal/app/browser/browser.go +32 -20
@@ -48,7 +48,11 @@ type BrowserApp struct {
4848 navigationController *control.NavigationController
4949 clipboardController *control.ClipboardController
5050
51- // Pane workspace management
51+ // Tab and workspace management
52+ tabManager *TabManager
53+
54+ // Convenience accessors (delegate to active tab's workspace)
55+ // These maintain compatibility with existing code
5256 panes []*BrowserPane
5357 activePane *BrowserPane
5458 workspace *WorkspaceManager
@@ -186,6 +190,15 @@ func (app *BrowserApp) loadCachesParallel(ctx context.Context) error {
186190 return nil
187191 })
188192
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+
189202 // Load fuzzy search cache in parallel for instant dmenu access
190203 g.Go(func() error {
191204 if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {
@@ -239,8 +252,19 @@ func (app *BrowserApp) Run() {
239252 // Continue without content blocking
240253 }
241254
242- // Handle browse command if present
243- app.navigationController.HandleBrowseCommand()
255+ // Handle browse command if present (must use active tab's navigation controller)
256+ if app.tabManager != nil {
257+ activeTab := app.tabManager.GetActiveTab()
258+ if activeTab != nil && activeTab.workspace != nil {
259+ activePane := activeTab.workspace.GetActivePane()
260+ if activePane != nil && activePane.navigationController != nil {
261+ activePane.navigationController.HandleBrowseCommand()
262+ }
263+ }
264+ } else {
265+ // Fallback to legacy behavior if no tab manager
266+ app.navigationController.HandleBrowseCommand()
267+ }
244268
245269 // Setup signal handling
246270 app.setupSignalHandling()
@@ -260,23 +284,11 @@ func (app *BrowserApp) cleanup() {
260284 app.windowShortcutHandler = nil
261285 }
262286
263- // Cleanup all panes
264- if app.panes != nil {
265- log.Printf("Cleaning up %d panes", len(app.panes))
266- for i, pane := range app.panes {
267- if pane != nil {
268- log.Printf("Cleaning up pane %d (%s)", i, pane.ID())
269- pane.Cleanup()
270- }
271- }
272- app.panes = nil
273- }
274-
275- // Cleanup workspace manager
276- if app.workspace != nil {
277- log.Printf("Cleaning up workspace manager")
278- // WorkspaceManager doesn't have explicit cleanup yet, but we clear the reference
279- app.workspace = nil
287+ // Cleanup tab manager (which cleans up all tabs and their workspaces)
288+ if app.tabManager != nil {
289+ log.Printf("Cleaning up tab manager")
290+ app.tabManager.Cleanup()
291+ app.tabManager = nil
280292 }
281293
282294 // 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 (
77 "sync/atomic"
88 "time"
99
10- "github.com/bnema/dumber/pkg/webkit"
1110 "github.com/diamondburned/gotk4/pkg/gdk/v4"
1211 "github.com/diamondburned/gotk4/pkg/gtk/v4"
12+
13+ "github.com/bnema/dumber/pkg/webkit"
1314 )
1415
1516 const (
@@ -306,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
306307 // detachFromParent removes a pane from its current parent
307308 func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
308309 if parent == nil {
309- // Target is the root - remove it from window
310- if spm.wm.window != nil {
311- spm.wm.window.SetChild(nil)
312- }
310+ // Target is the root - clear it (handles both tab and non-tab environments)
311+ spm.wm.clearRootContainer()
313312 // Unparent if it has a GTK parent
314313 if target.container != nil {
315314 if webkit.WidgetGetParent(target.container) != nil {
@@ -329,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
329328 // reattachToParent attaches a container to the parent
330329 func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
331330 if parent == nil {
332- if spm.wm.window != nil {
333- spm.wm.window.SetChild(container)
334- }
331+ // Attach as root (handles both tab and non-tab environments)
332+ spm.wm.setRootContainer(container)
335333 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
336334 if parent.left == target {
337335 paned.SetStartChild(container)
@@ -478,12 +476,14 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
478476 case DirectionUp:
479477 newIndex = currentIndex - 1
480478 if newIndex < 0 {
481- newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
479+ // At the top of the stack - allow navigation outside
480+ return false
482481 }
483482 case DirectionDown:
484483 newIndex = currentIndex + 1
485484 if newIndex >= len(stackNode.stackedPanes) {
486- newIndex = 0 // Wrap to first
485+ // At the bottom of the stack - allow navigation outside
486+ return false
487487 }
488488 default:
489489 return false
@@ -538,10 +538,9 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
538538 spm.titleBarToPane[titleBarID] = pane
539539
540540 // Attach click handler
541- // TODO: Implement click handler with gtk.GestureClick
542- // webkit.WidgetAttachClickHandler(titleBar, func() {
543- // spm.handleTitleBarClick(titleBarID)
544- // })
541+ webkit.WidgetAttachClickHandler(titleBar, func() {
542+ spm.handleTitleBarClick(titleBarID)
543+ })
545544 }
546545
547546 return titleBar
@@ -610,10 +609,9 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
610609 spm.titleBarToPane[titleBarID] = node
611610
612611 // Attach click handler
613- // TODO: Implement with gtk.GestureClick
614- // webkit.WidgetAttachClickHandler(newTitleBar, func() {
615- // spm.handleTitleBarClick(titleBarID)
616- // })
612+ webkit.WidgetAttachClickHandler(newTitleBar, func() {
613+ spm.handleTitleBarClick(titleBarID)
614+ })
617615
618616 // Replace the old title bar in the stack
619617 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
@@ -678,11 +676,18 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
678676 }
679677
680678 if texture != nil && titleBox != nil {
681- log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL)
682679
683- // Create favicon image
680+ // Create favicon image with UI scaling
684681 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)
686691 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
687692
688693 // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
@@ -776,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
776781 webkit.WidgetUnparent(stackNode.container)
777782 }
778783 }
779- if spm.wm.window != nil {
780- spm.wm.window.SetChild(nil)
781- }
784+ // Clear root container (handles both tab and non-tab environments)
785+ spm.wm.clearRootContainer()
782786 _, err := spm.wm.cleanupAndExit(node)
783787 return err
784788 }
@@ -802,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
802806 grandparent := parent.parent
803807
804808 spm.wm.promoteSibling(grandparent, parent, sibling)
805- spm.wm.swapContainers(grandparent, sibling)
809+ spm.wm.swapContainers(grandparent, parent, sibling)
806810
807811 generation := spm.wm.nextCleanupGeneration()
808812 spm.wm.cleanupPane(node, generation)
@@ -840,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
840844 }
841845
842846 if parent == nil {
847+ // Stack was root - make last pane the new root
843848 spm.wm.root = lastPane
844849 lastPane.parent = nil
845- if spm.wm.window != nil && lastPaneContainer != nil {
846- spm.wm.window.SetChild(lastPaneContainer)
850+ if lastPaneContainer != nil {
851+ spm.wm.setRootContainer(lastPaneContainer)
847852 webkit.WidgetQueueAllocate(lastPaneContainer)
848853 webkit.WidgetShow(lastPaneContainer)
849854 }
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 {
249249 app.clipboardController = pane.clipboardController
250250 app.messageHandler = pane.messageHandler
251251 app.shortcutHandler = pane.shortcutHandler
252- app.panes = []*BrowserPane{pane}
253- app.activePane = pane
254252
255- if app.workspace == nil {
256- app.workspace = NewWorkspaceManager(app, pane)
257- // CRITICAL: Register navigation handler for the root webview
258- // This is needed because buildPane() couldn't register it when workspace was nil
259- app.workspace.RegisterNavigationHandler(view)
253+ // Initialize tab manager (which manages workspaces)
254+ window := view.Window()
255+ if window != nil && app.tabManager == nil {
256+ app.tabManager = NewTabManager(app, window)
260257
261- // CRITICAL: Setup popup handling for the root webview
262- // This must be done after workspace is created so WebKit's create signal is connected
263- node := app.workspace.GetNodeForWebView(view)
264- if node != nil {
265- app.workspace.setupPopupHandling(view, node)
266- log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())
258+ // Determine initial URL
259+ initialURL := "about:blank"
260+
261+ // Initialize tab system (creates root container with tab bar and first tab)
262+ if err := app.tabManager.Initialize(initialURL); err != nil {
263+ log.Printf("ERROR: failed to initialize tab manager: %v", err)
264+ return err
265+ }
266+
267+ // Set tab manager's root container as window content
268+ rootContainer := app.tabManager.GetRootContainer()
269+ if rootContainer != nil {
270+ window.SetChild(rootContainer)
271+ log.Printf("[tabs] Tab system initialized and set as window content")
267272 } 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")
269275 }
270276 }
271277
272- // Initialize window-level global shortcuts AFTER workspace is set up
273- if window := view.Window(); window != nil {
278+ // Initialize window-level global shortcuts AFTER tab manager is set up
279+ if window != nil {
274280 app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
275281 if app.windowShortcutHandler != nil {
276282 log.Printf("Window-level global shortcuts initialized")
M internal/app/browser/window_shortcuts.go +276 -8
@@ -8,6 +8,8 @@ import (
88
99 "github.com/bnema/dumber/internal/app/messaging"
1010 "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1113 )
1214
1315 // WindowShortcutHandler manages global shortcuts at the window level
@@ -47,6 +49,11 @@ func (h *WindowShortcutHandler) initialize() error {
4749 return ErrFailedToInitialize
4850 }
4951
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+
5057 return h.registerGlobalShortcuts()
5158 }
5259
@@ -60,9 +67,23 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
6067 {"ctrl+f", h.handleFindToggle, "Find in page"},
6168 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
6269 {"ctrl+shift+p", h.handlePrint, "Print page"},
63- {"ctrl+t", h.handleNewTab, "New tab (no-op)"},
64- {"ctrl+w", h.handleClosePane, "Close current pane"},
6570 {"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)"},
6687 // Zoom shortcuts - global level for proper active pane targeting
6788 {"ctrl+plus", h.handleZoomIn, "Zoom in"},
6889 {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
@@ -73,6 +94,11 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
7394 {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},
7495 {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},
7596 {"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)"},
76102 }
77103
78104 for _, shortcut := range shortcuts {
@@ -81,6 +107,142 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
81107 shortcut.key, shortcut.desc)
82108 }
83109
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+
84246 return nil
85247 }
86248
@@ -112,7 +274,7 @@ func (h *WindowShortcutHandler) handleCopyURL() {
112274 script := `
113275 (async function() {
114276 const toast = (message, type) => {
115- document.dispatchEvent(new CustomEvent('dumber:showToast', {
277+ document.dispatchEvent(new CustomEvent('dumber:toast', {
116278 detail: { message, duration: 2000, type }
117279 }));
118280 };
@@ -176,6 +338,38 @@ func (h *WindowShortcutHandler) handlePrint() {
176338 }
177339 }
178340
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+
179373 // handleZoomIn increases zoom level by 10%
180374 func (h *WindowShortcutHandler) handleZoomIn() {
181375 h.handleZoom("in", 1.1)
@@ -367,11 +561,85 @@ func (h *WindowShortcutHandler) handleClosePane() {
367561 }
368562 }
369563
370-// handleNewTab is a no-op handler for Ctrl+T
371-// This prevents the default browser behavior and allows for future implementation
372-func (h *WindowShortcutHandler) handleNewTab() {
373- log.Printf("[window-shortcuts] Ctrl+T pressed (no-op - tabs not yet implemented)")
374- // TODO: Implement new tab functionality
564+// handlePaneMode enters pane mode for modal pane management.
565+func (h *WindowShortcutHandler) handlePaneMode() {
566+ if h.app == nil || h.app.workspace == nil {
567+ log.Printf("[window-shortcuts] Cannot enter pane mode: workspace not available")
568+ return
569+ }
570+
571+ log.Printf("[window-shortcuts] Entering pane mode")
572+ h.app.workspace.EnterPaneMode()
573+}
574+
575+// handleTabMode enters tab mode for modal tab management.
576+func (h *WindowShortcutHandler) handleTabMode() {
577+ if h.app == nil || h.app.tabManager == nil {
578+ log.Printf("[window-shortcuts] Cannot enter tab mode: tab manager not available")
579+ return
580+ }
581+
582+ log.Printf("[window-shortcuts] Entering tab mode")
583+ h.app.tabManager.EnterTabMode()
584+}
585+
586+// handleNextTab switches to the next tab.
587+func (h *WindowShortcutHandler) handleNextTab() {
588+ if h.app == nil || h.app.tabManager == nil {
589+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
590+ return
591+ }
592+
593+ log.Printf("[window-shortcuts] Switching to next tab")
594+ if err := h.app.tabManager.NextTab(); err != nil {
595+ log.Printf("[window-shortcuts] Failed to switch to next tab: %v", err)
596+ }
597+}
598+
599+// handlePrevTab switches to the previous tab.
600+func (h *WindowShortcutHandler) handlePrevTab() {
601+ if h.app == nil || h.app.tabManager == nil {
602+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
603+ return
604+ }
605+
606+ log.Printf("[window-shortcuts] Switching to previous tab")
607+ if err := h.app.tabManager.PreviousTab(); err != nil {
608+ log.Printf("[window-shortcuts] Failed to switch to previous tab: %v", err)
609+ }
610+}
611+
612+// handleDirectTabSwitch switches to a specific tab by index (0-based).
613+func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
614+ if h.app == nil || h.app.tabManager == nil {
615+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
616+ return
617+ }
618+
619+ log.Printf("[window-shortcuts] Direct tab switch to index %d", index)
620+ if err := h.app.tabManager.SwitchToTab(index); err != nil {
621+ log.Printf("[window-shortcuts] Failed to switch to tab %d: %v", index, err)
622+ }
623+}
624+
625+// isTabModeActive checks if tab mode is currently active
626+func (h *WindowShortcutHandler) isTabModeActive() bool {
627+ if h.app == nil || h.app.tabManager == nil {
628+ return false
629+ }
630+ return h.app.tabManager.IsTabModeActive()
631+}
632+
633+// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
634+// Only processes actions when tab mode is active
635+func (h *WindowShortcutHandler) handleTabModeAction(action string) {
636+ if h.app == nil || h.app.tabManager == nil {
637+ return
638+ }
639+
640+ // Tab mode is active, handle the action
641+ log.Printf("[window-shortcuts] Tab mode action: %s", action)
642+ h.app.tabManager.HandleTabAction(action)
375643 }
376644
377645 // Cleanup releases resources
M internal/app/browser/workspace_css.go +174 -28
@@ -36,6 +36,22 @@ func getStackTitleTextColor(isDark bool) string {
3636 return "#333333"
3737 }
3838
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+
3955 // getActivePaneBorderColor returns the border color for active panes based on config and theme
4056 func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {
4157 // Use configured border color if set
@@ -55,30 +71,68 @@ func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool
5571 return "#87CEEB" // Sky blue pastel for light theme
5672 }
5773
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+
5893 // generateWorkspaceCSS generates the complete CSS for workspace panes and stacked panes
5994 func (wm *WorkspaceManager) generateWorkspaceCSS() string {
6095 cfg := config.Get()
6196 styling := cfg.Workspace.Styling
6297
6398 // Get appropriate colors based on GTK theme preference
64- var inactiveBorderColor, windowBackgroundColor string
99+ var windowBackgroundColor string
65100 isDark := webkit.PrefersDarkTheme()
66101 if isDark {
67- inactiveBorderColor = "#333333" // Dark border for dark theme
68102 windowBackgroundColor = "#2b2b2b" // Dark window background
69103 } else {
70- inactiveBorderColor = "#dddddd" // Light border for light theme
71104 windowBackgroundColor = "#ffffff" // Light window background
72105 }
73106
74107 activeBorderColor := getActivePaneBorderColor(styling, isDark)
108+ inactiveBorderColor := getInactivePaneBorderColor(styling, isDark)
75109
76- // Pane mode border color - use configured value or fallback to orange
110+ // Pane mode border color - use configured value or fallback to blue
77111 paneModeColor := styling.PaneModeBorderColor
78112 if paneModeColor == "" {
79- paneModeColor = "#FFA500" // Fallback to orange if not configured
113+ paneModeColor = "#4A90E2" // Fallback to blue if not configured
114+ }
115+
116+ // Tab mode border color - use configured value or fallback to orange
117+ tabModeColor := styling.TabModeBorderColor
118+ if tabModeColor == "" {
119+ tabModeColor = "#FFA500" // Fallback to orange if not configured
80120 }
81121
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+
82136 css := fmt.Sprintf(`window {
83137 background-color: %s;
84138 padding: 0;
@@ -96,16 +150,21 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
96150 transition: margin 150ms ease-in-out;
97151 }
98152
99- /* Base pane styling - subtle border for inactive panes */
153+ /* Base pane styling - configurable inactive borders */
100154 .workspace-pane, .stacked-pane-container {
101- border: 2px solid %s;
155+ border-width: %dpx;
156+ border-style: solid;
157+ border-color: %s;
102158 border-radius: %dpx;
103- transition: border-color %dms ease-in-out;
104159 margin: 0;
160+ transition-property: border-color;
161+ transition-duration: %dms;
162+ transition-timing-function: ease-in-out;
105163 }
106164
107165 /* Active pane border styling */
108166 .workspace-pane-active {
167+ border-width: %dpx;
109168 border-color: %s;
110169 }
111170
@@ -114,16 +173,17 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
114173 background-color: %s;
115174 }
116175
117- /* Stacked pane containers never get active border - focus is shown via WebKit */
176+ /* Stacked pane containers keep inactive border when active */
118177 .stacked-pane-container.workspace-pane-active {
178+ border-width: %dpx;
119179 border-color: %s;
120180 }
121181
122182 .stacked-pane-title {
123183 background-color: %s;
124- border-bottom: 1px solid %s;
125- padding: 4px 8px;
126- min-height: 24px;
184+ border-bottom: %s;
185+ padding: %dpx %dpx;
186+ min-height: %dpx;
127187 transition: background-color %dms ease-in-out;
128188 }
129189
@@ -132,13 +192,13 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
132192 }
133193
134194 .stacked-pane-title-text {
135- font-size: 12px;
195+ font-size: %dpx;
136196 color: %s;
137197 font-weight: 500;
138198 }
139199
140200 .stacked-pane-favicon {
141- margin-right: 4px;
201+ margin-right: %dpx;
142202 }
143203
144204 .stacked-pane-active {
@@ -147,21 +207,107 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
147207
148208 .stacked-pane-collapsed {
149209 /* 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; }
150271 }`,
151- windowBackgroundColor, // window background
152- paneModeColor, // window.pane-mode-active background (border color)
153- windowBackgroundColor, // paned, box background
154- inactiveBorderColor, // base pane border color (inactive)
155- styling.BorderRadius, // base pane border radius
156- styling.TransitionDuration, // base pane border transition
157- activeBorderColor, // workspace-pane-active border color
158- windowBackgroundColor, // stacked-pane-container background
159- inactiveBorderColor, // stacked-pane-container.workspace-pane-active border color (keep subtle)
160- getStackTitleBg(isDark), // stacked-pane-title background
161- inactiveBorderColor, // stacked-pane-title border-bottom
162- styling.TransitionDuration, // stacked-pane-title transition
163- getStackTitleHoverBg(isDark), // stacked-pane-title:hover background
164- getStackTitleTextColor(isDark), // stacked-pane-title-text color
272+ windowBackgroundColor, // window background
273+ paneModeColor, // window.pane-mode-active background (border color)
274+ windowBackgroundColor, // paned, box background
275+ styling.InactiveBorderWidth, // base pane border-width (inactive)
276+ inactiveBorderColor, // base pane border-color (inactive)
277+ styling.BorderRadius, // base pane border radius
278+ styling.TransitionDuration, // transition-duration
279+ styling.BorderWidth, // workspace-pane-active border-width
280+ activeBorderColor, // workspace-pane-active border color
281+ windowBackgroundColor, // stacked-pane-container background
282+ styling.InactiveBorderWidth, // stacked-pane-container.active border-width
283+ inactiveBorderColor, // stacked-pane-container.active border-color
284+ getStackTitleBg(isDark), // stacked-pane-title background
285+ stackedTitleBorder, // stacked-pane-title border-bottom
286+ titlePaddingVertical, // stacked-pane-title padding vertical
287+ titlePaddingHorizontal, // stacked-pane-title padding horizontal
288+ titleMinHeight, // stacked-pane-title min-height
289+ styling.TransitionDuration, // stacked-pane-title transition
290+ getStackTitleHoverBg(isDark), // stacked-pane-title:hover background
291+ titleFontSize, // stacked-pane-title-text font-size
292+ getStackTitleTextColor(isDark), // stacked-pane-title-text color
293+ faviconMargin, // stacked-pane-favicon margin-right
294+ // Tab bar styles
295+ windowBackgroundColor, // tab-bar background
296+ getStackTitleBorderColor(isDark), // tab-bar border-top
297+ int(32 * uiScale), // tab-bar min-height
298+ tabModeColor, // window.tab-mode-active .tab-bar border-top-color (orange, distinct from pane mode blue)
299+ getStackTitleBg(isDark), // tab-button background (same as stacked title)
300+ getStackTitleBorderColor(isDark), // tab-button border-right
301+ titlePaddingVertical, // tab-button padding vertical
302+ titlePaddingHorizontal, // tab-button padding horizontal
303+ styling.TransitionDuration, // tab-button transition
304+ getStackTitleHoverBg(isDark), // tab-button:hover background
305+ getActiveTabBg(isDark), // tab-button-active background (much more visible)
306+ getStackTitleBorderColor(isDark), // tab-button-active border-right
307+ titleFontSize, // tab-title font-size
308+ getStackTitleTextColor(isDark), // tab-title color
309+ windowBackgroundColor, // tab-content-area background
310+ windowBackgroundColor, // tab-workspace-container background
165311 )
166312
167313 return css
M internal/app/browser/workspace_manager.go +259 -14
@@ -73,6 +73,34 @@ type WorkspaceManager struct {
7373 pendingIdle map[uintptr][]*paneNode
7474 }
7575
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+
76104 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
77105
78106 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.
@@ -149,7 +177,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
149177 handler.SetWorkspaceObserver(manager)
150178 }
151179
152- app.workspace = manager
180+ // CRITICAL: Register navigation handler (including pane mode) for the root pane
181+ // This must happen here because when creating the first tab, app.workspace is nil
182+ // in createPaneForView, so it doesn't register the handler
183+ manager.RegisterNavigationHandler(rootPane.webView)
184+
185+ // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
186+ // TabManager sets app.workspace during tab switching (including initial tab creation).
187+ // This prevents workspaces from interfering with each other when multiple tabs exist.
153188
154189 // Configure debug settings from app config
155190 if app.config != nil {
@@ -414,15 +449,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
414449 return wm.FocusNeighbor(direction)
415450 })
416451
417- // Register pane mode callbacks for Ctrl+P and pane mode actions
452+ // Register pane mode callbacks for Ctrl+P and pane mode action keys
418453 webView.RegisterPaneModeHandler(func(action string) bool {
419454 if wm == nil {
420455 return false
421456 }
422457
423458 if action == "enter" {
424- wm.EnterPaneMode()
425- return true
459+ // CRITICAL FIX: Use app.workspace instead of closure's wm
460+ // This ensures Ctrl+P always targets the ACTIVE tab's workspace
461+ if wm.app != nil && wm.app.workspace != nil {
462+ wm.app.workspace.EnterPaneMode()
463+ return true
464+ }
465+ return false
426466 }
427467
428468 if action == "exit" {
@@ -438,18 +478,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
438478 return false // No mode active, pass through to DOM
439479 }
440480
441- // For other actions, only handle if pane mode is active
442- wm.paneMutex.Lock()
443- isActive := wm.paneModeActive
444- wm.paneMutex.Unlock()
481+ // For other actions, use active workspace (same pattern as "enter")
482+ // This ensures action keys work on the correct tab
483+ if wm.app != nil && wm.app.workspace != nil {
484+ // Check if pane mode is active on the active workspace
485+ activeWs := wm.app.workspace
486+ activeWs.paneMutex.Lock()
487+ isActive := activeWs.paneModeActive
488+ activeWs.paneMutex.Unlock()
489+
490+ if !isActive {
491+ return false // Not in pane mode, allow normal behavior
492+ }
445493
446- if !isActive {
447- return false // Not in pane mode, allow normal behavior
494+ // Pane mode is active, handle the action on active workspace
495+ activeWs.HandlePaneAction(action)
496+ return true
448497 }
449-
450- // Pane mode is active, handle the action
451- wm.HandlePaneAction(action)
452- return true
498+ return false
453499 }, func() bool {
454500 // Check if pane mode is currently active
455501 wm.paneMutex.Lock()
@@ -457,5 +503,204 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
457503 return wm.paneModeActive
458504 })
459505
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+
460589 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
461590 }
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 {
208208 }
209209
210210 if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
211+ log.Printf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction)
211212 wm.SetActivePane(neighbor, SourceKeyboard)
212213 return true
213214 }
214215
215- curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)
216+ curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused)
216217 cx := float64(curX) + float64(curWidth)/2.0
217218 cy := float64(curY) + float64(curHeight)/2.0
219+ log.Printf("[workspace] focusAdjacent: current pane center=(%.0f, %.0f) direction=%s", cx, cy, direction)
218220
219221 leaves := wm.collectLeaves()
220222 bestScore := math.MaxFloat64
@@ -226,7 +228,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
226228 continue
227229 }
228230
229- x, y, width, height := webkit.WidgetGetAllocation(candidate.container)
231+ x, y, width, height := wm.getNavigationAllocation(candidate)
230232 tx := float64(x) + float64(width)/2.0
231233 ty := float64(y) + float64(height)/2.0
232234
@@ -247,18 +249,18 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
247249 score = math.Abs(dx)*1000 + math.Abs(dy)
248250 case DirectionUp:
249251 if dy >= -focusEpsilon {
252+ debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not above)", candidate.container, tx, ty, dy))
250253 continue
251254 }
252255 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))
253257 case DirectionDown:
254258 if dy <= focusEpsilon {
259+ debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not below)", candidate.container, tx, ty, dy))
255260 continue
256261 }
257262 score = math.Abs(dy)*1000 + math.Abs(dx)
258- }
259-
260- if direction == DirectionUp || direction == DirectionDown {
261- debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
263+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
262264 }
263265
264266 if score < bestScore {
@@ -268,39 +270,83 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
268270 }
269271
270272 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)
271275 wm.SetActivePane(best, SourceKeyboard)
272276 return true
273277 }
274278
275279 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, "; "))
277281 }
278282 return false
279283 }
280284
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+
281303 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
282304 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
283305 if node == nil || node.container == nil {
284306 return nil
285307 }
286308
287- refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)
309+ refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node)
288310 cx := float64(refX) + float64(refWidth)/2.0
289311 cy := float64(refY) + float64(refHeight)/2.0
290312 axisVertical := direction == DirectionUp || direction == DirectionDown
291313
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+
293331 switch direction {
294332 case DirectionUp:
295333 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
334+ log.Printf("[workspace] structuralNeighbor: DirectionUp - we are RIGHT child, looking in LEFT subtree")
296335 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)
297338 return leaf
298339 }
299340 }
300341 case DirectionDown:
301342 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
343+ log.Printf("[workspace] structuralNeighbor: DirectionDown - we are LEFT child, looking in RIGHT subtree")
302344 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)
303347 return leaf
348+ } else {
349+ log.Printf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown")
304350 }
305351 }
306352 case DirectionLeft:
@@ -323,7 +369,8 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string)
323369
324370 // closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
325371 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)
327374 bestScore := math.MaxFloat64
328375 var best *paneNode
329376 for _, leaf := range leaves {
@@ -331,44 +378,52 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
331378 continue
332379 }
333380
334- x, y, width, height := webkit.WidgetGetAllocation(leaf.container)
381+ x, y, width, height := wm.getNavigationAllocation(leaf)
335382 tx := float64(x) + float64(width)/2.0
336383 ty := float64(y) + float64(height)/2.0
337384 dx := tx - cx
338385 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)
339387 var score float64
340388 switch direction {
341389 case DirectionLeft:
342390 if dx >= -focusEpsilon {
391+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx)
343392 continue
344393 }
345394 score = math.Abs(dx)*1000 + math.Abs(dy)
346395 case DirectionRight:
347396 if dx <= focusEpsilon {
397+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx)
348398 continue
349399 }
350400 score = math.Abs(dx)*1000 + math.Abs(dy)
351401 case DirectionUp:
352402 if dy >= -focusEpsilon {
403+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy)
353404 continue
354405 }
355406 score = math.Abs(dy)*1000 + math.Abs(dx)
356407 case DirectionDown:
357408 if dy <= focusEpsilon {
409+ log.Printf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy)
358410 continue
359411 }
360412 score = math.Abs(dy)*1000 + math.Abs(dx)
361413 default:
362414 continue
363415 }
416+ log.Printf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score)
364417 if score < bestScore {
365418 bestScore = score
366419 best = leaf
367420 }
368421 }
369422 if best == nil {
423+ log.Printf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback")
370424 return wm.boundaryFallback(node, direction)
371425 }
426+ log.Printf("[workspace] closestLeafFromSubtree: returning best=%p", best.container)
372427 return best
373428 }
374429
M internal/app/browser/workspace_navigation_test.go +76 -0
@@ -144,3 +144,79 @@ func TestCaseSensitiveDirections(t *testing.T) {
144144 })
145145 }
146146 }
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 (
55 "log"
66
77 "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
89 )
910
1011 // EnterPaneMode activates pane mode on the currently focused pane
1112 // This is called directly from the keyboard bridge, not from JavaScript
1213 func (wm *WorkspaceManager) EnterPaneMode() {
14+ // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab)
15+
1316 wm.paneMutex.Lock()
1417 defer wm.paneMutex.Unlock()
1518
@@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() {
2629 return
2730 }
2831
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)
3033
3134 wm.paneModeActive = true
3235 wm.paneModeActivePane = activeNode
@@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) {
6770 // HandlePaneAction processes pane mode actions (close, split, etc.)
6871 // Called from keyboard bridge when user presses action keys in pane mode
6972 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+
7076 wm.paneMutex.Lock()
7177
7278 if !wm.paneModeActive {
@@ -201,34 +207,59 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) {
201207
202208 // applyPaneModeBorder applies pane mode visual indicator using GTK margins
203209 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 {
205236 return
206237 }
207238
208239 // Save the container reference so we can remove margins from it later
209- // (even if wm.root.container changes due to splits)
210- wm.paneModeContainer = wm.root.container
240+ wm.paneModeContainer = targetContainer
211241
212- // Apply 4px margins to root container to create space for the border
213- webkit.WidgetSetMargin(wm.root.container, 4)
242+ // Apply 4px margins to create space for the border
243+ webkit.WidgetSetMargin(targetContainer, 4)
214244
215245 // 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")
217247
218248 // Force resize/allocation to apply margin changes immediately
219- webkit.WidgetQueueResize(wm.root.container)
220- webkit.WidgetQueueAllocate(wm.root.container)
249+ webkit.WidgetQueueResize(targetContainer)
250+ webkit.WidgetQueueAllocate(targetContainer)
221251
222252 // Queue redraw to show changes
223- webkit.WidgetQueueDraw(wm.window.AsWindow())
224- webkit.WidgetQueueDraw(wm.root.container)
253+ webkit.WidgetQueueDraw(window.AsWindow())
254+ webkit.WidgetQueueDraw(targetContainer)
225255
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)
227257 }
228258
229259 // removePaneModeBorder removes the pane mode visual indicator
230260 func (wm *WorkspaceManager) removePaneModeBorder() {
231- if wm.window == nil {
261+ window := wm.resolveWindow()
262+ if window == nil {
232263 return
233264 }
234265
@@ -247,14 +278,14 @@ func (wm *WorkspaceManager) removePaneModeBorder() {
247278 webkit.WidgetSetMargin(container, 0)
248279
249280 // Remove CSS class from window
250- webkit.WidgetRemoveCSSClass(wm.window.AsWindow(), "pane-mode-active")
281+ webkit.WidgetRemoveCSSClass(window.AsWindow(), "pane-mode-active")
251282
252283 // Force resize/allocation to apply margin changes immediately
253284 webkit.WidgetQueueResize(container)
254285 webkit.WidgetQueueAllocate(container)
255286
256287 // Queue redraw to show changes
257- webkit.WidgetQueueDraw(wm.window.AsWindow())
288+ webkit.WidgetQueueDraw(window.AsWindow())
258289 webkit.WidgetQueueDraw(container)
259290
260291 // Clear the saved container reference
M internal/app/browser/workspace_pane_ops.go +132 -27
@@ -3,11 +3,13 @@ package browser
33
44 import (
55 "errors"
6+ "fmt"
67 "log"
78
8- "github.com/bnema/dumber/pkg/webkit"
99 "github.com/diamondburned/gotk4/pkg/gdk/v4"
1010 "github.com/diamondburned/gotk4/pkg/gtk/v4"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
1113 )
1214
1315 // closeContext manages the context for a pane close operation
@@ -52,6 +54,60 @@ func (wm *WorkspaceManager) collectLeaves() []*paneNode {
5254 return wm.collectLeavesFrom(wm.root)
5355 }
5456
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+
55111 // collectLeavesFrom collects all leaf nodes from a given subtree
56112 func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
57113 var leaves []*paneNode
@@ -475,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi
475531
476532 // GTK4 handles widget operations automatically - no need to force redraw
477533
478- // Handle root split: attach paned to window FIRST, then let GTK handle reparenting
534+ // Handle root split: attach paned to ContentArea (tab system is always present)
479535 if parent == nil {
480- // ROOT SPLIT: Attach empty paned to window, then add children
481- // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild
482- if wm.window != nil {
483- // Clear any existing window child (the splitTargetContainer)
484- wm.window.SetChild(nil)
485-
486- // Attach the new paned to window FIRST
487- wm.window.SetChild(paned)
488- log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
536+ // ROOT SPLIT: Manipulate TabManager.ContentArea
537+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
538+ log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized")
539+ return nil, fmt.Errorf("tab manager not initialized")
540+ }
541+
542+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
543+ if !ok || contentBox == nil {
544+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
545+ return nil, fmt.Errorf("ContentArea is not a Box")
489546 }
490547
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+
491556 // Now add children to the paned - GTK will handle reparenting automatically
492557 if existingFirst {
493558 paned.SetStartChild(splitTargetContainer)
@@ -656,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si
656721 }
657722
658723 // 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) {
660725 if grand == nil {
726+ // Sibling is being promoted to root
661727 // When TreeRebalancer is enabled, skip GTK attachment here as it will be
662728 // handled by the rebalancer's promotion transaction to avoid double-attachment
663729 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)
665759 }
666760 return
667761 }
@@ -773,34 +867,45 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
773867 }
774868 }
775869
776-// attachRoot attaches a node as the new window root
870+// attachRoot attaches a node as the new root in TabManager.ContentArea.
871+// NOTE: This is typically called AFTER wm.root has already been updated to point to this node.
872+// The old root container (if any) will be orphaned in ContentArea and garbage collected.
777873 func (wm *WorkspaceManager) attachRoot(root *paneNode) {
778- if root == nil || root.container == nil || wm.window == nil {
874+ if root == nil || root.container == nil {
779875 return
780876 }
781877
782878 // Note: In gotk4, reference counting is handled automatically by Go's GC
783879 // No need for manual Ref()/Unref() calls
784880
785- // Detach from previous container (paned, stack, etc.) before replacing the window child.
786- // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent
881+ // Detach from previous container (paned, stack, etc.) before attaching to ContentArea
882+ // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
787883 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)
789885 webkit.WidgetUnparent(root.container)
790886 // Note: In gotk4, the Go object reference remains valid after unparent
791887 // Only the GTK parent relationship is cleared
792888 }
793889
794- // Clear any existing window child to avoid GTK warnings when swapping roots.
795- wm.window.SetChild(nil)
796-
797890 if root.container != nil {
798891 webkit.WidgetSetHExpand(root.container, true)
799892 webkit.WidgetSetVExpand(root.container, true)
800893 }
801894
802- // GTK4 will automatically unparent root.container from its previous parent when we attach it here
803- wm.window.SetChild(root.container)
895+ // Add to ContentArea (the old root will be orphaned and garbage collected)
896+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
897+ log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized")
898+ return
899+ }
900+
901+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
902+ if !ok || contentBox == nil {
903+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
904+ return
905+ }
906+
907+ contentBox.Append(root.container)
908+ webkit.WidgetSetVisible(root.container, true)
804909 if root.container != nil {
805910 webkit.WidgetQueueAllocate(root.container)
806911 webkit.WidgetShow(root.container)
@@ -997,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
9971102
9981103 // STEP 7: GTK widget reparenting
9991104 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1000- // For window: we already manually unparented above, so now we can safely attach
1001- wm.swapContainers(grandparent, sibling)
1105+ // For root promotion: we need to remove the old paned from ContentArea
1106+ wm.swapContainers(grandparent, parent, sibling)
10021107
10031108 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
10041109 if sibling != nil && sibling.container != nil {
@@ -1083,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
10831188 }
10841189 }
10851190 } else {
1086- // Stack was root
1191+ // Stack was root - attach remaining pane as new root
10871192 wm.root = remaining
10881193 if remaining.container != nil {
1089- wm.window.SetChild(remaining.container)
1194+ wm.setRootContainer(remaining.container)
10901195 }
10911196 }
10921197
M internal/app/browser/workspace_popup.go +53 -31
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/dumber/internal/config"
1010 "github.com/bnema/dumber/pkg/webkit"
1111 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12- "github.com/diamondburned/gotk4/pkg/glib/v2"
1312 )
1413
1514 const (
@@ -342,34 +341,26 @@ func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNo
342341 }
343342 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344343
345- // CRITICAL: Do NOT connect signals before returning to WebKit
346- // Connecting signals might trigger WebKit to access WindowFeatures before it's set
347- // Instead, we'll connect them after WebKit has processed the return
348- log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
344+ // Connect signals immediately BEFORE returning to WebKit
345+ // This is safe because we're only connecting signal handlers, not accessing properties
346+ // WebKit will configure WindowFeatures AFTER this callback returns, which is fine
347+ log.Printf("[workspace] Step 4: Connecting signals immediately before returning to WebKit")
349348
350- // Use GLib idle_add to connect signals AFTER this callback returns
351- // This ensures WebKit has finished configuring WindowFeatures before we touch the view
352- glib.IdleAdd(func() bool {
353- log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
354-
355- // Connect ready-to-show signal NOW (after WebKit has configured the view)
356- barePopupView.ConnectReadyToShow(func() {
357- log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
358- wm.handlePopupReadyToShow(popupID)
359- })
360- log.Printf("[workspace] Idle callback: ready-to-show connected")
361-
362- // Connect close handler
363- barePopupView.ConnectClose(func() {
364- log.Printf("[workspace] close signal received for popup ID=%d", popupID)
365- wm.handlePopupClose(popupID)
366- })
367- log.Printf("[workspace] Idle callback: close signal connected")
349+ // Connect ready-to-show signal
350+ barePopupView.ConnectReadyToShow(func() {
351+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
352+ wm.handlePopupReadyToShow(popupID)
353+ })
354+ log.Printf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID)
368355
369- return false // Don't repeat
356+ // Connect close handler
357+ barePopupView.ConnectClose(func() {
358+ log.Printf("[workspace] close signal received for popup ID=%d", popupID)
359+ wm.handlePopupClose(popupID)
370360 })
361+ log.Printf("[workspace] Connected close signal for popup ID=%d", popupID)
371362
372- log.Printf("[workspace] Step 4 complete: signal connections scheduled")
363+ log.Printf("[workspace] Step 4 complete: signals connected")
373364
374365 log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
375366
@@ -418,9 +409,39 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
418409
419410 log.Printf("[workspace] Created popup pane, now inserting into workspace")
420411
421- // NOW it's safe to insert into workspace based on configured behavior
422- behavior := wm.app.config.Workspace.Popups.Behavior
423- log.Printf("[workspace] Popup behavior: %s", behavior)
412+ // Determine window type from node (set during setupPopupHandling)
413+ node := wm.viewToNode[wrappedView]
414+ windowType := webkit.WindowTypePopup
415+ if node != nil {
416+ windowType = node.windowType
417+ }
418+
419+ // Select behavior based on window type:
420+ // - WindowTypeTab (_blank links): Use BlankTargetBehavior config
421+ // - WindowTypePopup (JavaScript popups): Use global Behavior config
422+ var behaviorStr string
423+ if windowType == webkit.WindowTypeTab {
424+ behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior
425+ log.Printf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr)
426+ } else {
427+ behaviorStr = string(wm.app.config.Workspace.Popups.Behavior)
428+ log.Printf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr)
429+ }
430+
431+ // Map string to PopupBehavior enum
432+ var behavior config.PopupBehavior
433+ switch behaviorStr {
434+ case "split":
435+ behavior = config.PopupBehaviorSplit
436+ case "stacked":
437+ behavior = config.PopupBehaviorStacked
438+ case "tabbed":
439+ behavior = config.PopupBehaviorTabbed
440+ default:
441+ behavior = config.PopupBehaviorSplit // Fallback
442+ }
443+
444+ log.Printf("[workspace] Final behavior: %s", behavior)
424445
425446 switch behavior {
426447 case config.PopupBehaviorSplit:
@@ -490,12 +511,13 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
490511 }
491512
492513 // 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]
494517 if node != nil {
495- node.windowType = webkit.WindowTypePopup
496518 node.isRelated = true
497519 node.parentPane = info.parentNode
498- node.isPopup = true
520+ node.isPopup = true // Mark as popup to prevent browser exit when parent closes
499521 node.autoClose = wm.shouldAutoClose(info.url)
500522 node.popupID = popupID // Store popupID for close signal lookup
501523
M internal/app/browser/workspace_utils.go +9 -0
@@ -33,15 +33,24 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
3333 }
3434
3535 // 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
3639 func mapDirection(direction string) (gtk.Orientation, bool) {
3740 switch direction {
3841 case DirectionLeft:
42+ // New pane goes to left, existing to right
3943 return gtk.OrientationHorizontal, false
4044 case DirectionUp:
45+ // New pane goes to top (start), existing to bottom (end)
46+ // So existingFirst = false
4147 return gtk.OrientationVertical, false
4248 case DirectionDown:
49+ // New pane goes to bottom (end), existing to top (start)
50+ // So existingFirst = true
4351 return gtk.OrientationVertical, true
4452 default:
53+ // Right: new pane to right, existing to left
4554 return gtk.OrientationHorizontal, true
4655 }
4756 }
M internal/app/control/clipboard.go +1 -1
@@ -51,7 +51,7 @@ func (c *ClipboardController) showToast(message, toastType string) {
5151 detail["type"] = toastType
5252 }
5353
54- if err := c.webView.DispatchCustomEvent("dumber:showToast", detail); err != nil {
54+ if err := c.webView.DispatchCustomEvent("dumber:toast", detail); err != nil {
5555 log.Printf("Failed to dispatch toast event: %v", err)
5656 }
5757 }
M internal/app/control/zoom.go +8 -1
@@ -26,6 +26,7 @@ type WebViewInterface interface {
2626 InjectScript(script string) error
2727 RegisterZoomChangedHandler(handler func(float64))
2828 RegisterURIChangedHandler(handler func(string))
29+ RegisterLoadCommittedHandler(handler func(string))
2930 }
3031
3132 // NewZoomController creates a new zoom controller
@@ -41,11 +42,17 @@ func NewZoomController(browserService *services.BrowserService, webView *webkit.
4142 func (z *ZoomController) RegisterHandlers() {
4243 z.webView.RegisterZoomChangedHandler(z.handleZoomChange)
4344 z.webView.RegisterURIChangedHandler(z.handleURIChange)
45+ z.webView.RegisterLoadCommittedHandler(z.handleLoadCommitted)
4446 }
4547
46-// handleURIChange responds to URL changes and applies saved zoom levels
48+// handleURIChange tracks URL changes but doesn't apply zoom (wait for load-committed)
4749 func (z *ZoomController) handleURIChange(url string) {
4850 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) {
4956 if url == "" {
5057 return
5158 }
M internal/app/environment/locale.go +34 -24
@@ -12,38 +12,21 @@ import (
1212 func DetectAndSetKeyboardLocale() {
1313 const colonSplitParts = 2 // Number of parts when splitting on colon
1414
15+ var locale string
16+
1517 // 0) Explicit override
16- locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
18+ locale = os.Getenv("DUMBER_KEYBOARD_LOCALE")
1719 if locale == "" {
1820 locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
1921 }
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- }
4022
41- // 2) Try XKB env override
23+ // 1) XKB env override (actual keyboard layout)
4224 if locale == "" {
4325 locale = os.Getenv("XKB_DEFAULT_LAYOUT")
4426 }
4527
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
4730 if locale == "" {
4831 if out, err := exec.Command("localectl", "status").Output(); err == nil {
4932 s := string(out)
@@ -55,6 +38,7 @@ func DetectAndSetKeyboardLocale() {
5538 cand := strings.TrimSpace(parts[1])
5639 if cand != "" {
5740 locale = cand
41+ log.Printf("[locale] keyboard layout from localectl: %s", locale)
5842 }
5943 }
6044 break
@@ -73,6 +57,7 @@ func DetectAndSetKeyboardLocale() {
7357 cand := strings.TrimSpace(parts[1])
7458 if cand != "" {
7559 locale = cand
60+ log.Printf("[locale] keyboard layout from setxkbmap: %s", locale)
7661 }
7762 }
7863 break
@@ -81,9 +66,34 @@ func DetectAndSetKeyboardLocale() {
8166 }
8267 }
8368
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+
8493 if locale == "" {
8594 locale = "en"
95+ log.Printf("[locale] keyboard layout defaulted to: %s", locale)
8696 }
87- // No layout-specific remaps; log for diagnostics only
97+
8898 log.Printf("[locale] keyboard locale detected: %s", locale)
8999 }
M internal/app/environment/media.go +2 -8
@@ -15,18 +15,12 @@ func SetupMediaPipeline() {
1515 log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
1616 }
1717 }
18- // Provide a safe audio sink to avoid autoaudiosink failures
19- if os.Getenv("GST_AUDIO_SINK") == "" {
20- if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil {
21- log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)
22- }
23- }
24- // Optionally disable media stream features that can engage complex pipelines
18+ // Optionally disable media stream features that can engage complex pipelines (webcam/mic)
2519 if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
2620 if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
2721 log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)
2822 }
2923 }
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)")
3125 }
3226 }
M internal/app/messaging/handler.go +120 -0
@@ -50,6 +50,9 @@ type Message struct {
5050 // Wails fetch bridge
5151 ID string `json:"id"`
5252 Payload json.RawMessage `json:"payload"`
53+ // Favorites operations
54+ Title string `json:"title"`
55+ FaviconURL string `json:"faviconURL"`
5356 }
5457
5558 // NewHandler creates a new message handler
@@ -110,6 +113,12 @@ func (h *Handler) Handle(payload string) {
110113 h.handleGetSearchShortcuts(msg)
111114 case "get_color_palettes":
112115 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)
113122 }
114123 }
115124
@@ -568,3 +577,114 @@ func (h *Handler) handleGetColorPalettes(msg Message) {
568577 log.Printf("[messaging] Sending color palettes to JavaScript")
569578 _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
570579 }
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 {
2626 Database DatabaseConfig `mapstructure:"database" yaml:"database"`
2727 History HistoryConfig `mapstructure:"history" yaml:"history"`
2828 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"`
3032 Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
3133 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
3234 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
@@ -67,8 +69,8 @@ type HistoryConfig struct {
6769
6870 // SearchShortcut represents a search shortcut configuration.
6971 type SearchShortcut struct {
70- URL string `mapstructure:"url" yaml:"url" json:"url"`
71- Description string `mapstructure:"description" yaml:"description" json:"description"`
72+ URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
73+ Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
7274 }
7375
7476 // DmenuConfig holds dmenu/rofi integration configuration.
@@ -214,12 +216,14 @@ type DebugConfig struct {
214216
215217 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
216218 type WorkspaceConfig struct {
217- // EnableZellijControls toggles Zellij-inspired keybindings.
218- EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"`
219219 // PaneMode defines modal pane behaviour and bindings.
220220 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"`
221223 // Tabs holds classic browser tab shortcuts.
222224 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"`
223227 // Popups configures default popup placement rules.
224228 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`
225229 // Styling configures workspace visual appearance.
@@ -228,9 +232,40 @@ type WorkspaceConfig struct {
228232
229233 // PaneModeConfig defines modal behaviour for pane management.
230234 type PaneModeConfig struct {
231- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
232- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
233- ActionBindings map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"`
235+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
236+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
237+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"`
238+}
239+
240+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
241+// This is built from the action→keys structure in the config.
242+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
243+ keyToAction := make(map[string]string)
244+ for action, keys := range p.Actions {
245+ for _, key := range keys {
246+ keyToAction[key] = action
247+ }
248+ }
249+ return keyToAction
250+}
251+
252+// TabModeConfig defines modal behaviour for tab management (Zellij-style).
253+type TabModeConfig struct {
254+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
255+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
256+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" json:"actions"`
257+}
258+
259+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
260+// This is built from the action→keys structure in the config.
261+func (t *TabModeConfig) GetKeyBindings() map[string]string {
262+ keyToAction := make(map[string]string)
263+ for action, keys := range t.Actions {
264+ for _, key := range keys {
265+ keyToAction[key] = action
266+ }
267+ }
268+ return keyToAction
234269 }
235270
236271 // TabKeyConfig defines Zellij-inspired tab shortcuts.
@@ -270,8 +305,9 @@ type PopupBehaviorConfig struct {
270305 // FollowPaneContext determines if popup placement follows parent pane context
271306 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
272307
273- // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents
274- // Accepted values: "pane" (default) or "tab" (future support)
308+ // BlankTargetBehavior determines how target="_blank" links are opened
309+ // Accepted values: "split", "stacked" (default), "tabbed"
310+ // This is separate from Behavior which controls JavaScript popups
275311 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
276312
277313 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
@@ -283,17 +319,28 @@ type PopupBehaviorConfig struct {
283319
284320 // WorkspaceStylingConfig defines visual styling for workspace panes.
285321 type WorkspaceStylingConfig struct {
286- // BorderWidth in pixels for pane borders
322+ // BorderWidth in pixels for active pane borders
287323 BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`
288324 // BorderColor for focused panes (CSS color value or theme variable)
289325 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"`
290332 // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
291- // Defaults to "#FFA500" (orange) if not set
333+ // Defaults to "#4A90E2" (blue) if not set
292334 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"`
293338 // TransitionDuration in milliseconds for border animations
294339 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`
295340 // BorderRadius in pixels for pane border corners
296341 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"`
297344 }
298345
299346 // Manager handles configuration loading, watching, and reloading.
@@ -315,7 +362,7 @@ func NewManager() (*Manager, error) {
315362 // Add config paths
316363 configDir, err := GetConfigDir()
317364 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)
319366 }
320367 v.AddConfigPath(configDir)
321368 v.AddConfigPath(".") // Current directory for development
@@ -325,36 +372,11 @@ func NewManager() (*Manager, error) {
325372 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
326373 v.AutomaticEnv()
327374
328- // Bind specific environment variables
329- bindings := map[string]string{
330- "database.path": "DATABASE_PATH",
331- "history.max_entries": "HISTORY_MAX_ENTRIES",
332- "history.retention_period": "HISTORY_RETENTION_PERIOD",
333- "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
334- "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
335- "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
336- "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
337- "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
338- "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
339- "dmenu.url_prefix": "DMENU_URL_PREFIX",
340- "dmenu.date_format": "DMENU_DATE_FORMAT",
341- "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
342- "logging.level": "LOGGING_LEVEL",
343- "logging.format": "LOGGING_FORMAT",
344- "logging.filename": "LOGGING_FILENAME",
345- "logging.max_size": "LOGGING_MAX_SIZE",
346- "logging.max_backups": "LOGGING_MAX_BACKUPS",
347- "logging.max_age": "LOGGING_MAX_AGE",
348- "logging.compress": "LOGGING_COMPRESS",
349- }
350-
351- for key, env := range bindings {
352- if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
353- return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
354- }
355- }
375+ // Note: Most environment variables are handled automatically via AutomaticEnv()
376+ // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
377+ // The explicit bindings below are only for special cases with different naming patterns.
356378
357- // Explicit binding for rendering mode via dedicated env var
379+ // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
358380 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
359381 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
360382 }
@@ -366,6 +388,7 @@ func NewManager() (*Manager, error) {
366388 }
367389
368390 // Video acceleration environment variable bindings
391+ // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
369392 videoAccelEnvBindings := map[string]string{
370393 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
371394 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
@@ -381,6 +404,7 @@ func NewManager() (*Manager, error) {
381404 }
382405
383406 // Codec preferences environment variable bindings
407+ // These use DUMBER_* prefix for shorter, more convenient env var names
384408 codecEnvBindings := map[string]string{
385409 "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
386410 "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
@@ -426,21 +450,28 @@ func (m *Manager) Load() error {
426450 if errors.As(err, &configFileNotFoundError) {
427451 // Config file not found, create default one
428452 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)
430455 }
431456 // Re-read the newly created config file
432457 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)
434459 }
435460 } 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)
437467 }
438468 }
439469
440470 // Unmarshal into config struct
441471 config := &Config{}
442472 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)
444475 }
445476
446477 // Set database path if not specified
@@ -478,6 +509,11 @@ func (m *Manager) Load() error {
478509 // Validate ColorScheme setting
479510 m.validateColorScheme(config)
480511
512+ // Validate all config values
513+ if err := validateConfig(config); err != nil {
514+ return fmt.Errorf("configuration validation failed: %w", err)
515+ }
516+
481517 m.config = config
482518 return nil
483519 }
@@ -579,6 +615,11 @@ func (m *Manager) reload() error {
579615 // Validate ColorScheme setting
580616 m.validateColorScheme(config)
581617
618+ // Validate all config values
619+ if err := validateConfig(config); err != nil {
620+ return fmt.Errorf("configuration validation failed: %w", err)
621+ }
622+
582623 m.config = config
583624 return nil
584625 }
@@ -598,6 +639,7 @@ func (m *Manager) setDefaults() {
598639
599640 // Search shortcuts defaults
600641 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
642+ m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
601643
602644 // Dmenu defaults
603645 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
@@ -674,14 +716,17 @@ func (m *Manager) setDefaults() {
674716 m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
675717
676718 // Workspace defaults
677- m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
678719 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
679720 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)
681725 m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
682726 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
683727 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
684728 m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
729+ m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
685730 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
686731 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
687732 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -692,9 +737,13 @@ func (m *Manager) setDefaults() {
692737 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
693738 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
694739 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)
695743 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
696744 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
697745 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)
746+ m.viper.SetDefault("workspace.styling.ui_scale", defaults.Workspace.Styling.UIScale)
698747 }
699748
700749 // createDefaultConfig creates a default configuration file.
@@ -714,12 +763,14 @@ func (m *Manager) createDefaultConfig() error {
714763 return fmt.Errorf("failed to write config file: %w", err)
715764 }
716765
717- fmt.Printf("Created default configuration file: %s\n", configFile)
766+ fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
718767
719- // Generate JSON schema file
720- if err := GenerateSchemaFile(); err != nil {
721- // Log error but don't fail config creation
722- fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
768+ // Generate JSON schema file only for JSON configs (used for IDE autocompletion)
769+ if filepath.Ext(configFile) == ".json" {
770+ if err := GenerateSchemaFile(); err != nil {
771+ // Log error but don't fail config creation
772+ fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
773+ }
723774 }
724775
725776 return nil
M internal/config/defaults.go +49 -26
@@ -21,14 +21,22 @@ const (
2121 // Workspace defaults
2222 defaultPaneActivationShortcut = "ctrl+p"
2323 defaultPaneTimeoutMilliseconds = 3000
24+ defaultTabActivationShortcut = "ctrl+t"
25+ defaultTabTimeoutMilliseconds = 3000
26+ defaultTabBarPosition = "bottom"
2427 defaultPopupPlacement = "right"
2528
2629 // Workspace styling defaults
27- defaultBorderWidth = 2
28- defaultBorderColor = "@theme_selected_bg_color"
29- defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator
30- defaultTransitionDuration = 120
31- defaultBorderRadius = 0
30+ defaultBorderWidth = 1
31+ defaultBorderColor = "@theme_selected_bg_color"
32+ defaultInactiveBorderWidth = 1 // Same width as active to prevent layout shift
33+ defaultInactiveBorderColor = "@theme_unfocused_bg_color" // GTK theme variable
34+ defaultShowStackedTitleBorder = false // Hidden by default
35+ defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
36+ defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator (distinct from pane mode)
37+ defaultTransitionDuration = 120
38+ defaultBorderRadius = 0
39+ defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
3240 )
3341
3442 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -51,7 +59,8 @@ func DefaultConfig() *Config {
5159 RetentionPeriodDays: defaultRetentionDays, // 1 year
5260 CleanupIntervalDays: 1, // daily cleanup
5361 },
54- SearchShortcuts: GetDefaultSearchShortcuts(),
62+ SearchShortcuts: GetDefaultSearchShortcuts(),
63+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
5564 Dmenu: DmenuConfig{
5665 MaxHistoryItems: defaultMaxHistoryItems,
5766 ShowVisitCount: true,
@@ -142,22 +151,30 @@ func DefaultConfig() *Config {
142151 UseDomZoom: false,
143152 DefaultZoom: 1.2, // 120% default zoom for better readability
144153 Workspace: WorkspaceConfig{
145- EnableZellijControls: true,
146154 PaneMode: PaneModeConfig{
147155 ActivationShortcut: defaultPaneActivationShortcut,
148156 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
149- ActionBindings: map[string]string{
150- "arrowright": "split-right",
151- "arrowleft": "split-left",
152- "arrowup": "split-up",
153- "arrowdown": "split-down",
154- "r": "split-right",
155- "l": "split-left",
156- "u": "split-up",
157- "d": "split-down",
158- "x": "close-pane",
159- "enter": "confirm",
160- "escape": "cancel",
157+ Actions: map[string][]string{
158+ "split-right": {"arrowright", "r"},
159+ "split-left": {"arrowleft", "l"},
160+ "split-up": {"arrowup", "u"},
161+ "split-down": {"arrowdown", "d"},
162+ "close-pane": {"x"},
163+ "confirm": {"enter"},
164+ "cancel": {"escape"},
165+ },
166+ },
167+ TabMode: TabModeConfig{
168+ ActivationShortcut: defaultTabActivationShortcut,
169+ TimeoutMilliseconds: defaultTabTimeoutMilliseconds,
170+ Actions: map[string][]string{
171+ "new-tab": {"n", "c"},
172+ "close-tab": {"x"},
173+ "next-tab": {"l", "tab"},
174+ "previous-tab": {"h", "shift+tab"},
175+ "rename-tab": {"r"},
176+ "confirm": {"enter"},
177+ "cancel": {"escape"},
161178 },
162179 },
163180 Tabs: TabKeyConfig{
@@ -166,21 +183,27 @@ func DefaultConfig() *Config {
166183 NextTab: "ctrl+tab",
167184 PreviousTab: "ctrl+shift+tab",
168185 },
186+ TabBarPosition: defaultTabBarPosition,
169187 Popups: PopupBehaviorConfig{
170- Behavior: PopupBehaviorSplit, // Default: open popups in split panes
188+ Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
171189 Placement: defaultPopupPlacement,
172190 OpenInNewPane: true,
173191 FollowPaneContext: true,
174- BlankTargetBehavior: "pane", // Default to pane, future: "tab"
192+ BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
175193 EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
176194 OAuthAutoClose: true, // Auto-close OAuth popups on success
177195 },
178196 Styling: WorkspaceStylingConfig{
179- BorderWidth: defaultBorderWidth,
180- BorderColor: defaultBorderColor,
181- PaneModeBorderColor: defaultPaneModeBorderColor,
182- TransitionDuration: defaultTransitionDuration,
183- BorderRadius: defaultBorderRadius,
197+ BorderWidth: defaultBorderWidth,
198+ BorderColor: defaultBorderColor,
199+ InactiveBorderWidth: defaultInactiveBorderWidth,
200+ InactiveBorderColor: defaultInactiveBorderColor,
201+ ShowStackedTitleBorder: defaultShowStackedTitleBorder,
202+ PaneModeBorderColor: defaultPaneModeBorderColor,
203+ TabModeBorderColor: defaultTabModeBorderColor,
204+ TransitionDuration: defaultTransitionDuration,
205+ BorderRadius: defaultBorderRadius,
206+ UIScale: defaultUIScale,
184207 },
185208 },
186209 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) {
114114 if err != nil {
115115 return "", err
116116 }
117- return filepath.Join(configDir, "config.json"), nil
117+ return filepath.Join(configDir, "config.toml"), nil
118118 }
119119
120120 // 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 {
3636 DeleteExpiredCertificateValidations(ctx context.Context) error
3737 }
3838
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+
3951 // DatabaseQuerier combines all database operation interfaces
4052 type DatabaseQuerier interface {
4153 ZoomQuerier
4254 HistoryQuerier
4355 CertificateQuerier
56+ FavoritesQuerier
4457 }
4558
4659 // Ensure that *Queries implements DatabaseQuerier interface
M internal/db/models.go +10 -0
@@ -17,6 +17,16 @@ type CertificateValidation struct {
1717 ExpiresAt sql.NullTime `json:"expires_at"`
1818 }
1919
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+
2030 type FuzzyCacheMetadatum struct {
2131 ID int64 `json:"id"`
2232 Version int64 `json:"version"`
M internal/db/querier.go +16 -0
@@ -13,13 +13,23 @@ type Querier interface {
1313 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
1414 // Cleanup zoom level entries older than specified days
1515 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
1618 DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error
1719 DeleteExpiredCertificateValidations(ctx context.Context) error
20+ // Delete a favorite by URL
21+ DeleteFavorite(ctx context.Context, url string) error
1822 DeleteHistory(ctx context.Context, id int64) error
1923 // Delete zoom level setting for a domain
2024 DeleteZoomLevel(ctx context.Context, domain string) error
25+ // Get all favorites ordered by position
26+ GetAllFavorites(ctx context.Context) ([]Favorite, error)
2127 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
2228 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)
2333 GetHistory(ctx context.Context, limit int64) ([]History, error)
2434 GetHistoryEntry(ctx context.Context, url string) (History, error)
2535 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
@@ -27,6 +37,8 @@ type Querier interface {
2737 GetZoomLevel(ctx context.Context, domain string) (float64, error)
2838 // Get zoom level for domain with default fallback
2939 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
40+ // Check if a URL is favorited
41+ IsFavorite(ctx context.Context, url string) (int64, error)
3042 ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
3143 // List all zoom level settings ordered by most recently updated
3244 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
@@ -34,6 +46,10 @@ type Querier interface {
3446 // Set or update zoom level for a domain with validation
3547 SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
3648 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
3753 UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error
3854 }
3955
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) {
1818 if input == "" {
1919 return &ParseResult{
2020 Type: InputTypeFallbackSearch,
21- URL: "https://www.google.com/search?q=",
21+ URL: fmt.Sprintf(p.config.DefaultSearchEngine, ""),
2222 Query: input,
2323 Confidence: 0.0,
2424 ProcessingTime: time.Since(startTime),
@@ -29,19 +29,29 @@ func (p *Parser) ParseInput(input string) (*ParseResult, error) {
2929 finalURL, err := p.parseInputUsingCLILogic(input)
3030 if err != nil {
3131 // 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))
3333 }
3434
3535 // Determine result type based on the URL
36- var resultType InputType = InputTypeFallbackSearch
37- confidence := 0.1
36+ var resultType InputType
37+ var confidence float64
3838
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:
4044 resultType = InputTypeFallbackSearch
4145 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://"):
4350 resultType = InputTypeDirectURL
4451 confidence = 1.0
52+ default:
53+ resultType = InputTypeFallbackSearch
54+ confidence = 0.1
4555 }
4656
4757 // Check if it was a shortcut
@@ -140,7 +150,7 @@ func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
140150 // 8. Everything else is a search query
141151 // This includes: single words, multi-word phrases, questions, special characters, etc.
142152 // Single words without TLDs should be treated as search queries, not hostnames
143- return fmt.Sprintf("https://www.google.com/search?q=%s", neturl.QueryEscape(input)), nil
153+ return fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input)), nil
144154 }
145155
146156 // parseSearchShortcut handles search shortcut inputs.
@@ -256,22 +266,16 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear
256266 // buildSearchURL builds a search URL using the default search engine.
257267 func (p *Parser) buildSearchURL(searchEngine, query string) string {
258268 q := neturl.QueryEscape(query)
259- // If no specific search engine, use Google as default
269+ // If no specific search engine, use the configured default
260270 if searchEngine == "" {
261- defaultShortcut, exists := p.config.SearchShortcuts["g"]
262- if exists {
263- url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
264- url = strings.ReplaceAll(url, "%s", q)
265- return url
266- }
267- // Fallback to Google if no "g" shortcut configured
268- return fmt.Sprintf("https://www.google.com/search?q=%s", q)
271+ return fmt.Sprintf(p.config.DefaultSearchEngine, q)
269272 }
270273
271274 // Use specified search engine
272275 shortcut, exists := p.config.SearchShortcuts[searchEngine]
273276 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)
275279 }
276280
277281 u := strings.ReplaceAll(shortcut.URL, "{query}", q)
@@ -433,9 +437,13 @@ func (p *Parser) ValidateConfig() error {
433437 return fmt.Errorf("no search shortcuts configured")
434438 }
435439
436- // Check for default search shortcut
437- if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle {
438- // Warning: no default search configured
440+ // Check for default search engine
441+ if p.config.DefaultSearchEngine == "" {
442+ return fmt.Errorf("default search engine is not configured")
443+ }
444+
445+ if !strings.Contains(p.config.DefaultSearchEngine, "%s") {
446+ return fmt.Errorf("default search engine must contain %%s placeholder")
439447 }
440448
441449 return nil
M internal/services/browser.go +90 -0
@@ -50,6 +50,7 @@ type BrowserService struct {
5050 guiBundle string
5151 zoomCache *cache.ZoomCache // In-memory cache for zoom levels
5252 certCache *cache.CertValidationsCache // In-memory cache for certificate validations
53+ favoritesCache *cache.FavoritesCache // In-memory cache for favorites
5354 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu
5455 historyQueue chan historyUpdate // Queue for batched history writes
5556 historyFlushDone chan bool // Signal when history flush is complete (buffered)
@@ -97,6 +98,7 @@ func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserS
9798 guiBundle: "",
9899 zoomCache: cache.NewZoomCache(queries),
99100 certCache: cache.NewCertValidationsCache(queries),
101+ favoritesCache: cache.NewFavoritesCache(queries),
100102 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults
101103 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
102104 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking
@@ -576,6 +578,89 @@ func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error {
576578 return nil
577579 }
578580
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+
579664 // FlushAllCaches flushes all in-memory caches to ensure pending writes complete.
580665 // This is typically called during shutdown to prevent data loss.
581666 func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
@@ -589,6 +674,11 @@ func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
589674 return fmt.Errorf("failed to flush cert cache: %w", err)
590675 }
591676
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+
592682 // Save fuzzy cache to binary file for instant dmenu access
593683 s.fuzzyCache.OnApplicationExit(ctx)
594684
M internal/services/favicon_service.go +59 -10
@@ -9,6 +9,7 @@ import (
99 "log"
1010 "os"
1111 "path/filepath"
12+ "sync"
1213 "time"
1314
1415 "github.com/bnema/dumber/internal/db"
@@ -28,6 +29,14 @@ type FaviconService struct {
2829 exportDir string // Directory for exporting favicons for CLI access
2930 dataDir string // WebKit data directory
3031 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
3140 }
3241
3342 const (
@@ -35,6 +44,8 @@ const (
3544 DefaultFaviconSize = 32
3645 // FaviconTimeout is the maximum time to wait for a favicon to be available
3746 FaviconTimeout = 5 * time.Second
47+ // DeduplicationWindow is the time window for ignoring duplicate favicon changes
48+ DeduplicationWindow = 2 * time.Second
3849 )
3950
4051 // ServiceName returns the service name for identification
@@ -83,28 +94,66 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue
8394 // OnFaviconChanged handles favicon URI changes from WebKit's favicon database.
8495 // We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture.
8596 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-
9097 if !fs.shouldProcessURL(pageURL) {
9198 return nil
9299 }
93100
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
94135 nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""}
95136 if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil {
96137 log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err)
97138 return fmt.Errorf("failed to update favicon in database: %w", err)
98139 }
99140
100- // Also update all other URLs from the same domain to propagate favicon
101- // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar)
102- fs.propagateFaviconToDomain(pageURL, faviconURI)
141+ // Store this change as recently processed
142+ fs.recentChanges.Store(pageURL, recentChange{faviconURI: faviconURI, timestamp: time.Now()})
103143
104- // Proactively render and export favicon for omnibox/CLI use
105- // This ensures favicons are available ASAP for suggestions
144+ // Propagate favicon to domain asynchronously to avoid blocking WebKit
145+ go fs.propagateFaviconToDomain(pageURL, faviconURI)
146+
147+ // Proactively render and export favicon for omnibox/CLI use (debounced)
106148 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+ }
108157 }
109158
110159 return nil
M main.go +6 -2
@@ -33,8 +33,12 @@ func main() {
3333 return
3434 }
3535
36- // Initialize logging early for GUI mode using default config
37- cfg := config.New()
36+ // Load config file first, then initialize logging with actual config
37+ if err := config.Init(); err != nil {
38+ log.Printf("Warning: failed to load config: %v, using defaults", err)
39+ }
40+ cfg := config.Get()
41+
3842 if err := logging.Init(
3943 cfg.Logging.LogDir,
4044 cfg.Logging.Level,
M pkg/webkit/helpers.go +35 -0
@@ -171,6 +171,23 @@ func WidgetGetBounds(w gtk.Widgetter) (x, y, width, height float64) {
171171 return 0, 0, 0, 0
172172 }
173173
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+
174191 // WidgetSetFocusChild sets the focus child of a widget
175192 func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {
176193 if widget := getWidget(w); widget != nil {
@@ -233,6 +250,24 @@ func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) {
233250 }
234251 }
235252
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+
236271 // WidgetAllocation returns the widget's allocation (alias for WidgetGetAllocation)
237272 func WidgetAllocation(w gtk.Widgetter) (x, y, width, height int) {
238273 return WidgetGetAllocation(w)
M pkg/webkit/keyboard_bridge.go +15 -0
@@ -41,6 +41,9 @@ const (
4141 keyW = "w"
4242 keyX = "x"
4343 keyS = "s"
44+ keyH = "h"
45+ keyJ = "j"
46+ keyK = "k"
4447 keyEscape = "escape"
4548 keyArrowLeft = "arrowleft"
4649 keyArrowRight = "arrowright"
@@ -92,6 +95,12 @@ var shortcutActions = map[string]string{
9295 altmod + "+" + keyArrowRight: directionRight,
9396 altmod + "+" + keyArrowDown: directionDown,
9497 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,
95104 }
96105
97106 // TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30)
@@ -161,6 +170,12 @@ func (w *WebView) AttachKeyboardBridge() {
161170 keyName = keyW
162171 case 'x', 'X':
163172 keyName = keyX
173+ case 'h', 'H':
174+ keyName = keyH
175+ case 'j', 'J':
176+ keyName = keyJ
177+ case 'k', 'K':
178+ keyName = keyK
164179 case 's', 'S':
165180 keyName = keyS
166181 case gdkKeyLeft:
M pkg/webkit/mouse_gestures.go +1 -6
@@ -42,7 +42,6 @@ func (w *WebView) AttachMouseGestures() {
4242
4343 switch button {
4444 case mouseButtonBack:
45- // Mouse back button - navigate backward
4645 if w.view.CanGoBack() {
4746 log.Printf("[mouse-gestures] Back button clicked - navigating backward")
4847 w.view.GoBack()
@@ -51,21 +50,17 @@ func (w *WebView) AttachMouseGestures() {
5150 }
5251
5352 case mouseButtonForward:
54- // Mouse forward button - navigate forward
5553 if w.view.CanGoForward() {
5654 log.Printf("[mouse-gestures] Forward button clicked - navigating forward")
5755 w.view.GoForward()
5856 } else {
5957 log.Printf("[mouse-gestures] Forward button clicked but cannot go forward")
6058 }
61-
62- default:
63- // Don't handle other buttons (let WebView handle normally)
64- return
6559 }
6660 })
6761
6862 // Attach controller to WebView
6963 w.view.AddController(gestureClick)
64+
7065 log.Printf("[mouse-gestures] Mouse gesture controller attached to WebView ID %d", w.id)
7166 }
M pkg/webkit/shortcuts.go +6 -0
@@ -138,6 +138,12 @@ func convertToGtkFormat(key string) string {
138138 parts = append(parts, "Up")
139139 case "down", "arrowdown":
140140 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")
141147 default:
142148 // Single letter keys like l, f, c, w, etc.
143149 parts = append(parts, part)
M pkg/webkit/user_content.go +105 -25
@@ -9,6 +9,13 @@ import (
99 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
1010 )
1111
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+
1219 // SetupUserContentManager configures UserContentManager for the WebView
1320 // This injects GUI scripts at document-start and registers message handlers
1421 func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error {
@@ -23,22 +30,35 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string,
2330 return nil
2431 }
2532
26- // Inject webview ID FIRST, so GUI scripts can access it immediately
33+ // Inject webview ID in BOTH main world and isolated world
34+ // The isolated world (GUI scripts) needs this, and pages might use it for communication
2735 // Note: webviewID is uint64, formatted as number (not string) to avoid any injection concerns
2836 webviewIDScript := fmt.Sprintf(`
2937 window.__dumber_webview_id = %d;
3038 console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id);
3139 `, webviewID)
40+
41+ // Main world (for page scripts)
3242 ucm.AddScript(webkit.NewUserScript(
3343 webviewIDScript,
34- webkit.UserContentInjectTopFrame, // Only top frame needs the webview ID
44+ webkit.UserContentInjectTopFrame,
45+ webkit.UserScriptInjectAtDocumentStart,
46+ nil,
47+ nil,
48+ ))
49+
50+ // Isolated world (for GUI scripts)
51+ ucm.AddScript(webkit.NewUserScriptForWorld(
52+ webviewIDScript,
53+ webkit.UserContentInjectTopFrame,
3554 webkit.UserScriptInjectAtDocumentStart,
55+ DumberIsolatedWorld,
3656 nil,
3757 nil,
3858 ))
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)
4060
41- // Inject GTK theme detection SECOND, before color-scheme script
61+ // Inject GTK theme detection in BOTH worlds
4262 // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
4363 // Respect the ColorScheme config setting
4464 cfg := config.Get()
@@ -57,71 +77,131 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string,
5777 }
5878
5979 gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
80+
81+ // Main world
6082 ucm.AddScript(webkit.NewUserScript(
6183 gtkThemeScript,
62- webkit.UserContentInjectTopFrame, // Only top frame needs theme preference
84+ webkit.UserContentInjectTopFrame,
6385 webkit.UserScriptInjectAtDocumentStart,
6486 nil,
6587 nil,
6688 ))
67- log.Printf("[webkit] Injected theme preference: prefersDark=%t", prefersDark)
6889
69- // Inject palette config SECOND, before GUI scripts
90+ // Isolated world
91+ ucm.AddScript(webkit.NewUserScriptForWorld(
92+ gtkThemeScript,
93+ webkit.UserContentInjectTopFrame,
94+ webkit.UserScriptInjectAtDocumentStart,
95+ DumberIsolatedWorld,
96+ nil,
97+ nil,
98+ ))
99+ log.Printf("[webkit] Injected theme preference: prefersDark=%t (main + isolated world)", prefersDark)
100+
101+ // Inject palette config in BOTH worlds
70102 // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} }
71103 if appearanceConfigJSON != "" {
72104 paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON)
105+
106+ // Main world
73107 ucm.AddScript(webkit.NewUserScript(
74108 paletteScript,
75- webkit.UserContentInjectTopFrame, // Only top frame needs palette config
109+ webkit.UserContentInjectTopFrame,
76110 webkit.UserScriptInjectAtDocumentStart,
77111 nil,
78112 nil,
79113 ))
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))
81125 }
82126
83- // Inject color-scheme script at document-start
127+ // Inject color-scheme script in BOTH worlds
128+ // Main world: manipulates document.documentElement.classList for page scripts
129+ // Isolated world: sets CSS variables that GUI scripts can read via getComputedStyle
84130 if assets.ColorSchemeScript != "" {
131+ // Main world
85132 ucm.AddScript(webkit.NewUserScript(
86133 assets.ColorSchemeScript,
87- webkit.UserContentInjectTopFrame, // Only top frame needs color scheme detection
134+ webkit.UserContentInjectTopFrame,
88135 webkit.UserScriptInjectAtDocumentStart,
89- nil, // whitelist (nil = all)
90- nil, // blacklist (nil = none)
136+ nil,
137+ nil,
91138 ))
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))
93150 }
94151
95- // Inject main-world script at document-start
152+ // Inject main-world script in MAIN world
153+ // This script needs webkit.messageHandlers access to forward isolated world messages
154+ // It also handles theme, zoom, and other main-world only APIs
96155 if assets.MainWorldScript != "" {
97156 ucm.AddScript(webkit.NewUserScript(
98157 assets.MainWorldScript,
99- webkit.UserContentInjectTopFrame, // Only top frame needs bridge/UI functionality
158+ webkit.UserContentInjectTopFrame,
100159 webkit.UserScriptInjectAtDocumentStart,
101160 nil,
102161 nil,
103162 ))
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))
105164 }
106165
107- // Inject GUI controls script at document-start
166+ // Inject GUI controls script in ISOLATED world only
167+ // This contains Svelte components and must be protected from page interference
108168 if assets.GUIScript != "" {
109- ucm.AddScript(webkit.NewUserScript(
169+ ucm.AddScript(webkit.NewUserScriptForWorld(
110170 assets.GUIScript,
111- webkit.UserContentInjectTopFrame, // Only top frame needs GUI controls
171+ webkit.UserContentInjectTopFrame,
172+ webkit.UserScriptInjectAtDocumentStart,
173+ DumberIsolatedWorld,
174+ nil,
175+ nil,
176+ ))
177+ log.Printf("[webkit] Injected GUI controls script (%d bytes, isolated world)", len(assets.GUIScript))
178+ }
179+
180+ // Inject component CSS styles as a JavaScript variable in ISOLATED world
181+ // These styles need to be injected into the shadow root by shadowHost.ts
182+ // because Shadow DOM has style encapsulation - external stylesheets don't penetrate it
183+ if assets.ComponentStyles != "" {
184+ // Use fmt.Sprintf with %q to properly escape the CSS string for JavaScript
185+ componentStylesScript := fmt.Sprintf(`window.__dumber_component_styles = %q;`, assets.ComponentStyles)
186+
187+ ucm.AddScript(webkit.NewUserScriptForWorld(
188+ componentStylesScript,
189+ webkit.UserContentInjectTopFrame,
112190 webkit.UserScriptInjectAtDocumentStart,
191+ DumberIsolatedWorld,
113192 nil,
114193 nil,
115194 ))
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))
117196 }
118197
119- // Register script message handler "dumber" for communication from JS
120- // Pass empty string for worldName to use the default world
198+ // Register script message handler "dumber" in the MAIN world
199+ // Isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler
200+ // This architecture is required because webkit.messageHandlers is only available in main world
121201 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")
123203 } else {
124- log.Printf("[webkit] Registered 'dumber' script message handler")
204+ log.Printf("[webkit] Registered 'dumber' script message handler in main world (default)")
125205 }
126206
127207 return nil
M pkg/webkit/webview.go +108 -0
@@ -38,6 +38,7 @@ type WebView struct {
3838 onFaviconChanged func([]byte)
3939 onFaviconURIChanged func(pageURI, faviconURI string)
4040 onZoomChanged func(float64)
41+ onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
4142 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
4243 onReadyToShow func() // WebKit ready-to-show signal handler
4344 onClose func()
@@ -46,6 +47,7 @@ type WebView struct {
4647 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
4748 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
4849 isPaneModeActive func() bool // Check if pane mode is active
50+ onMiddleClickLink func(url string) bool // Middle mouse click on link handler
4951 }
5052
5153 // NewWebView creates a new WebView with the given configuration
@@ -233,6 +235,14 @@ func (w *WebView) setupEventHandlers() {
233235 }
234236 })
235237
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+
236246 // Favicon changed - connect to FaviconDatabase
237247 w.setupFaviconHandlers()
238248
@@ -290,6 +300,85 @@ func (w *WebView) setupEventHandlers() {
290300 }
291301 })
292302 }
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)
293382 }
294383
295384 // LoadURL loads the given URL in the WebView
@@ -525,6 +614,14 @@ func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) {
525614 w.onZoomChanged = handler
526615 }
527616
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+
528625 // RegisterCloseHandler registers a handler for close requests
529626 func (w *WebView) RegisterCloseHandler(handler func()) {
530627 w.mu.Lock()
@@ -565,6 +662,14 @@ func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)
565662 w.onWindowTypeDetected = handler
566663 }
567664
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+
568673 // RunOnMainThread executes a function on the GTK main thread
569674 func (w *WebView) RunOnMainThread(fn func()) {
570675 RunOnMainThread(fn)
@@ -643,6 +748,9 @@ func (w *WebView) InjectScript(script string) error {
643748 }
644749
645750 // 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.
646754 func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
647755 // Serialize data to JSON
648756 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