detached d9a391f

Merge pull request #19 from bnema/dev

Fix navigation perf, Wayland dialogs, idle inhibition, and stacked pane updates
commit
d9a391f91b3f05a07690925fec08bc57145c6cea
author
bnema · · 2025-12-24 16:18 UTC
committer
GitHub · · 2025-12-24 16:18 UTC
merge of
c741a39 + 3026381 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.20.1
56 files · +1854 -207
M .github/workflows/ci.yml +3 -1
@@ -65,7 +65,9 @@ jobs:
6565 run: |
6666 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.2
6767 - name: Run golangci-lint
68- run: golangci-lint run --timeout=20m
68+ run: |
69+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
70+ golangci-lint run --timeout=20m
6971
7072 test:
7173 runs-on: ubuntu-latest
@@ -65,7 +65,9 @@ jobs:
65 run: |65 run: |
66 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.266 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.2
67 - name: Run golangci-lint67 - name: Run golangci-lint
68- run: golangci-lint run --timeout=20m68+ run: |
69+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
70+ golangci-lint run --timeout=20m
69 71
70 test:72 test:
71 runs-on: ubuntu-latest73 runs-on: ubuntu-latest
M CHANGELOG.md +20 -0
@@ -4,7 +4,27 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Fixed
8+- **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane.
9+- **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes.
10+- **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits.
11+- **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference.
12+- **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
13+- **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
14+- **Navigation performance**: Significantly faster page navigation through multiple optimizations:
15+ - Async history recording via background worker (SQLite writes no longer block GTK main thread)
16+ - LRU cache for per-domain zoom levels (repeat visits skip database queries)
17+ - Async favicon preload (cache lookup doesn't block navigation start)
18+ - Progress callback throttling at ~60fps (reduces UI callback overhead)
19+ - Increased WebView pool prewarm from 2 to 4 for faster initial tab creation
20+
21+### Changed
22+- **Custom color picker and confirm dialogs**: Replaced native GTK color picker and confirm dialogs with custom Svelte components using bits-ui AlertDialog. Fixes Wayland compatibility issues where native dialogs would not display properly (fixes #12).
23+- **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit.
24+- **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars).
25+
726 ### Added
27+- **Performance config section**: New `performance` config section with `zoom_cache_size` (default: 256) and `webview_pool_prewarm_count` (default: 4) for tuning. Not exposed in UI but can be set in config file.
828 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
929 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
1030 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
@@ -4,7 +4,27 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Fixed
8+- **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane.
9+- **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes.
10+- **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits.
11+- **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference.
12+- **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
13+- **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
14+- **Navigation performance**: Significantly faster page navigation through multiple optimizations:
15+ - Async history recording via background worker (SQLite writes no longer block GTK main thread)
16+ - LRU cache for per-domain zoom levels (repeat visits skip database queries)
17+ - Async favicon preload (cache lookup doesn't block navigation start)
18+ - Progress callback throttling at ~60fps (reduces UI callback overhead)
19+ - Increased WebView pool prewarm from 2 to 4 for faster initial tab creation
20+
21+### Changed
22+- **Custom color picker and confirm dialogs**: Replaced native GTK color picker and confirm dialogs with custom Svelte components using bits-ui AlertDialog. Fixes Wayland compatibility issues where native dialogs would not display properly (fixes #12).
23+- **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit.
24+- **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars).
25+
7 ### Added26 ### Added
27+- **Performance config section**: New `performance` config section with `zoom_cache_size` (default: 256) and `webview_pool_prewarm_count` (default: 4) for tuning. Not exposed in UI but can be set in config file.
8 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.28 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
9 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.29 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
10 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.30 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
M cmd/dumber/main.go +19 -4
@@ -15,11 +15,14 @@ import (
1515 "github.com/bnema/dumber/internal/bootstrap"
1616 "github.com/bnema/dumber/internal/cli/cmd"
1717 "github.com/bnema/dumber/internal/domain/build"
18+ "github.com/bnema/dumber/internal/domain/entity"
1819 "github.com/bnema/dumber/internal/domain/repository"
20+ "github.com/bnema/dumber/internal/infrastructure/cache"
1921 "github.com/bnema/dumber/internal/infrastructure/clipboard"
2022 "github.com/bnema/dumber/internal/infrastructure/config"
2123 "github.com/bnema/dumber/internal/infrastructure/deps"
2224 "github.com/bnema/dumber/internal/infrastructure/favicon"
25+ "github.com/bnema/dumber/internal/infrastructure/idle"
2326 "github.com/bnema/dumber/internal/infrastructure/media"
2427 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2528 "github.com/bnema/dumber/internal/logging"
@@ -108,8 +111,14 @@ func runGUI() int {
108111 themeManager := theme.NewManager(ctx, cfg)
109112 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
110113 repos := createRepositories(db)
111- useCases := createUseCases(repos, cfg.DefaultWebpageZoom)
112- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases)
114+ useCases := createUseCases(repos, cfg)
115+ idleInhibitor := idle.NewPortalInhibitor(ctx)
116+ defer func() {
117+ if idleInhibitor != nil {
118+ _ = idleInhibitor.Close()
119+ }
120+ }()
121+ uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor)
113122
114123 app, err := ui.New(uiDeps)
115124 if err != nil {
@@ -247,7 +256,7 @@ type useCases struct {
247256 favicon *favicon.Service
248257 }
249258
250-func createUseCases(repos *repositories, defaultZoom float64) *useCases {
259+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
251260 const idAlphabetSize = 26
252261 idCounter := uint64(0)
253262 idGenerator := func() string {
@@ -257,13 +266,17 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases {
257266
258267 clipboardAdapter := clipboard.New()
259268 faviconCacheDir, _ := config.GetFaviconCacheDir()
269+ defaultZoom := cfg.DefaultWebpageZoom
270+
271+ // Create LRU cache for zoom levels to avoid database queries on repeat visits
272+ zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
260273
261274 return &useCases{
262275 tabs: usecase.NewManageTabsUseCase(idGenerator),
263276 panes: usecase.NewManagePanesUseCase(idGenerator),
264277 history: usecase.NewSearchHistoryUseCase(repos.history),
265278 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
266- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom),
279+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
267280 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
268281 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
269282 clipboard: clipboardAdapter,
@@ -278,6 +291,7 @@ func buildUIDependencies(
278291 stack *bootstrap.WebKitStack,
279292 repos *repositories,
280293 uc *useCases,
294+ idleInhibitor port.IdleInhibitor,
281295 ) *ui.Dependencies {
282296 return &ui.Dependencies{
283297 Ctx: ctx,
@@ -302,5 +316,6 @@ func buildUIDependencies(
302316 CopyURLUC: uc.copyURL,
303317 Clipboard: uc.clipboard,
304318 FaviconService: uc.favicon,
319+ IdleInhibitor: idleInhibitor,
305320 }
306321 }
@@ -15,11 +15,14 @@ import (
15 "github.com/bnema/dumber/internal/bootstrap"15 "github.com/bnema/dumber/internal/bootstrap"
16 "github.com/bnema/dumber/internal/cli/cmd"16 "github.com/bnema/dumber/internal/cli/cmd"
17 "github.com/bnema/dumber/internal/domain/build"17 "github.com/bnema/dumber/internal/domain/build"
18+ "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/domain/repository"19 "github.com/bnema/dumber/internal/domain/repository"
20+ "github.com/bnema/dumber/internal/infrastructure/cache"
19 "github.com/bnema/dumber/internal/infrastructure/clipboard"21 "github.com/bnema/dumber/internal/infrastructure/clipboard"
20 "github.com/bnema/dumber/internal/infrastructure/config"22 "github.com/bnema/dumber/internal/infrastructure/config"
21 "github.com/bnema/dumber/internal/infrastructure/deps"23 "github.com/bnema/dumber/internal/infrastructure/deps"
22 "github.com/bnema/dumber/internal/infrastructure/favicon"24 "github.com/bnema/dumber/internal/infrastructure/favicon"
25+ "github.com/bnema/dumber/internal/infrastructure/idle"
23 "github.com/bnema/dumber/internal/infrastructure/media"26 "github.com/bnema/dumber/internal/infrastructure/media"
24 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"27 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
25 "github.com/bnema/dumber/internal/logging"28 "github.com/bnema/dumber/internal/logging"
@@ -108,8 +111,14 @@ func runGUI() int {
108 themeManager := theme.NewManager(ctx, cfg)111 themeManager := theme.NewManager(ctx, cfg)
109 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)112 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
110 repos := createRepositories(db)113 repos := createRepositories(db)
111- useCases := createUseCases(repos, cfg.DefaultWebpageZoom)114+ useCases := createUseCases(repos, cfg)
112- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases)115+ idleInhibitor := idle.NewPortalInhibitor(ctx)
116+ defer func() {
117+ if idleInhibitor != nil {
118+ _ = idleInhibitor.Close()
119+ }
120+ }()
121+ uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor)
113 122
114 app, err := ui.New(uiDeps)123 app, err := ui.New(uiDeps)
115 if err != nil {124 if err != nil {
@@ -247,7 +256,7 @@ type useCases struct {
247 favicon *favicon.Service256 favicon *favicon.Service
248 }257 }
249 258
250-func createUseCases(repos *repositories, defaultZoom float64) *useCases {259+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
251 const idAlphabetSize = 26260 const idAlphabetSize = 26
252 idCounter := uint64(0)261 idCounter := uint64(0)
253 idGenerator := func() string {262 idGenerator := func() string {
@@ -257,13 +266,17 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases {
257 266
258 clipboardAdapter := clipboard.New()267 clipboardAdapter := clipboard.New()
259 faviconCacheDir, _ := config.GetFaviconCacheDir()268 faviconCacheDir, _ := config.GetFaviconCacheDir()
269+ defaultZoom := cfg.DefaultWebpageZoom
270+
271+ // Create LRU cache for zoom levels to avoid database queries on repeat visits
272+ zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
260 273
261 return &useCases{274 return &useCases{
262 tabs: usecase.NewManageTabsUseCase(idGenerator),275 tabs: usecase.NewManageTabsUseCase(idGenerator),
263 panes: usecase.NewManagePanesUseCase(idGenerator),276 panes: usecase.NewManagePanesUseCase(idGenerator),
264 history: usecase.NewSearchHistoryUseCase(repos.history),277 history: usecase.NewSearchHistoryUseCase(repos.history),
265 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),278 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
266- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom),279+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
267 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),280 navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
268 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),281 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
269 clipboard: clipboardAdapter,282 clipboard: clipboardAdapter,
@@ -278,6 +291,7 @@ func buildUIDependencies(
278 stack *bootstrap.WebKitStack,291 stack *bootstrap.WebKitStack,
279 repos *repositories,292 repos *repositories,
280 uc *useCases,293 uc *useCases,
294+ idleInhibitor port.IdleInhibitor,
281 ) *ui.Dependencies {295 ) *ui.Dependencies {
282 return &ui.Dependencies{296 return &ui.Dependencies{
283 Ctx: ctx,297 Ctx: ctx,
@@ -302,5 +316,6 @@ func buildUIDependencies(
302 CopyURLUC: uc.copyURL,316 CopyURLUC: uc.copyURL,
303 Clipboard: uc.clipboard,317 Clipboard: uc.clipboard,
304 FaviconService: uc.favicon,318 FaviconService: uc.favicon,
319+ IdleInhibitor: idleInhibitor,
305 }320 }
306 }321 }
M docs/CONFIG.md +6 -14
@@ -182,7 +182,7 @@ border = "#363636"
182182 | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |
183183 | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |
184184 | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |
185-| `rendering.gsk_renderer` | string | `"vulkan"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |
185+| `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |
186186 | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |
187187 | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |
188188 | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |
@@ -282,13 +282,11 @@ cancel = ["escape"]
282282 | Key | Type | Default | Valid Values | Description |
283283 |-----|------|---------|--------------|-------------|
284284 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |
285-| `media.prefer_av1` | bool | `true` | - | Prefer AV1 codec when available |
286-| `media.show_diagnostics` | bool | `true` | - | Show media diagnostics warnings at startup |
285+| `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |
286+| `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup |
287287 | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |
288288 | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |
289289 | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |
290-| `media.video_buffer_size_mb` | int | `64` | > 0 | Video buffer size in MB for smoother streaming |
291-| `media.queue_buffer_time_sec` | int | `20` | > 0 | Queue prebuffer time in seconds |
292290
293291 **Hardware decoding modes:**
294292 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000
@@ -307,21 +305,15 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal
307305 - **Intel**: Uses `iHD` driver (modern, for Broadwell+)
308306 - **NVIDIA**: Uses `nvidia` driver with EGL platform
309307
310-**Buffering settings:**
311-- `video_buffer_size_mb`: Controls GStreamer buffer size. Larger buffers reduce rebuffering on bursty streams (Twitch, YouTube). Uses more memory.
312-- `queue_buffer_time_sec`: Controls prebuffer duration. Higher values allow more data to be buffered ahead of playback.
313-
314308 **Example:**
315309 ```toml
316310 [media]
317311 hardware_decoding = "auto" # HW preferred, SW fallback
318-prefer_av1 = true # AV1 is most efficient codec
319-show_diagnostics = true # Log warnings if HW accel unavailable
312+prefer_av1 = false # Let site choose codec
313+show_diagnostics = false # Enable for debugging
320314 force_vsync = false # Let compositor handle VSync
321315 gl_rendering_mode = "auto" # GStreamer picks best GL API
322316 gstreamer_debug_level = 0 # Increase to 3-5 for debugging
323-video_buffer_size_mb = 64 # 64 MB buffer for smooth streaming
324-queue_buffer_time_sec = 20 # 20 seconds prebuffer
325317 ```
326318
327319 **Diagnostics CLI:**
@@ -368,7 +360,7 @@ All config values can be overridden via environment variables with the prefix `D
368360 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
369361
370362 # Rendering (examples)
371-# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan"
363+# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto"
372364 DUMBER_RENDERING_MODE=cpu
373365 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
374366 # DUMBER_RENDERING_GSK_RENDERER=opengl
@@ -182,7 +182,7 @@ border = "#363636"
182 | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |182 | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |
183 | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |183 | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |
184 | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |184 | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |
185-| `rendering.gsk_renderer` | string | `"vulkan"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |185+| `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |
186 | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |186 | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |
187 | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |187 | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |
188 | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |188 | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |
@@ -282,13 +282,11 @@ cancel = ["escape"]
282 | Key | Type | Default | Valid Values | Description |282 | Key | Type | Default | Valid Values | Description |
283 |-----|------|---------|--------------|-------------|283 |-----|------|---------|--------------|-------------|
284 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |284 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |
285-| `media.prefer_av1` | bool | `true` | - | Prefer AV1 codec when available |285+| `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |
286-| `media.show_diagnostics` | bool | `true` | - | Show media diagnostics warnings at startup |286+| `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup |
287 | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |287 | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |
288 | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |288 | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |
289 | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |289 | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |
290-| `media.video_buffer_size_mb` | int | `64` | > 0 | Video buffer size in MB for smoother streaming |
291-| `media.queue_buffer_time_sec` | int | `20` | > 0 | Queue prebuffer time in seconds |
292 290
293 **Hardware decoding modes:**291 **Hardware decoding modes:**
294 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000292 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000
@@ -307,21 +305,15 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal
307 - **Intel**: Uses `iHD` driver (modern, for Broadwell+)305 - **Intel**: Uses `iHD` driver (modern, for Broadwell+)
308 - **NVIDIA**: Uses `nvidia` driver with EGL platform306 - **NVIDIA**: Uses `nvidia` driver with EGL platform
309 307
310-**Buffering settings:**
311-- `video_buffer_size_mb`: Controls GStreamer buffer size. Larger buffers reduce rebuffering on bursty streams (Twitch, YouTube). Uses more memory.
312-- `queue_buffer_time_sec`: Controls prebuffer duration. Higher values allow more data to be buffered ahead of playback.
313-
314 **Example:**308 **Example:**
315 ```toml309 ```toml
316 [media]310 [media]
317 hardware_decoding = "auto" # HW preferred, SW fallback311 hardware_decoding = "auto" # HW preferred, SW fallback
318-prefer_av1 = true # AV1 is most efficient codec312+prefer_av1 = false # Let site choose codec
319-show_diagnostics = true # Log warnings if HW accel unavailable313+show_diagnostics = false # Enable for debugging
320 force_vsync = false # Let compositor handle VSync314 force_vsync = false # Let compositor handle VSync
321 gl_rendering_mode = "auto" # GStreamer picks best GL API315 gl_rendering_mode = "auto" # GStreamer picks best GL API
322 gstreamer_debug_level = 0 # Increase to 3-5 for debugging316 gstreamer_debug_level = 0 # Increase to 3-5 for debugging
323-video_buffer_size_mb = 64 # 64 MB buffer for smooth streaming
324-queue_buffer_time_sec = 20 # 20 seconds prebuffer
325 ```317 ```
326 318
327 **Diagnostics CLI:**319 **Diagnostics CLI:**
@@ -368,7 +360,7 @@ All config values can be overridden via environment variables with the prefix `D
368 DUMBER_DATABASE_PATH=/custom/path/db.sqlite360 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
369 361
370 # Rendering (examples)362 # Rendering (examples)
371-# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan"363+# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto"
372 DUMBER_RENDERING_MODE=cpu364 DUMBER_RENDERING_MODE=cpu
373 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true365 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
374 # DUMBER_RENDERING_GSK_RENDERER=opengl366 # DUMBER_RENDERING_GSK_RENDERER=opengl
M go.mod +1 -0
@@ -30,6 +30,7 @@ require (
3030 github.com/davecgh/go-spew v1.1.1 // indirect
3131 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3232 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33+ github.com/godbus/dbus/v5 v5.2.1 // indirect
3334 github.com/inconshreveable/mousetrap v1.1.0 // indirect
3435 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
3536 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -30,6 +30,7 @@ require (
30 github.com/davecgh/go-spew v1.1.1 // indirect30 github.com/davecgh/go-spew v1.1.1 // indirect
31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33+ github.com/godbus/dbus/v5 v5.2.1 // indirect
33 github.com/inconshreveable/mousetrap v1.1.0 // indirect34 github.com/inconshreveable/mousetrap v1.1.0 // indirect
34 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect35 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
35 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect36 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
M go.sum +2 -0
@@ -39,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8
3939 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
4040 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
4141 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
42+github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk=
43+github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
4244 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4345 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
4446 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -39,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8
39 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=39 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
40 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=40 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
41 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=41 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
42+github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk=
43+github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
42 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=44 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
43 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=45 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
44 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=46 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
A internal/application/port/cache.go +19 -0
@@ -0,0 +1,19 @@
1+package port
2+
3+// Cache is a generic cache interface for storing key-value pairs.
4+// Implementations should be thread-safe.
5+type Cache[K comparable, V any] interface {
6+ // Get retrieves a value by key. Returns the value and true if found,
7+ // or the zero value and false if not found.
8+ Get(key K) (V, bool)
9+
10+ // Set stores a value for the given key. If the cache is at capacity,
11+ // the least recently used entry may be evicted.
12+ Set(key K, value V)
13+
14+ // Remove deletes a key from the cache.
15+ Remove(key K)
16+
17+ // Len returns the number of items currently in the cache.
18+ Len() int
19+}
@@ -0,0 +1,19 @@
1+package port
2+
3+// Cache is a generic cache interface for storing key-value pairs.
4+// Implementations should be thread-safe.
5+type Cache[K comparable, V any] interface {
6+ // Get retrieves a value by key. Returns the value and true if found,
7+ // or the zero value and false if not found.
8+ Get(key K) (V, bool)
9+
10+ // Set stores a value for the given key. If the cache is at capacity,
11+ // the least recently used entry may be evicted.
12+ Set(key K, value V)
13+
14+ // Remove deletes a key from the cache.
15+ Remove(key K)
16+
17+ // Len returns the number of items currently in the cache.
18+ Len() int
19+}
A internal/application/port/idle_inhibit.go +22 -0
@@ -0,0 +1,22 @@
1+package port
2+
3+import "context"
4+
5+// IdleInhibitor prevents system idle/screensaver during fullscreen video playback.
6+// Implementations use refcounting internally - multiple Inhibit calls require
7+// matching Uninhibit calls before inhibition is released.
8+type IdleInhibitor interface {
9+ // Inhibit increments the inhibit refcount. First call activates inhibition.
10+ // Safe to call multiple times (refcounted).
11+ Inhibit(ctx context.Context, reason string) error
12+
13+ // Uninhibit decrements the refcount. When zero, releases inhibition.
14+ // Safe to call even if not currently inhibited (no-op).
15+ Uninhibit(ctx context.Context) error
16+
17+ // IsInhibited returns true if currently inhibiting idle.
18+ IsInhibited() bool
19+
20+ // Close releases any held resources. Should be called on application shutdown.
21+ Close() error
22+}
@@ -0,0 +1,22 @@
1+package port
2+
3+import "context"
4+
5+// IdleInhibitor prevents system idle/screensaver during fullscreen video playback.
6+// Implementations use refcounting internally - multiple Inhibit calls require
7+// matching Uninhibit calls before inhibition is released.
8+type IdleInhibitor interface {
9+ // Inhibit increments the inhibit refcount. First call activates inhibition.
10+ // Safe to call multiple times (refcounted).
11+ Inhibit(ctx context.Context, reason string) error
12+
13+ // Uninhibit decrements the refcount. When zero, releases inhibition.
14+ // Safe to call even if not currently inhibited (no-op).
15+ Uninhibit(ctx context.Context) error
16+
17+ // IsInhibited returns true if currently inhibiting idle.
18+ IsInhibited() bool
19+
20+ // Close releases any held resources. Should be called on application shutdown.
21+ Close() error
22+}
M internal/application/usecase/manage_zoom.go +45 -2
@@ -13,20 +13,28 @@ import (
1313 )
1414
1515 // ManageZoomUseCase handles per-domain zoom level operations.
16+// It uses an LRU cache to avoid database queries on every navigation.
1617 type ManageZoomUseCase struct {
1718 zoomRepo repository.ZoomRepository
1819 defaultZoom float64
20+ cache port.Cache[string, *entity.ZoomLevel]
1921 }
2022
2123 // NewManageZoomUseCase creates a new zoom management use case.
2224 // defaultZoom is the zoom level to use when resetting (typically from config).
23-func NewManageZoomUseCase(zoomRepo repository.ZoomRepository, defaultZoom float64) *ManageZoomUseCase {
25+// cache is an LRU cache for zoom levels to avoid database queries on repeat visits.
26+func NewManageZoomUseCase(
27+ zoomRepo repository.ZoomRepository,
28+ defaultZoom float64,
29+ cache port.Cache[string, *entity.ZoomLevel],
30+) *ManageZoomUseCase {
2431 if defaultZoom <= 0 {
2532 defaultZoom = entity.ZoomDefault
2633 }
2734 return &ManageZoomUseCase{
2835 zoomRepo: zoomRepo,
2936 defaultZoom: defaultZoom,
37+ cache: cache,
3038 }
3139 }
3240
@@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 {
3745
3846 // GetZoom retrieves the zoom level for a domain.
3947 // Returns the configured default zoom level if none is set.
48+// Uses LRU cache to avoid database queries on repeat visits.
4049 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
4150 log := logging.FromContext(ctx)
42- log.Debug().Str("domain", domain).Msg("getting zoom level")
4351
52+ // Check cache first (fast path - no I/O)
53+ if uc.cache != nil {
54+ if cached, ok := uc.cache.Get(domain); ok {
55+ log.Debug().Str("domain", domain).Float64("zoom", cached.ZoomFactor).Msg("zoom level from cache")
56+ return cached, nil
57+ }
58+ }
59+
60+ // Cache miss - fetch from database
61+ log.Debug().Str("domain", domain).Msg("zoom cache miss, querying database")
4462 zoom, err := uc.zoomRepo.Get(ctx, domain)
4563 if err != nil {
4664 return nil, fmt.Errorf("failed to get zoom level: %w", err)
@@ -51,6 +69,11 @@ func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entit
5169 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
5270 }
5371
72+ // Cache for future lookups
73+ if uc.cache != nil {
74+ uc.cache.Set(domain, zoom)
75+ }
76+
5477 return zoom, nil
5578 }
5679
@@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor
6487 return fmt.Errorf("failed to set zoom level: %w", err)
6588 }
6689
90+ // Update cache
91+ if uc.cache != nil {
92+ uc.cache.Set(domain, zoom)
93+ }
94+
6795 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
6896 return nil
6997 }
@@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error
77105 return fmt.Errorf("failed to reset zoom level: %w", err)
78106 }
79107
108+ // Invalidate cache
109+ if uc.cache != nil {
110+ uc.cache.Remove(domain)
111+ }
112+
80113 log.Info().Str("domain", domain).Msg("zoom level reset to default")
81114 return nil
82115 }
@@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current
98131 return nil, fmt.Errorf("failed to save zoom level: %w", err)
99132 }
100133
134+ // Update cache
135+ if uc.cache != nil {
136+ uc.cache.Set(domain, zoom)
137+ }
138+
101139 return zoom, nil
102140 }
103141
@@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current
118156 return nil, fmt.Errorf("failed to save zoom level: %w", err)
119157 }
120158
159+ // Update cache
160+ if uc.cache != nil {
161+ uc.cache.Set(domain, zoom)
162+ }
163+
121164 return zoom, nil
122165 }
123166
@@ -13,20 +13,28 @@ import (
13 )13 )
14 14
15 // ManageZoomUseCase handles per-domain zoom level operations.15 // ManageZoomUseCase handles per-domain zoom level operations.
16+// It uses an LRU cache to avoid database queries on every navigation.
16 type ManageZoomUseCase struct {17 type ManageZoomUseCase struct {
17 zoomRepo repository.ZoomRepository18 zoomRepo repository.ZoomRepository
18 defaultZoom float6419 defaultZoom float64
20+ cache port.Cache[string, *entity.ZoomLevel]
19 }21 }
20 22
21 // NewManageZoomUseCase creates a new zoom management use case.23 // NewManageZoomUseCase creates a new zoom management use case.
22 // defaultZoom is the zoom level to use when resetting (typically from config).24 // defaultZoom is the zoom level to use when resetting (typically from config).
23-func NewManageZoomUseCase(zoomRepo repository.ZoomRepository, defaultZoom float64) *ManageZoomUseCase {25+// cache is an LRU cache for zoom levels to avoid database queries on repeat visits.
26+func NewManageZoomUseCase(
27+ zoomRepo repository.ZoomRepository,
28+ defaultZoom float64,
29+ cache port.Cache[string, *entity.ZoomLevel],
30+) *ManageZoomUseCase {
24 if defaultZoom <= 0 {31 if defaultZoom <= 0 {
25 defaultZoom = entity.ZoomDefault32 defaultZoom = entity.ZoomDefault
26 }33 }
27 return &ManageZoomUseCase{34 return &ManageZoomUseCase{
28 zoomRepo: zoomRepo,35 zoomRepo: zoomRepo,
29 defaultZoom: defaultZoom,36 defaultZoom: defaultZoom,
37+ cache: cache,
30 }38 }
31 }39 }
32 40
@@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 {
37 45
38 // GetZoom retrieves the zoom level for a domain.46 // GetZoom retrieves the zoom level for a domain.
39 // Returns the configured default zoom level if none is set.47 // Returns the configured default zoom level if none is set.
48+// Uses LRU cache to avoid database queries on repeat visits.
40 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {49 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
41 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
42- log.Debug().Str("domain", domain).Msg("getting zoom level")
43 51
52+ // Check cache first (fast path - no I/O)
53+ if uc.cache != nil {
54+ if cached, ok := uc.cache.Get(domain); ok {
55+ log.Debug().Str("domain", domain).Float64("zoom", cached.ZoomFactor).Msg("zoom level from cache")
56+ return cached, nil
57+ }
58+ }
59+
60+ // Cache miss - fetch from database
61+ log.Debug().Str("domain", domain).Msg("zoom cache miss, querying database")
44 zoom, err := uc.zoomRepo.Get(ctx, domain)62 zoom, err := uc.zoomRepo.Get(ctx, domain)
45 if err != nil {63 if err != nil {
46 return nil, fmt.Errorf("failed to get zoom level: %w", err)64 return nil, fmt.Errorf("failed to get zoom level: %w", err)
@@ -51,6 +69,11 @@ func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entit
51 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")69 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
52 }70 }
53 71
72+ // Cache for future lookups
73+ if uc.cache != nil {
74+ uc.cache.Set(domain, zoom)
75+ }
76+
54 return zoom, nil77 return zoom, nil
55 }78 }
56 79
@@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor
64 return fmt.Errorf("failed to set zoom level: %w", err)87 return fmt.Errorf("failed to set zoom level: %w", err)
65 }88 }
66 89
90+ // Update cache
91+ if uc.cache != nil {
92+ uc.cache.Set(domain, zoom)
93+ }
94+
67 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")95 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
68 return nil96 return nil
69 }97 }
@@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error
77 return fmt.Errorf("failed to reset zoom level: %w", err)105 return fmt.Errorf("failed to reset zoom level: %w", err)
78 }106 }
79 107
108+ // Invalidate cache
109+ if uc.cache != nil {
110+ uc.cache.Remove(domain)
111+ }
112+
80 log.Info().Str("domain", domain).Msg("zoom level reset to default")113 log.Info().Str("domain", domain).Msg("zoom level reset to default")
81 return nil114 return nil
82 }115 }
@@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current
98 return nil, fmt.Errorf("failed to save zoom level: %w", err)131 return nil, fmt.Errorf("failed to save zoom level: %w", err)
99 }132 }
100 133
134+ // Update cache
135+ if uc.cache != nil {
136+ uc.cache.Set(domain, zoom)
137+ }
138+
101 return zoom, nil139 return zoom, nil
102 }140 }
103 141
@@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current
118 return nil, fmt.Errorf("failed to save zoom level: %w", err)156 return nil, fmt.Errorf("failed to save zoom level: %w", err)
119 }157 }
120 158
159+ // Update cache
160+ if uc.cache != nil {
161+ uc.cache.Set(domain, zoom)
162+ }
163+
121 return zoom, nil164 return zoom, nil
122 }165 }
123 166
M internal/application/usecase/navigate.go +92 -11
@@ -13,12 +13,30 @@ import (
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
16+const (
17+ // historyQueueSize is the buffer size for the async history queue.
18+ // If the queue is full, new records are dropped with a warning.
19+ historyQueueSize = 100
20+)
21+
22+// historyRecord holds data for async history recording.
23+type historyRecord struct {
24+ url string
25+ timestamp time.Time
26+}
27+
1628 // NavigateUseCase handles URL navigation with history recording and zoom application.
1729 type NavigateUseCase struct {
1830 historyRepo repository.HistoryRepository
1931 zoomRepo repository.ZoomRepository
2032 defaultZoom float64
2133 recentVisits sync.Map // key: url, value: time.Time - for deduplication
34+
35+ // Async history recording
36+ historyQueue chan historyRecord
37+ done chan struct{}
38+ wg sync.WaitGroup
39+ ctx context.Context // Base context for background worker
2240 }
2341
2442 // NewNavigateUseCase creates a new navigation use case.
@@ -31,11 +49,28 @@ func NewNavigateUseCase(
3149 if defaultZoom <= 0 {
3250 defaultZoom = entity.ZoomDefault
3351 }
34- return &NavigateUseCase{
35- historyRepo: historyRepo,
36- zoomRepo: zoomRepo,
37- defaultZoom: defaultZoom,
52+
53+ uc := &NavigateUseCase{
54+ historyRepo: historyRepo,
55+ zoomRepo: zoomRepo,
56+ defaultZoom: defaultZoom,
57+ historyQueue: make(chan historyRecord, historyQueueSize),
58+ done: make(chan struct{}),
59+ ctx: context.Background(),
3860 }
61+
62+ // Start background history worker
63+ uc.wg.Add(1)
64+ go uc.historyWorker()
65+
66+ return uc
67+}
68+
69+// Close shuts down the background history worker and drains any pending records.
70+// This should be called when the application is shutting down.
71+func (uc *NavigateUseCase) Close() {
72+ close(uc.done)
73+ uc.wg.Wait()
3974 }
4075
4176 // NavigateInput contains parameters for navigation.
@@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
79114 // This prevents inflation from redirects and rapid navigation.
80115 const historyDeduplicationWindow = 2 * time.Second
81116
82-// RecordHistory saves or updates the history entry.
117+// RecordHistory queues a history entry for async recording.
118+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
83119 // Should be called on LoadCommitted when URI is guaranteed correct.
84120 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85121 log := logging.FromContext(ctx)
@@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
88124 url = normalizeURLForHistory(url)
89125
90126 // Time-based deduplication: skip if same URL was recorded recently
127+ // This check is fast (sync.Map lookup) and stays on the main thread
91128 now := time.Now()
92129 if lastTime, ok := uc.recentVisits.Load(url); ok {
93130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
@@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
97134 }
98135 uc.recentVisits.Store(url, now)
99136
137+ // Non-blocking send to async queue
138+ select {
139+ case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
140+ log.Debug().Str("url", url).Msg("history record queued")
141+ default:
142+ // Queue full - log warning but don't block navigation
143+ log.Warn().Str("url", url).Msg("history queue full, dropping record")
144+ }
145+}
146+
147+// historyWorker is a background goroutine that processes history records.
148+// It drains the queue and persists records to the database without blocking the UI.
149+func (uc *NavigateUseCase) historyWorker() {
150+ defer uc.wg.Done()
151+
152+ log := logging.FromContext(uc.ctx).With().
153+ Str("component", "history-worker").
154+ Logger()
155+
156+ for {
157+ select {
158+ case record := <-uc.historyQueue:
159+ uc.persistHistory(uc.ctx, record)
160+ case <-uc.done:
161+ // Drain remaining records before shutdown
162+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
163+ for {
164+ select {
165+ case record := <-uc.historyQueue:
166+ uc.persistHistory(uc.ctx, record)
167+ default:
168+ log.Debug().Msg("history worker shutdown complete")
169+ return
170+ }
171+ }
172+ }
173+ }
174+}
175+
176+// persistHistory writes a history record to the database.
177+// Called from the background worker goroutine.
178+func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
179+ log := logging.FromContext(ctx)
180+
100181 // Check if entry exists
101- existing, err := uc.historyRepo.FindByURL(ctx, url)
182+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
102183 if err != nil {
103- log.Warn().Err(err).Str("url", url).Msg("failed to check history")
184+ log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
104185 return
105186 }
106187
107188 if existing != nil {
108189 // Increment visit count
109- if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
110- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
190+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
191+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
111192 }
112193 } else {
113194 // Create new entry
114- entry := entity.NewHistoryEntry(url, "")
195+ entry := entity.NewHistoryEntry(record.url, "")
115196 if err := uc.historyRepo.Save(ctx, entry); err != nil {
116- log.Warn().Err(err).Str("url", url).Msg("failed to save history")
197+ log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
117198 }
118199 }
119200 }
@@ -13,12 +13,30 @@ import (
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 )14 )
15 15
16+const (
17+ // historyQueueSize is the buffer size for the async history queue.
18+ // If the queue is full, new records are dropped with a warning.
19+ historyQueueSize = 100
20+)
21+
22+// historyRecord holds data for async history recording.
23+type historyRecord struct {
24+ url string
25+ timestamp time.Time
26+}
27+
16 // NavigateUseCase handles URL navigation with history recording and zoom application.28 // NavigateUseCase handles URL navigation with history recording and zoom application.
17 type NavigateUseCase struct {29 type NavigateUseCase struct {
18 historyRepo repository.HistoryRepository30 historyRepo repository.HistoryRepository
19 zoomRepo repository.ZoomRepository31 zoomRepo repository.ZoomRepository
20 defaultZoom float6432 defaultZoom float64
21 recentVisits sync.Map // key: url, value: time.Time - for deduplication33 recentVisits sync.Map // key: url, value: time.Time - for deduplication
34+
35+ // Async history recording
36+ historyQueue chan historyRecord
37+ done chan struct{}
38+ wg sync.WaitGroup
39+ ctx context.Context // Base context for background worker
22 }40 }
23 41
24 // NewNavigateUseCase creates a new navigation use case.42 // NewNavigateUseCase creates a new navigation use case.
@@ -31,11 +49,28 @@ func NewNavigateUseCase(
31 if defaultZoom <= 0 {49 if defaultZoom <= 0 {
32 defaultZoom = entity.ZoomDefault50 defaultZoom = entity.ZoomDefault
33 }51 }
34- return &NavigateUseCase{52+
35- historyRepo: historyRepo,53+ uc := &NavigateUseCase{
36- zoomRepo: zoomRepo,54+ historyRepo: historyRepo,
37- defaultZoom: defaultZoom,55+ zoomRepo: zoomRepo,
56+ defaultZoom: defaultZoom,
57+ historyQueue: make(chan historyRecord, historyQueueSize),
58+ done: make(chan struct{}),
59+ ctx: context.Background(),
38 }60 }
61+
62+ // Start background history worker
63+ uc.wg.Add(1)
64+ go uc.historyWorker()
65+
66+ return uc
67+}
68+
69+// Close shuts down the background history worker and drains any pending records.
70+// This should be called when the application is shutting down.
71+func (uc *NavigateUseCase) Close() {
72+ close(uc.done)
73+ uc.wg.Wait()
39 }74 }
40 75
41 // NavigateInput contains parameters for navigation.76 // NavigateInput contains parameters for navigation.
@@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
79 // This prevents inflation from redirects and rapid navigation.114 // This prevents inflation from redirects and rapid navigation.
80 const historyDeduplicationWindow = 2 * time.Second115 const historyDeduplicationWindow = 2 * time.Second
81 116
82-// RecordHistory saves or updates the history entry.117+// RecordHistory queues a history entry for async recording.
118+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
83 // Should be called on LoadCommitted when URI is guaranteed correct.119 // Should be called on LoadCommitted when URI is guaranteed correct.
84 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {120 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85 log := logging.FromContext(ctx)121 log := logging.FromContext(ctx)
@@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
88 url = normalizeURLForHistory(url)124 url = normalizeURLForHistory(url)
89 125
90 // Time-based deduplication: skip if same URL was recorded recently126 // Time-based deduplication: skip if same URL was recorded recently
127+ // This check is fast (sync.Map lookup) and stays on the main thread
91 now := time.Now()128 now := time.Now()
92 if lastTime, ok := uc.recentVisits.Load(url); ok {129 if lastTime, ok := uc.recentVisits.Load(url); ok {
93 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
@@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
97 }134 }
98 uc.recentVisits.Store(url, now)135 uc.recentVisits.Store(url, now)
99 136
137+ // Non-blocking send to async queue
138+ select {
139+ case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
140+ log.Debug().Str("url", url).Msg("history record queued")
141+ default:
142+ // Queue full - log warning but don't block navigation
143+ log.Warn().Str("url", url).Msg("history queue full, dropping record")
144+ }
145+}
146+
147+// historyWorker is a background goroutine that processes history records.
148+// It drains the queue and persists records to the database without blocking the UI.
149+func (uc *NavigateUseCase) historyWorker() {
150+ defer uc.wg.Done()
151+
152+ log := logging.FromContext(uc.ctx).With().
153+ Str("component", "history-worker").
154+ Logger()
155+
156+ for {
157+ select {
158+ case record := <-uc.historyQueue:
159+ uc.persistHistory(uc.ctx, record)
160+ case <-uc.done:
161+ // Drain remaining records before shutdown
162+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
163+ for {
164+ select {
165+ case record := <-uc.historyQueue:
166+ uc.persistHistory(uc.ctx, record)
167+ default:
168+ log.Debug().Msg("history worker shutdown complete")
169+ return
170+ }
171+ }
172+ }
173+ }
174+}
175+
176+// persistHistory writes a history record to the database.
177+// Called from the background worker goroutine.
178+func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
179+ log := logging.FromContext(ctx)
180+
100 // Check if entry exists181 // Check if entry exists
101- existing, err := uc.historyRepo.FindByURL(ctx, url)182+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
102 if err != nil {183 if err != nil {
103- log.Warn().Err(err).Str("url", url).Msg("failed to check history")184+ log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
104 return185 return
105 }186 }
106 187
107 if existing != nil {188 if existing != nil {
108 // Increment visit count189 // Increment visit count
109- if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {190+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
110- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")191+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
111 }192 }
112 } else {193 } else {
113 // Create new entry194 // Create new entry
114- entry := entity.NewHistoryEntry(url, "")195+ entry := entity.NewHistoryEntry(record.url, "")
115 if err := uc.historyRepo.Save(ctx, entry); err != nil {196 if err := uc.historyRepo.Save(ctx, entry); err != nil {
116- log.Warn().Err(err).Str("url", url).Msg("failed to save history")197+ log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
117 }198 }
118 }199 }
119 }200 }
M internal/bootstrap/webkit_stack.go +4 -0
@@ -103,6 +103,10 @@ func BuildWebKitStack(
103103
104104 messageRouter := webkit.NewMessageRouter(ctx)
105105 poolCfg := webkit.DefaultPoolConfig()
106+ // Override prewarm count from config if set
107+ if cfg.Performance.WebViewPoolPrewarmCount > 0 {
108+ poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109+ }
106110 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
107111
108112 if filterManager != nil {
@@ -103,6 +103,10 @@ func BuildWebKitStack(
103 103
104 messageRouter := webkit.NewMessageRouter(ctx)104 messageRouter := webkit.NewMessageRouter(ctx)
105 poolCfg := webkit.DefaultPoolConfig()105 poolCfg := webkit.DefaultPoolConfig()
106+ // Override prewarm count from config if set
107+ if cfg.Performance.WebViewPoolPrewarmCount > 0 {
108+ poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109+ }
106 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)110 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
107 111
108 if filterManager != nil {112 if filterManager != nil {
A internal/infrastructure/cache/lru.go +109 -0
@@ -0,0 +1,109 @@
1+// Package cache provides cache implementations for the application layer.
2+package cache
3+
4+import (
5+ "container/list"
6+ "sync"
7+)
8+
9+// LRU is a thread-safe LRU (Least Recently Used) cache with a fixed capacity.
10+// It implements port.Cache[K, V].
11+//
12+// When the cache reaches capacity, the least recently accessed entry is evicted
13+// to make room for new entries. Both Get and Set operations mark an entry as
14+// recently used.
15+type LRU[K comparable, V any] struct {
16+ capacity int
17+ mu sync.RWMutex
18+ items map[K]*list.Element
19+ order *list.List // Front = most recent, Back = least recent
20+}
21+
22+// entry holds a key-value pair in the LRU cache.
23+type entry[K comparable, V any] struct {
24+ key K
25+ value V
26+}
27+
28+// NewLRU creates a new LRU cache with the given capacity.
29+// Capacity must be positive; if zero or negative, a capacity of 1 is used.
30+func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
31+ if capacity <= 0 {
32+ capacity = 1
33+ }
34+ return &LRU[K, V]{
35+ capacity: capacity,
36+ items: make(map[K]*list.Element),
37+ order: list.New(),
38+ }
39+}
40+
41+// Get retrieves a value by key and marks it as recently used.
42+// Returns the value and true if found, or the zero value and false if not found.
43+func (c *LRU[K, V]) Get(key K) (V, bool) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+
47+ if elem, ok := c.items[key]; ok {
48+ c.order.MoveToFront(elem)
49+ return elem.Value.(*entry[K, V]).value, true
50+ }
51+ var zero V
52+ return zero, false
53+}
54+
55+// Set adds or updates a value in the cache.
56+// If the key already exists, its value is updated and it's marked as recently used.
57+// If the cache is at capacity, the least recently used entry is evicted.
58+func (c *LRU[K, V]) Set(key K, value V) {
59+ c.mu.Lock()
60+ defer c.mu.Unlock()
61+
62+ // Update existing entry
63+ if elem, ok := c.items[key]; ok {
64+ c.order.MoveToFront(elem)
65+ elem.Value.(*entry[K, V]).value = value
66+ return
67+ }
68+
69+ // Evict LRU entry if at capacity
70+ if c.order.Len() >= c.capacity {
71+ oldest := c.order.Back()
72+ if oldest != nil {
73+ c.order.Remove(oldest)
74+ delete(c.items, oldest.Value.(*entry[K, V]).key)
75+ }
76+ }
77+
78+ // Add new entry at front (most recent)
79+ elem := c.order.PushFront(&entry[K, V]{key: key, value: value})
80+ c.items[key] = elem
81+}
82+
83+// Remove deletes a key from the cache.
84+// If the key doesn't exist, this is a no-op.
85+func (c *LRU[K, V]) Remove(key K) {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+
89+ if elem, ok := c.items[key]; ok {
90+ c.order.Remove(elem)
91+ delete(c.items, key)
92+ }
93+}
94+
95+// Len returns the number of items currently in the cache.
96+func (c *LRU[K, V]) Len() int {
97+ c.mu.RLock()
98+ defer c.mu.RUnlock()
99+ return c.order.Len()
100+}
101+
102+// Clear removes all items from the cache.
103+func (c *LRU[K, V]) Clear() {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+
107+ c.items = make(map[K]*list.Element)
108+ c.order.Init()
109+}
@@ -0,0 +1,109 @@
1+// Package cache provides cache implementations for the application layer.
2+package cache
3+
4+import (
5+ "container/list"
6+ "sync"
7+)
8+
9+// LRU is a thread-safe LRU (Least Recently Used) cache with a fixed capacity.
10+// It implements port.Cache[K, V].
11+//
12+// When the cache reaches capacity, the least recently accessed entry is evicted
13+// to make room for new entries. Both Get and Set operations mark an entry as
14+// recently used.
15+type LRU[K comparable, V any] struct {
16+ capacity int
17+ mu sync.RWMutex
18+ items map[K]*list.Element
19+ order *list.List // Front = most recent, Back = least recent
20+}
21+
22+// entry holds a key-value pair in the LRU cache.
23+type entry[K comparable, V any] struct {
24+ key K
25+ value V
26+}
27+
28+// NewLRU creates a new LRU cache with the given capacity.
29+// Capacity must be positive; if zero or negative, a capacity of 1 is used.
30+func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
31+ if capacity <= 0 {
32+ capacity = 1
33+ }
34+ return &LRU[K, V]{
35+ capacity: capacity,
36+ items: make(map[K]*list.Element),
37+ order: list.New(),
38+ }
39+}
40+
41+// Get retrieves a value by key and marks it as recently used.
42+// Returns the value and true if found, or the zero value and false if not found.
43+func (c *LRU[K, V]) Get(key K) (V, bool) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+
47+ if elem, ok := c.items[key]; ok {
48+ c.order.MoveToFront(elem)
49+ return elem.Value.(*entry[K, V]).value, true
50+ }
51+ var zero V
52+ return zero, false
53+}
54+
55+// Set adds or updates a value in the cache.
56+// If the key already exists, its value is updated and it's marked as recently used.
57+// If the cache is at capacity, the least recently used entry is evicted.
58+func (c *LRU[K, V]) Set(key K, value V) {
59+ c.mu.Lock()
60+ defer c.mu.Unlock()
61+
62+ // Update existing entry
63+ if elem, ok := c.items[key]; ok {
64+ c.order.MoveToFront(elem)
65+ elem.Value.(*entry[K, V]).value = value
66+ return
67+ }
68+
69+ // Evict LRU entry if at capacity
70+ if c.order.Len() >= c.capacity {
71+ oldest := c.order.Back()
72+ if oldest != nil {
73+ c.order.Remove(oldest)
74+ delete(c.items, oldest.Value.(*entry[K, V]).key)
75+ }
76+ }
77+
78+ // Add new entry at front (most recent)
79+ elem := c.order.PushFront(&entry[K, V]{key: key, value: value})
80+ c.items[key] = elem
81+}
82+
83+// Remove deletes a key from the cache.
84+// If the key doesn't exist, this is a no-op.
85+func (c *LRU[K, V]) Remove(key K) {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+
89+ if elem, ok := c.items[key]; ok {
90+ c.order.Remove(elem)
91+ delete(c.items, key)
92+ }
93+}
94+
95+// Len returns the number of items currently in the cache.
96+func (c *LRU[K, V]) Len() int {
97+ c.mu.RLock()
98+ defer c.mu.RUnlock()
99+ return c.order.Len()
100+}
101+
102+// Clear removes all items from the cache.
103+func (c *LRU[K, V]) Clear() {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+
107+ c.items = make(map[K]*list.Element)
108+ c.order.Init()
109+}
A internal/infrastructure/cache/lru_test.go +212 -0
@@ -0,0 +1,212 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestLRU_BasicOperations(t *testing.T) {
12+ cache := NewLRU[string, int](3)
13+
14+ // Test Set and Get
15+ cache.Set("a", 1)
16+ cache.Set("b", 2)
17+ cache.Set("c", 3)
18+
19+ val, ok := cache.Get("a")
20+ assert.True(t, ok)
21+ assert.Equal(t, 1, val)
22+
23+ val, ok = cache.Get("b")
24+ assert.True(t, ok)
25+ assert.Equal(t, 2, val)
26+
27+ // Test not found
28+ val, ok = cache.Get("notfound")
29+ assert.False(t, ok)
30+ assert.Equal(t, 0, val)
31+
32+ assert.Equal(t, 3, cache.Len())
33+}
34+
35+func TestLRU_Eviction(t *testing.T) {
36+ cache := NewLRU[string, int](2)
37+
38+ cache.Set("a", 1)
39+ cache.Set("b", 2)
40+ // Cache is now at capacity: [b, a] (b is most recent)
41+
42+ // Adding "c" should evict "a" (least recently used)
43+ cache.Set("c", 3)
44+
45+ _, ok := cache.Get("a")
46+ assert.False(t, ok, "a should have been evicted")
47+
48+ val, ok := cache.Get("b")
49+ assert.True(t, ok)
50+ assert.Equal(t, 2, val)
51+
52+ val, ok = cache.Get("c")
53+ assert.True(t, ok)
54+ assert.Equal(t, 3, val)
55+}
56+
57+func TestLRU_GetUpdatesRecency(t *testing.T) {
58+ cache := NewLRU[string, int](2)
59+
60+ cache.Set("a", 1)
61+ cache.Set("b", 2)
62+ // Order: [b, a]
63+
64+ // Access "a" to make it most recent
65+ cache.Get("a")
66+ // Order: [a, b]
67+
68+ // Adding "c" should now evict "b" (least recently used)
69+ cache.Set("c", 3)
70+
71+ val, ok := cache.Get("a")
72+ assert.True(t, ok, "a should still exist")
73+ assert.Equal(t, 1, val)
74+
75+ _, ok = cache.Get("b")
76+ assert.False(t, ok, "b should have been evicted")
77+}
78+
79+func TestLRU_UpdateExisting(t *testing.T) {
80+ cache := NewLRU[string, int](2)
81+
82+ cache.Set("a", 1)
83+ cache.Set("b", 2)
84+
85+ // Update "a" with new value
86+ cache.Set("a", 100)
87+
88+ val, ok := cache.Get("a")
89+ assert.True(t, ok)
90+ assert.Equal(t, 100, val)
91+
92+ // Cache should still have 2 items (no duplicate)
93+ assert.Equal(t, 2, cache.Len())
94+}
95+
96+func TestLRU_Remove(t *testing.T) {
97+ cache := NewLRU[string, int](3)
98+
99+ cache.Set("a", 1)
100+ cache.Set("b", 2)
101+ cache.Set("c", 3)
102+
103+ cache.Remove("b")
104+
105+ _, ok := cache.Get("b")
106+ assert.False(t, ok)
107+ assert.Equal(t, 2, cache.Len())
108+
109+ // Remove non-existent key should not panic
110+ cache.Remove("notfound")
111+ assert.Equal(t, 2, cache.Len())
112+}
113+
114+func TestLRU_Clear(t *testing.T) {
115+ cache := NewLRU[string, int](3)
116+
117+ cache.Set("a", 1)
118+ cache.Set("b", 2)
119+ cache.Set("c", 3)
120+
121+ cache.Clear()
122+
123+ assert.Equal(t, 0, cache.Len())
124+ _, ok := cache.Get("a")
125+ assert.False(t, ok)
126+}
127+
128+func TestLRU_ZeroCapacity(t *testing.T) {
129+ // Should default to capacity of 1
130+ cache := NewLRU[string, int](0)
131+
132+ cache.Set("a", 1)
133+ val, ok := cache.Get("a")
134+ assert.True(t, ok)
135+ assert.Equal(t, 1, val)
136+
137+ // Adding another should evict first
138+ cache.Set("b", 2)
139+ _, ok = cache.Get("a")
140+ assert.False(t, ok)
141+}
142+
143+func TestLRU_ConcurrentAccess(t *testing.T) {
144+ cache := NewLRU[int, int](100)
145+ var wg sync.WaitGroup
146+
147+ // Concurrent writes
148+ for i := 0; i < 100; i++ {
149+ wg.Add(1)
150+ go func(i int) {
151+ defer wg.Done()
152+ cache.Set(i, i*10)
153+ }(i)
154+ }
155+ wg.Wait()
156+
157+ // Concurrent reads
158+ for i := 0; i < 100; i++ {
159+ wg.Add(1)
160+ go func(i int) {
161+ defer wg.Done()
162+ cache.Get(i)
163+ }(i)
164+ }
165+ wg.Wait()
166+
167+ // Concurrent mixed operations
168+ for i := 0; i < 50; i++ {
169+ wg.Add(3)
170+ go func(i int) {
171+ defer wg.Done()
172+ cache.Set(i+100, i)
173+ }(i)
174+ go func(i int) {
175+ defer wg.Done()
176+ cache.Get(i)
177+ }(i)
178+ go func(i int) {
179+ defer wg.Done()
180+ cache.Remove(i + 50)
181+ }(i)
182+ }
183+ wg.Wait()
184+
185+ // Should not panic and cache should be in valid state
186+ require.LessOrEqual(t, cache.Len(), 100)
187+}
188+
189+func TestLRU_PointerValues(t *testing.T) {
190+ type Data struct {
191+ Name string
192+ Value float64
193+ }
194+
195+ cache := NewLRU[string, *Data](2)
196+
197+ cache.Set("key1", &Data{Name: "test1", Value: 1.5})
198+ cache.Set("key2", &Data{Name: "test2", Value: 2.5})
199+
200+ val, ok := cache.Get("key1")
201+ require.True(t, ok)
202+ assert.Equal(t, "test1", val.Name)
203+ assert.InDelta(t, 1.5, val.Value, 0.001)
204+
205+ // Update pointer value
206+ cache.Set("key1", &Data{Name: "updated", Value: 99.9})
207+
208+ val, ok = cache.Get("key1")
209+ require.True(t, ok)
210+ assert.Equal(t, "updated", val.Name)
211+ assert.InDelta(t, 99.9, val.Value, 0.001)
212+}
@@ -0,0 +1,212 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestLRU_BasicOperations(t *testing.T) {
12+ cache := NewLRU[string, int](3)
13+
14+ // Test Set and Get
15+ cache.Set("a", 1)
16+ cache.Set("b", 2)
17+ cache.Set("c", 3)
18+
19+ val, ok := cache.Get("a")
20+ assert.True(t, ok)
21+ assert.Equal(t, 1, val)
22+
23+ val, ok = cache.Get("b")
24+ assert.True(t, ok)
25+ assert.Equal(t, 2, val)
26+
27+ // Test not found
28+ val, ok = cache.Get("notfound")
29+ assert.False(t, ok)
30+ assert.Equal(t, 0, val)
31+
32+ assert.Equal(t, 3, cache.Len())
33+}
34+
35+func TestLRU_Eviction(t *testing.T) {
36+ cache := NewLRU[string, int](2)
37+
38+ cache.Set("a", 1)
39+ cache.Set("b", 2)
40+ // Cache is now at capacity: [b, a] (b is most recent)
41+
42+ // Adding "c" should evict "a" (least recently used)
43+ cache.Set("c", 3)
44+
45+ _, ok := cache.Get("a")
46+ assert.False(t, ok, "a should have been evicted")
47+
48+ val, ok := cache.Get("b")
49+ assert.True(t, ok)
50+ assert.Equal(t, 2, val)
51+
52+ val, ok = cache.Get("c")
53+ assert.True(t, ok)
54+ assert.Equal(t, 3, val)
55+}
56+
57+func TestLRU_GetUpdatesRecency(t *testing.T) {
58+ cache := NewLRU[string, int](2)
59+
60+ cache.Set("a", 1)
61+ cache.Set("b", 2)
62+ // Order: [b, a]
63+
64+ // Access "a" to make it most recent
65+ cache.Get("a")
66+ // Order: [a, b]
67+
68+ // Adding "c" should now evict "b" (least recently used)
69+ cache.Set("c", 3)
70+
71+ val, ok := cache.Get("a")
72+ assert.True(t, ok, "a should still exist")
73+ assert.Equal(t, 1, val)
74+
75+ _, ok = cache.Get("b")
76+ assert.False(t, ok, "b should have been evicted")
77+}
78+
79+func TestLRU_UpdateExisting(t *testing.T) {
80+ cache := NewLRU[string, int](2)
81+
82+ cache.Set("a", 1)
83+ cache.Set("b", 2)
84+
85+ // Update "a" with new value
86+ cache.Set("a", 100)
87+
88+ val, ok := cache.Get("a")
89+ assert.True(t, ok)
90+ assert.Equal(t, 100, val)
91+
92+ // Cache should still have 2 items (no duplicate)
93+ assert.Equal(t, 2, cache.Len())
94+}
95+
96+func TestLRU_Remove(t *testing.T) {
97+ cache := NewLRU[string, int](3)
98+
99+ cache.Set("a", 1)
100+ cache.Set("b", 2)
101+ cache.Set("c", 3)
102+
103+ cache.Remove("b")
104+
105+ _, ok := cache.Get("b")
106+ assert.False(t, ok)
107+ assert.Equal(t, 2, cache.Len())
108+
109+ // Remove non-existent key should not panic
110+ cache.Remove("notfound")
111+ assert.Equal(t, 2, cache.Len())
112+}
113+
114+func TestLRU_Clear(t *testing.T) {
115+ cache := NewLRU[string, int](3)
116+
117+ cache.Set("a", 1)
118+ cache.Set("b", 2)
119+ cache.Set("c", 3)
120+
121+ cache.Clear()
122+
123+ assert.Equal(t, 0, cache.Len())
124+ _, ok := cache.Get("a")
125+ assert.False(t, ok)
126+}
127+
128+func TestLRU_ZeroCapacity(t *testing.T) {
129+ // Should default to capacity of 1
130+ cache := NewLRU[string, int](0)
131+
132+ cache.Set("a", 1)
133+ val, ok := cache.Get("a")
134+ assert.True(t, ok)
135+ assert.Equal(t, 1, val)
136+
137+ // Adding another should evict first
138+ cache.Set("b", 2)
139+ _, ok = cache.Get("a")
140+ assert.False(t, ok)
141+}
142+
143+func TestLRU_ConcurrentAccess(t *testing.T) {
144+ cache := NewLRU[int, int](100)
145+ var wg sync.WaitGroup
146+
147+ // Concurrent writes
148+ for i := 0; i < 100; i++ {
149+ wg.Add(1)
150+ go func(i int) {
151+ defer wg.Done()
152+ cache.Set(i, i*10)
153+ }(i)
154+ }
155+ wg.Wait()
156+
157+ // Concurrent reads
158+ for i := 0; i < 100; i++ {
159+ wg.Add(1)
160+ go func(i int) {
161+ defer wg.Done()
162+ cache.Get(i)
163+ }(i)
164+ }
165+ wg.Wait()
166+
167+ // Concurrent mixed operations
168+ for i := 0; i < 50; i++ {
169+ wg.Add(3)
170+ go func(i int) {
171+ defer wg.Done()
172+ cache.Set(i+100, i)
173+ }(i)
174+ go func(i int) {
175+ defer wg.Done()
176+ cache.Get(i)
177+ }(i)
178+ go func(i int) {
179+ defer wg.Done()
180+ cache.Remove(i + 50)
181+ }(i)
182+ }
183+ wg.Wait()
184+
185+ // Should not panic and cache should be in valid state
186+ require.LessOrEqual(t, cache.Len(), 100)
187+}
188+
189+func TestLRU_PointerValues(t *testing.T) {
190+ type Data struct {
191+ Name string
192+ Value float64
193+ }
194+
195+ cache := NewLRU[string, *Data](2)
196+
197+ cache.Set("key1", &Data{Name: "test1", Value: 1.5})
198+ cache.Set("key2", &Data{Name: "test2", Value: 2.5})
199+
200+ val, ok := cache.Get("key1")
201+ require.True(t, ok)
202+ assert.Equal(t, "test1", val.Name)
203+ assert.InDelta(t, 1.5, val.Value, 0.001)
204+
205+ // Update pointer value
206+ cache.Set("key1", &Data{Name: "updated", Value: 99.9})
207+
208+ val, ok = cache.Get("key1")
209+ require.True(t, ok)
210+ assert.Equal(t, "updated", val.Name)
211+ assert.InDelta(t, 99.9, val.Value, 0.001)
212+}
M internal/infrastructure/config/defaults.go +13 -5
@@ -42,6 +42,10 @@ const (
4242 // Other styling
4343 defaultTransitionDuration = 120
4444 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
45+
46+ // Performance defaults
47+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
48+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
4549 )
4650
4751 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -117,7 +121,7 @@ func DefaultConfig() *Config {
117121 DisableDMABufRenderer: false,
118122 ForceCompositingMode: false,
119123 DisableCompositingMode: false,
120- GSKRenderer: GSKRendererVulkan,
124+ GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF
121125 DisableMipmaps: false,
122126 PreferGL: false,
123127 DrawCompositingIndicators: false,
@@ -194,17 +198,21 @@ func DefaultConfig() *Config {
194198 },
195199 Media: MediaConfig{
196200 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
197- PreferAV1: true, // AV1 is most efficient codec
198- ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable
201+ PreferAV1: false, // Don't force codec preference, let site choose
202+ ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy
199203 ForceVSync: false, // Let compositor handle VSync
200204 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
201205 GStreamerDebugLevel: 0, // Disabled by default
202- VideoBufferSizeMB: 64, // Larger buffer for bursty streams (Twitch, YouTube)
203- QueueBufferTimeSec: 20, // More prebuffering for smooth playback
206+ VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed
207+ QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed
204208 },
205209 Runtime: RuntimeConfig{
206210 Prefix: "",
207211 },
212+ Performance: PerformanceConfig{
213+ ZoomCacheSize: defaultZoomCacheSize,
214+ WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
215+ },
208216 }
209217 }
210218
@@ -42,6 +42,10 @@ const (
42 // Other styling42 // Other styling
43 defaultTransitionDuration = 12043 defaultTransitionDuration = 120
44 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)44 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
45+
46+ // Performance defaults
47+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
48+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
45 )49 )
46 50
47 // getDefaultLogDir returns the default log directory, falls back to empty string on error51 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -117,7 +121,7 @@ func DefaultConfig() *Config {
117 DisableDMABufRenderer: false,121 DisableDMABufRenderer: false,
118 ForceCompositingMode: false,122 ForceCompositingMode: false,
119 DisableCompositingMode: false,123 DisableCompositingMode: false,
120- GSKRenderer: GSKRendererVulkan,124+ GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF
121 DisableMipmaps: false,125 DisableMipmaps: false,
122 PreferGL: false,126 PreferGL: false,
123 DrawCompositingIndicators: false,127 DrawCompositingIndicators: false,
@@ -194,17 +198,21 @@ func DefaultConfig() *Config {
194 },198 },
195 Media: MediaConfig{199 Media: MediaConfig{
196 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback200 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
197- PreferAV1: true, // AV1 is most efficient codec201+ PreferAV1: false, // Don't force codec preference, let site choose
198- ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable202+ ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy
199 ForceVSync: false, // Let compositor handle VSync203 ForceVSync: false, // Let compositor handle VSync
200 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API204 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
201 GStreamerDebugLevel: 0, // Disabled by default205 GStreamerDebugLevel: 0, // Disabled by default
202- VideoBufferSizeMB: 64, // Larger buffer for bursty streams (Twitch, YouTube)206+ VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed
203- QueueBufferTimeSec: 20, // More prebuffering for smooth playback207+ QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed
204 },208 },
205 Runtime: RuntimeConfig{209 Runtime: RuntimeConfig{
206 Prefix: "",210 Prefix: "",
207 },211 },
212+ Performance: PerformanceConfig{
213+ ZoomCacheSize: defaultZoomCacheSize,
214+ WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
215+ },
208 }216 }
209 }217 }
210 218
M internal/infrastructure/config/schema.go +16 -0
@@ -27,6 +27,8 @@ type Config struct {
2727 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
2828 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
2929 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
30+ // Performance holds internal performance tuning options (not exposed in UI).
31+ Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
3032 }
3133
3234 // RenderingMode selects GPU vs CPU rendering.
@@ -172,6 +174,20 @@ type HistoryConfig struct {
172174 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
173175 }
174176
177+// PerformanceConfig holds internal performance tuning options.
178+// These are not exposed in dumb://config UI but can be set in config file.
179+type PerformanceConfig struct {
180+ // ZoomCacheSize is the number of domain zoom levels to cache in memory.
181+ // Higher values reduce database queries but use more memory.
182+ // Default: 256 domains (~20KB memory)
183+ ZoomCacheSize int `mapstructure:"zoom_cache_size" yaml:"zoom_cache_size" toml:"zoom_cache_size"`
184+
185+ // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
186+ // Higher values speed up initial tab creation but use more memory.
187+ // Default: 4
188+ WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line
189+}
190+
175191 // SearchShortcut represents a search shortcut configuration.
176192 type SearchShortcut struct {
177193 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
@@ -27,6 +27,8 @@ type Config struct {
27 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`27 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
28 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).28 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
29 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`29 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
30+ // Performance holds internal performance tuning options (not exposed in UI).
31+ Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
30 }32 }
31 33
32 // RenderingMode selects GPU vs CPU rendering.34 // RenderingMode selects GPU vs CPU rendering.
@@ -172,6 +174,20 @@ type HistoryConfig struct {
172 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`174 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
173 }175 }
174 176
177+// PerformanceConfig holds internal performance tuning options.
178+// These are not exposed in dumb://config UI but can be set in config file.
179+type PerformanceConfig struct {
180+ // ZoomCacheSize is the number of domain zoom levels to cache in memory.
181+ // Higher values reduce database queries but use more memory.
182+ // Default: 256 domains (~20KB memory)
183+ ZoomCacheSize int `mapstructure:"zoom_cache_size" yaml:"zoom_cache_size" toml:"zoom_cache_size"`
184+
185+ // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
186+ // Higher values speed up initial tab creation but use more memory.
187+ // Default: 4
188+ WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line
189+}
190+
175 // SearchShortcut represents a search shortcut configuration.191 // SearchShortcut represents a search shortcut configuration.
176 type SearchShortcut struct {192 type SearchShortcut struct {
177 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`193 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
M internal/infrastructure/env/manager.go +4 -12
@@ -91,18 +91,10 @@ func (m *Manager) applyGStreamerEnv(settings port.RenderingEnvSettings) {
9191 m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level))
9292 }
9393
94- // Video buffer size
95- if settings.VideoBufferSizeMB > 0 {
96- bufferBytes := settings.VideoBufferSizeMB * 1024 * 1024
97- m.setEnv("GST_BUFFER_SIZE", fmt.Sprintf("%d", bufferBytes))
98- m.setEnv("GST_QUEUE2_MAX_SIZE_BYTES", fmt.Sprintf("%d", bufferBytes))
99- }
100-
101- // Queue buffer time
102- if settings.QueueBufferTimeSec > 0 {
103- bufferTimeNs := settings.QueueBufferTimeSec * 1_000_000_000
104- m.setEnv("GST_QUEUE2_MAX_SIZE_TIME", fmt.Sprintf("%d", bufferTimeNs))
105- }
94+ // NOTE: GST_BUFFER_SIZE, GST_QUEUE2_MAX_SIZE_BYTES, GST_QUEUE2_MAX_SIZE_TIME
95+ // are NOT valid GStreamer environment variables. They are element properties
96+ // that must be set programmatically on individual GStreamer elements.
97+ // WebKitGTK manages its own GStreamer pipeline internally.
10698 }
10799
108100 func (m *Manager) applyGPUSpecificEnv() {
@@ -91,18 +91,10 @@ func (m *Manager) applyGStreamerEnv(settings port.RenderingEnvSettings) {
91 m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level))91 m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level))
92 }92 }
93 93
94- // Video buffer size94+ // NOTE: GST_BUFFER_SIZE, GST_QUEUE2_MAX_SIZE_BYTES, GST_QUEUE2_MAX_SIZE_TIME
95- if settings.VideoBufferSizeMB > 0 {95+ // are NOT valid GStreamer environment variables. They are element properties
96- bufferBytes := settings.VideoBufferSizeMB * 1024 * 102496+ // that must be set programmatically on individual GStreamer elements.
97- m.setEnv("GST_BUFFER_SIZE", fmt.Sprintf("%d", bufferBytes))97+ // WebKitGTK manages its own GStreamer pipeline internally.
98- m.setEnv("GST_QUEUE2_MAX_SIZE_BYTES", fmt.Sprintf("%d", bufferBytes))
99- }
100-
101- // Queue buffer time
102- if settings.QueueBufferTimeSec > 0 {
103- bufferTimeNs := settings.QueueBufferTimeSec * 1_000_000_000
104- m.setEnv("GST_QUEUE2_MAX_SIZE_TIME", fmt.Sprintf("%d", bufferTimeNs))
105- }
106 }98 }
107 99
108 func (m *Manager) applyGPUSpecificEnv() {100 func (m *Manager) applyGPUSpecificEnv() {
A internal/infrastructure/idle/portal_inhibitor.go +191 -0
@@ -0,0 +1,191 @@
1+// Package idle provides system idle/screensaver inhibition via XDG Desktop Portal.
2+package idle
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/godbus/dbus/v5"
12+)
13+
14+const (
15+ portalDest = "org.freedesktop.portal.Desktop"
16+ portalPath = "/org/freedesktop/portal/desktop"
17+ portalInterface = "org.freedesktop.portal.Inhibit"
18+ requestIface = "org.freedesktop.portal.Request"
19+
20+ // Inhibit flags from portal spec
21+ flagLogout = 1
22+ flagUserSwitch = 2
23+ flagSuspend = 4
24+ flagIdle = 8
25+)
26+
27+// Compile-time interface check.
28+var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29+
30+// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31+// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
32+type PortalInhibitor struct {
33+ conn *dbus.Conn
34+ requestPath dbus.ObjectPath // Active inhibit request handle
35+ refcount int
36+ supported bool
37+ mu sync.Mutex
38+}
39+
40+// NewPortalInhibitor creates a new portal-based idle inhibitor.
41+// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
42+func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
43+ log := logging.FromContext(ctx)
44+
45+ inhibitor := &PortalInhibitor{
46+ supported: false,
47+ }
48+
49+ // Connect to session bus
50+ conn, err := dbus.ConnectSessionBus()
51+ if err != nil {
52+ log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
53+ return inhibitor
54+ }
55+ inhibitor.conn = conn
56+
57+ // Check if portal is available
58+ obj := conn.Object(portalDest, portalPath)
59+ var version uint32
60+ err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
61+ portalInterface, "version").Store(&version)
62+ if err != nil {
63+ log.Debug().Err(err).Msg("idle inhibitor: portal not available")
64+ return inhibitor
65+ }
66+
67+ inhibitor.supported = true
68+ log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
69+
70+ return inhibitor
71+}
72+
73+// Inhibit increments the inhibit refcount. First call activates inhibition.
74+func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
75+ log := logging.FromContext(ctx)
76+
77+ p.mu.Lock()
78+ defer p.mu.Unlock()
79+
80+ p.refcount++
81+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
82+
83+ // Already inhibiting
84+ if p.refcount > 1 {
85+ return nil
86+ }
87+
88+ // First inhibit request
89+ if !p.supported || p.conn == nil {
90+ log.Debug().Msg("idle inhibitor: not supported, skipping")
91+ return nil
92+ }
93+
94+ // Call portal Inhibit method
95+ // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
96+ obj := p.conn.Object(portalDest, portalPath)
97+
98+ options := map[string]dbus.Variant{
99+ "reason": dbus.MakeVariant(reason),
100+ }
101+
102+ var handlePath dbus.ObjectPath
103+ err := obj.Call(portalInterface+".Inhibit", 0,
104+ "", // window identifier (empty for non-sandboxed)
105+ uint32(flagIdle|flagSuspend), // inhibit idle and suspend
106+ options,
107+ ).Store(&handlePath)
108+
109+ if err != nil {
110+ p.refcount--
111+ log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
112+ return fmt.Errorf("portal inhibit: %w", err)
113+ }
114+
115+ p.requestPath = handlePath
116+ log.Info().
117+ Str("handle", string(handlePath)).
118+ Str("reason", reason).
119+ Msg("idle inhibitor: activated")
120+
121+ return nil
122+}
123+
124+// Uninhibit decrements the refcount. When zero, releases inhibition.
125+func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
126+ log := logging.FromContext(ctx)
127+
128+ p.mu.Lock()
129+ defer p.mu.Unlock()
130+
131+ if p.refcount <= 0 {
132+ return nil // Nothing to uninhibit
133+ }
134+
135+ p.refcount--
136+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
137+
138+ // Still have active inhibitors
139+ if p.refcount > 0 {
140+ return nil
141+ }
142+
143+ // Release the inhibition
144+ if !p.supported || p.conn == nil || p.requestPath == "" {
145+ return nil
146+ }
147+
148+ // Close the request to release inhibition
149+ obj := p.conn.Object(portalDest, p.requestPath)
150+ err := obj.Call(requestIface+".Close", 0).Err
151+
152+ if err != nil {
153+ log.Warn().Err(err).Msg("idle inhibitor: failed to close request")
154+ // Don't return error - the request may have already been closed
155+ } else {
156+ log.Info().Msg("idle inhibitor: deactivated")
157+ }
158+
159+ p.requestPath = ""
160+ return nil
161+}
162+
163+// IsInhibited returns true if currently inhibiting idle.
164+func (p *PortalInhibitor) IsInhibited() bool {
165+ p.mu.Lock()
166+ defer p.mu.Unlock()
167+ return p.refcount > 0
168+}
169+
170+// Close releases D-Bus resources and any active inhibition.
171+func (p *PortalInhibitor) Close() error {
172+ p.mu.Lock()
173+ defer p.mu.Unlock()
174+
175+ // Release any active inhibition
176+ if p.conn != nil && p.requestPath != "" {
177+ obj := p.conn.Object(portalDest, p.requestPath)
178+ _ = obj.Call(requestIface+".Close", 0).Err
179+ p.requestPath = ""
180+ }
181+
182+ p.refcount = 0
183+
184+ if p.conn != nil {
185+ err := p.conn.Close()
186+ p.conn = nil
187+ return err
188+ }
189+
190+ return nil
191+}
@@ -0,0 +1,191 @@
1+// Package idle provides system idle/screensaver inhibition via XDG Desktop Portal.
2+package idle
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/godbus/dbus/v5"
12+)
13+
14+const (
15+ portalDest = "org.freedesktop.portal.Desktop"
16+ portalPath = "/org/freedesktop/portal/desktop"
17+ portalInterface = "org.freedesktop.portal.Inhibit"
18+ requestIface = "org.freedesktop.portal.Request"
19+
20+ // Inhibit flags from portal spec
21+ flagLogout = 1
22+ flagUserSwitch = 2
23+ flagSuspend = 4
24+ flagIdle = 8
25+)
26+
27+// Compile-time interface check.
28+var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29+
30+// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31+// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
32+type PortalInhibitor struct {
33+ conn *dbus.Conn
34+ requestPath dbus.ObjectPath // Active inhibit request handle
35+ refcount int
36+ supported bool
37+ mu sync.Mutex
38+}
39+
40+// NewPortalInhibitor creates a new portal-based idle inhibitor.
41+// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
42+func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
43+ log := logging.FromContext(ctx)
44+
45+ inhibitor := &PortalInhibitor{
46+ supported: false,
47+ }
48+
49+ // Connect to session bus
50+ conn, err := dbus.ConnectSessionBus()
51+ if err != nil {
52+ log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
53+ return inhibitor
54+ }
55+ inhibitor.conn = conn
56+
57+ // Check if portal is available
58+ obj := conn.Object(portalDest, portalPath)
59+ var version uint32
60+ err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
61+ portalInterface, "version").Store(&version)
62+ if err != nil {
63+ log.Debug().Err(err).Msg("idle inhibitor: portal not available")
64+ return inhibitor
65+ }
66+
67+ inhibitor.supported = true
68+ log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
69+
70+ return inhibitor
71+}
72+
73+// Inhibit increments the inhibit refcount. First call activates inhibition.
74+func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
75+ log := logging.FromContext(ctx)
76+
77+ p.mu.Lock()
78+ defer p.mu.Unlock()
79+
80+ p.refcount++
81+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
82+
83+ // Already inhibiting
84+ if p.refcount > 1 {
85+ return nil
86+ }
87+
88+ // First inhibit request
89+ if !p.supported || p.conn == nil {
90+ log.Debug().Msg("idle inhibitor: not supported, skipping")
91+ return nil
92+ }
93+
94+ // Call portal Inhibit method
95+ // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
96+ obj := p.conn.Object(portalDest, portalPath)
97+
98+ options := map[string]dbus.Variant{
99+ "reason": dbus.MakeVariant(reason),
100+ }
101+
102+ var handlePath dbus.ObjectPath
103+ err := obj.Call(portalInterface+".Inhibit", 0,
104+ "", // window identifier (empty for non-sandboxed)
105+ uint32(flagIdle|flagSuspend), // inhibit idle and suspend
106+ options,
107+ ).Store(&handlePath)
108+
109+ if err != nil {
110+ p.refcount--
111+ log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
112+ return fmt.Errorf("portal inhibit: %w", err)
113+ }
114+
115+ p.requestPath = handlePath
116+ log.Info().
117+ Str("handle", string(handlePath)).
118+ Str("reason", reason).
119+ Msg("idle inhibitor: activated")
120+
121+ return nil
122+}
123+
124+// Uninhibit decrements the refcount. When zero, releases inhibition.
125+func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
126+ log := logging.FromContext(ctx)
127+
128+ p.mu.Lock()
129+ defer p.mu.Unlock()
130+
131+ if p.refcount <= 0 {
132+ return nil // Nothing to uninhibit
133+ }
134+
135+ p.refcount--
136+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
137+
138+ // Still have active inhibitors
139+ if p.refcount > 0 {
140+ return nil
141+ }
142+
143+ // Release the inhibition
144+ if !p.supported || p.conn == nil || p.requestPath == "" {
145+ return nil
146+ }
147+
148+ // Close the request to release inhibition
149+ obj := p.conn.Object(portalDest, p.requestPath)
150+ err := obj.Call(requestIface+".Close", 0).Err
151+
152+ if err != nil {
153+ log.Warn().Err(err).Msg("idle inhibitor: failed to close request")
154+ // Don't return error - the request may have already been closed
155+ } else {
156+ log.Info().Msg("idle inhibitor: deactivated")
157+ }
158+
159+ p.requestPath = ""
160+ return nil
161+}
162+
163+// IsInhibited returns true if currently inhibiting idle.
164+func (p *PortalInhibitor) IsInhibited() bool {
165+ p.mu.Lock()
166+ defer p.mu.Unlock()
167+ return p.refcount > 0
168+}
169+
170+// Close releases D-Bus resources and any active inhibition.
171+func (p *PortalInhibitor) Close() error {
172+ p.mu.Lock()
173+ defer p.mu.Unlock()
174+
175+ // Release any active inhibition
176+ if p.conn != nil && p.requestPath != "" {
177+ obj := p.conn.Object(portalDest, p.requestPath)
178+ _ = obj.Call(requestIface+".Close", 0).Err
179+ p.requestPath = ""
180+ }
181+
182+ p.refcount = 0
183+
184+ if p.conn != nil {
185+ err := p.conn.Close()
186+ p.conn = nil
187+ return err
188+ }
189+
190+ return nil
191+}
M internal/infrastructure/webkit/factory.go +37 -4
@@ -3,6 +3,7 @@ package webkit
33 import (
44 "context"
55 "fmt"
6+ "sync"
67
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/logging"
@@ -18,6 +19,10 @@ type WebViewFactory struct {
1819 injector *ContentInjector
1920 router *MessageRouter
2021 filterApplier FilterApplier // Optional content filter applier
22+
23+ // Background color for WebViews (eliminates white flash)
24+ bgR, bgG, bgB, bgA float32
25+ bgMu sync.RWMutex
2126 }
2227
2328 // NewWebViewFactory creates a new WebViewFactory.
@@ -48,6 +53,18 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
4853 }
4954 }
5055
56+// SetBackgroundColor sets the background color for newly created WebViews.
57+// This color is shown before content is painted, eliminating white flash.
58+func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
59+ f.bgMu.Lock()
60+ f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a
61+ f.bgMu.Unlock()
62+ // Also propagate to the pool if present
63+ if f.pool != nil {
64+ f.pool.SetBackgroundColor(r, g, b, a)
65+ }
66+}
67+
5168 // Create creates a new WebView instance.
5269 // If a pool is configured, it will try to acquire from the pool first.
5370 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
@@ -88,8 +105,16 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
88105 return nil, fmt.Errorf("create related webview: %w", err)
89106 }
90107
91- // Ensure visible
92- wv.inner.SetVisible(true)
108+ // Set background color to match theme (eliminates white flash)
109+ f.bgMu.RLock()
110+ r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
111+ f.bgMu.RUnlock()
112+ if a > 0 {
113+ wv.SetBackgroundColor(r, g, b, a)
114+ }
115+
116+ // Keep hidden until content is painted
117+ wv.inner.SetVisible(false)
93118
94119 // Attach frontend (scripts, message handler)
95120 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
@@ -118,8 +143,16 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
118143 return nil, err
119144 }
120145
121- // Ensure visible
122- wv.inner.SetVisible(true)
146+ // Set background color to match theme (eliminates white flash)
147+ f.bgMu.RLock()
148+ r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
149+ f.bgMu.RUnlock()
150+ if a > 0 {
151+ wv.SetBackgroundColor(r, g, b, a)
152+ }
153+
154+ // Keep hidden until content is painted
155+ wv.inner.SetVisible(false)
123156
124157 // Attach frontend
125158 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
@@ -3,6 +3,7 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "sync"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
@@ -18,6 +19,10 @@ type WebViewFactory struct {
18 injector *ContentInjector19 injector *ContentInjector
19 router *MessageRouter20 router *MessageRouter
20 filterApplier FilterApplier // Optional content filter applier21 filterApplier FilterApplier // Optional content filter applier
22+
23+ // Background color for WebViews (eliminates white flash)
24+ bgR, bgG, bgB, bgA float32
25+ bgMu sync.RWMutex
21 }26 }
22 27
23 // NewWebViewFactory creates a new WebViewFactory.28 // NewWebViewFactory creates a new WebViewFactory.
@@ -48,6 +53,18 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
48 }53 }
49 }54 }
50 55
56+// SetBackgroundColor sets the background color for newly created WebViews.
57+// This color is shown before content is painted, eliminating white flash.
58+func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
59+ f.bgMu.Lock()
60+ f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a
61+ f.bgMu.Unlock()
62+ // Also propagate to the pool if present
63+ if f.pool != nil {
64+ f.pool.SetBackgroundColor(r, g, b, a)
65+ }
66+}
67+
51 // Create creates a new WebView instance.68 // Create creates a new WebView instance.
52 // If a pool is configured, it will try to acquire from the pool first.69 // If a pool is configured, it will try to acquire from the pool first.
53 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {70 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
@@ -88,8 +105,16 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
88 return nil, fmt.Errorf("create related webview: %w", err)105 return nil, fmt.Errorf("create related webview: %w", err)
89 }106 }
90 107
91- // Ensure visible108+ // Set background color to match theme (eliminates white flash)
92- wv.inner.SetVisible(true)109+ f.bgMu.RLock()
110+ r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
111+ f.bgMu.RUnlock()
112+ if a > 0 {
113+ wv.SetBackgroundColor(r, g, b, a)
114+ }
115+
116+ // Keep hidden until content is painted
117+ wv.inner.SetVisible(false)
93 118
94 // Attach frontend (scripts, message handler)119 // Attach frontend (scripts, message handler)
95 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {120 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
@@ -118,8 +143,16 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
118 return nil, err143 return nil, err
119 }144 }
120 145
121- // Ensure visible146+ // Set background color to match theme (eliminates white flash)
122- wv.inner.SetVisible(true)147+ f.bgMu.RLock()
148+ r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
149+ f.bgMu.RUnlock()
150+ if a > 0 {
151+ wv.SetBackgroundColor(r, g, b, a)
152+ }
153+
154+ // Keep hidden until content is painted
155+ wv.inner.SetVisible(false)
123 156
124 // Attach frontend157 // Attach frontend
125 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {158 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
M internal/infrastructure/webkit/injector.go +69 -29
@@ -27,9 +27,19 @@ const themeCSSScript = `(function() {
2727
2828 // darkModeScript patches window.matchMedia and sets theme class on <html>.
2929 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
30+// This script:
31+// 1. Sets dark/light class on <html> for CSS-based theme detection
32+// 2. Injects color-scheme meta tag and CSS for browser UA style hints
33+// 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries
34+//
35+// The matchMedia patch handles various query formats:
36+// - (prefers-color-scheme: dark)
37+// - (prefers-color-scheme:dark) -- no space
38+// - screen and (prefers-color-scheme: dark)
39+// And provides proper addEventListener/removeEventListener stubs for compatibility.
3040 const darkModeScript = `(function() {
31- const prefersDark = window.__dumber_gtk_prefers_dark || false;
32- const originalMatchMedia = window.matchMedia.bind(window);
41+ var prefersDark = window.__dumber_gtk_prefers_dark || false;
42+ var originalMatchMedia = window.matchMedia.bind(window);
3343
3444 // Apply dark/light class to document element for CSS theming
3545 if (prefersDark) {
@@ -41,44 +51,74 @@ const darkModeScript = `(function() {
4151 }
4252
4353 // Inject color-scheme meta tag
44- const meta = document.createElement('meta');
54+ var meta = document.createElement('meta');
4555 meta.name = 'color-scheme';
4656 meta.content = prefersDark ? 'dark light' : 'light dark';
4757 document.documentElement.appendChild(meta);
4858
4959 // Inject root color-scheme style
50- const style = document.createElement('style');
60+ var style = document.createElement('style');
5161 style.setAttribute('data-dumber-color-scheme', '');
5262 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
5363 document.documentElement.appendChild(style);
5464
65+ // Helper: Check if query is a prefers-color-scheme query
66+ // Normalizes by removing whitespace and lowercasing
67+ function isColorSchemeQuery(query, scheme) {
68+ if (typeof query !== 'string') return false;
69+ var normalized = query.replace(/\s+/g, '').toLowerCase();
70+ return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
71+ }
72+
73+ // Create a fake MediaQueryList that implements the full interface
74+ function createFakeMediaQueryList(query, matches) {
75+ var listeners = [];
76+ var onchangeHandler = null;
77+
78+ return {
79+ matches: matches,
80+ media: query,
81+ get onchange() { return onchangeHandler; },
82+ set onchange(fn) { onchangeHandler = fn; },
83+ // Deprecated but still used by some sites
84+ addListener: function(cb) {
85+ if (typeof cb === 'function') listeners.push(cb);
86+ },
87+ removeListener: function(cb) {
88+ var idx = listeners.indexOf(cb);
89+ if (idx !== -1) listeners.splice(idx, 1);
90+ },
91+ // Modern API
92+ addEventListener: function(type, cb) {
93+ if (type === 'change' && typeof cb === 'function') {
94+ listeners.push(cb);
95+ }
96+ },
97+ removeEventListener: function(type, cb) {
98+ if (type === 'change') {
99+ var idx = listeners.indexOf(cb);
100+ if (idx !== -1) listeners.splice(idx, 1);
101+ }
102+ },
103+ dispatchEvent: function(event) {
104+ for (var i = 0; i < listeners.length; i++) {
105+ try { listeners[i](event); } catch (e) {}
106+ }
107+ if (onchangeHandler) {
108+ try { onchangeHandler(event); } catch (e) {}
109+ }
110+ return true;
111+ }
112+ };
113+ }
114+
55115 // Patch matchMedia for prefers-color-scheme queries
56116 window.matchMedia = function(query) {
57- if (typeof query === 'string') {
58- if (query.includes('prefers-color-scheme: dark')) {
59- return {
60- matches: prefersDark,
61- media: query,
62- onchange: null,
63- addListener: function() {},
64- removeListener: function() {},
65- addEventListener: function() {},
66- removeEventListener: function() {},
67- dispatchEvent: function() { return false; }
68- };
69- }
70- if (query.includes('prefers-color-scheme: light')) {
71- return {
72- matches: !prefersDark,
73- media: query,
74- onchange: null,
75- addListener: function() {},
76- removeListener: function() {},
77- addEventListener: function() {},
78- removeEventListener: function() {},
79- dispatchEvent: function() { return false; }
80- };
81- }
117+ if (isColorSchemeQuery(query, 'dark')) {
118+ return createFakeMediaQueryList(query, prefersDark);
119+ }
120+ if (isColorSchemeQuery(query, 'light')) {
121+ return createFakeMediaQueryList(query, !prefersDark);
82122 }
83123 return originalMatchMedia(query);
84124 };
@@ -27,9 +27,19 @@ const themeCSSScript = `(function() {
27 27
28 // darkModeScript patches window.matchMedia and sets theme class on <html>.28 // darkModeScript patches window.matchMedia and sets theme class on <html>.
29 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.29 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
30+// This script:
31+// 1. Sets dark/light class on <html> for CSS-based theme detection
32+// 2. Injects color-scheme meta tag and CSS for browser UA style hints
33+// 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries
34+//
35+// The matchMedia patch handles various query formats:
36+// - (prefers-color-scheme: dark)
37+// - (prefers-color-scheme:dark) -- no space
38+// - screen and (prefers-color-scheme: dark)
39+// And provides proper addEventListener/removeEventListener stubs for compatibility.
30 const darkModeScript = `(function() {40 const darkModeScript = `(function() {
31- const prefersDark = window.__dumber_gtk_prefers_dark || false;41+ var prefersDark = window.__dumber_gtk_prefers_dark || false;
32- const originalMatchMedia = window.matchMedia.bind(window);42+ var originalMatchMedia = window.matchMedia.bind(window);
33 43
34 // Apply dark/light class to document element for CSS theming44 // Apply dark/light class to document element for CSS theming
35 if (prefersDark) {45 if (prefersDark) {
@@ -41,44 +51,74 @@ const darkModeScript = `(function() {
41 }51 }
42 52
43 // Inject color-scheme meta tag53 // Inject color-scheme meta tag
44- const meta = document.createElement('meta');54+ var meta = document.createElement('meta');
45 meta.name = 'color-scheme';55 meta.name = 'color-scheme';
46 meta.content = prefersDark ? 'dark light' : 'light dark';56 meta.content = prefersDark ? 'dark light' : 'light dark';
47 document.documentElement.appendChild(meta);57 document.documentElement.appendChild(meta);
48 58
49 // Inject root color-scheme style59 // Inject root color-scheme style
50- const style = document.createElement('style');60+ var style = document.createElement('style');
51 style.setAttribute('data-dumber-color-scheme', '');61 style.setAttribute('data-dumber-color-scheme', '');
52 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';62 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
53 document.documentElement.appendChild(style);63 document.documentElement.appendChild(style);
54 64
65+ // Helper: Check if query is a prefers-color-scheme query
66+ // Normalizes by removing whitespace and lowercasing
67+ function isColorSchemeQuery(query, scheme) {
68+ if (typeof query !== 'string') return false;
69+ var normalized = query.replace(/\s+/g, '').toLowerCase();
70+ return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
71+ }
72+
73+ // Create a fake MediaQueryList that implements the full interface
74+ function createFakeMediaQueryList(query, matches) {
75+ var listeners = [];
76+ var onchangeHandler = null;
77+
78+ return {
79+ matches: matches,
80+ media: query,
81+ get onchange() { return onchangeHandler; },
82+ set onchange(fn) { onchangeHandler = fn; },
83+ // Deprecated but still used by some sites
84+ addListener: function(cb) {
85+ if (typeof cb === 'function') listeners.push(cb);
86+ },
87+ removeListener: function(cb) {
88+ var idx = listeners.indexOf(cb);
89+ if (idx !== -1) listeners.splice(idx, 1);
90+ },
91+ // Modern API
92+ addEventListener: function(type, cb) {
93+ if (type === 'change' && typeof cb === 'function') {
94+ listeners.push(cb);
95+ }
96+ },
97+ removeEventListener: function(type, cb) {
98+ if (type === 'change') {
99+ var idx = listeners.indexOf(cb);
100+ if (idx !== -1) listeners.splice(idx, 1);
101+ }
102+ },
103+ dispatchEvent: function(event) {
104+ for (var i = 0; i < listeners.length; i++) {
105+ try { listeners[i](event); } catch (e) {}
106+ }
107+ if (onchangeHandler) {
108+ try { onchangeHandler(event); } catch (e) {}
109+ }
110+ return true;
111+ }
112+ };
113+ }
114+
55 // Patch matchMedia for prefers-color-scheme queries115 // Patch matchMedia for prefers-color-scheme queries
56 window.matchMedia = function(query) {116 window.matchMedia = function(query) {
57- if (typeof query === 'string') {117+ if (isColorSchemeQuery(query, 'dark')) {
58- if (query.includes('prefers-color-scheme: dark')) {118+ return createFakeMediaQueryList(query, prefersDark);
59- return {119+ }
60- matches: prefersDark,120+ if (isColorSchemeQuery(query, 'light')) {
61- media: query,121+ return createFakeMediaQueryList(query, !prefersDark);
62- onchange: null,
63- addListener: function() {},
64- removeListener: function() {},
65- addEventListener: function() {},
66- removeEventListener: function() {},
67- dispatchEvent: function() { return false; }
68- };
69- }
70- if (query.includes('prefers-color-scheme: light')) {
71- return {
72- matches: !prefersDark,
73- media: query,
74- onchange: null,
75- addListener: function() {},
76- removeListener: function() {},
77- addEventListener: function() {},
78- removeEventListener: function() {},
79- dispatchEvent: function() { return false; }
80- };
81- }
82 }122 }
83 return originalMatchMedia(query);123 return originalMatchMedia(query);
84 };124 };
M internal/infrastructure/webkit/pool.go +23 -3
@@ -33,7 +33,7 @@ func DefaultPoolConfig() PoolConfig {
3333 return PoolConfig{
3434 MinSize: 2,
3535 MaxSize: 8,
36- PrewarmCount: 2,
36+ PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
3737 IdleTimeout: 5 * time.Minute,
3838 }
3939 }
@@ -49,6 +49,10 @@ type WebViewPool struct {
4949 router *MessageRouter
5050 filterApplier FilterApplier // Optional content filter applier
5151
52+ // Background color for WebViews (eliminates white flash)
53+ bgR, bgG, bgB, bgA float32
54+ bgMu sync.RWMutex
55+
5256 closed atomic.Bool
5357 wg sync.WaitGroup
5458 }
@@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
96100 p.filterApplier = applier
97101 }
98102
103+// SetBackgroundColor sets the background color for newly created WebViews.
104+// This color is shown before content is painted, eliminating white flash.
105+func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
106+ p.bgMu.Lock()
107+ p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
108+ p.bgMu.Unlock()
109+}
110+
99111 // Acquire gets a WebView from the pool or creates a new one.
100112 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
101113 log := logging.FromContext(ctx)
@@ -135,8 +147,16 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
135147 return nil, err
136148 }
137149
138- // Ensure WebView is visible when used
139- wv.inner.SetVisible(true)
150+ // Set background color to match theme (eliminates white flash)
151+ p.bgMu.RLock()
152+ r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA
153+ p.bgMu.RUnlock()
154+ if a > 0 { // Only set if a valid color was configured
155+ wv.SetBackgroundColor(r, g, b, a)
156+ }
157+
158+ // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
159+ wv.inner.SetVisible(false)
140160
141161 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
142162 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
@@ -33,7 +33,7 @@ func DefaultPoolConfig() PoolConfig {
33 return PoolConfig{33 return PoolConfig{
34 MinSize: 2,34 MinSize: 2,
35 MaxSize: 8,35 MaxSize: 8,
36- PrewarmCount: 2,36+ PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
37 IdleTimeout: 5 * time.Minute,37 IdleTimeout: 5 * time.Minute,
38 }38 }
39 }39 }
@@ -49,6 +49,10 @@ type WebViewPool struct {
49 router *MessageRouter49 router *MessageRouter
50 filterApplier FilterApplier // Optional content filter applier50 filterApplier FilterApplier // Optional content filter applier
51 51
52+ // Background color for WebViews (eliminates white flash)
53+ bgR, bgG, bgB, bgA float32
54+ bgMu sync.RWMutex
55+
52 closed atomic.Bool56 closed atomic.Bool
53 wg sync.WaitGroup57 wg sync.WaitGroup
54 }58 }
@@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
96 p.filterApplier = applier100 p.filterApplier = applier
97 }101 }
98 102
103+// SetBackgroundColor sets the background color for newly created WebViews.
104+// This color is shown before content is painted, eliminating white flash.
105+func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
106+ p.bgMu.Lock()
107+ p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
108+ p.bgMu.Unlock()
109+}
110+
99 // Acquire gets a WebView from the pool or creates a new one.111 // Acquire gets a WebView from the pool or creates a new one.
100 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {112 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
101 log := logging.FromContext(ctx)113 log := logging.FromContext(ctx)
@@ -135,8 +147,16 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
135 return nil, err147 return nil, err
136 }148 }
137 149
138- // Ensure WebView is visible when used150+ // Set background color to match theme (eliminates white flash)
139- wv.inner.SetVisible(true)151+ p.bgMu.RLock()
152+ r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA
153+ p.bgMu.RUnlock()
154+ if a > 0 { // Only set if a valid color was configured
155+ wv.SetBackgroundColor(r, g, b, a)
156+ }
157+
158+ // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
159+ wv.inner.SetVisible(false)
140 160
141 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {161 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
142 log.Warn().Err(err).Msg("failed to attach frontend to new webview")162 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
M internal/infrastructure/webkit/webview.go +104 -4
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "sync"
77 "sync/atomic"
8+ "time"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/logging"
@@ -94,6 +95,9 @@ type WebView struct {
9495 canGoFwd bool
9596 isLoading bool
9697
98+ // Progress throttling (~60fps)
99+ lastProgressUpdate atomic.Int64 // Unix nanoseconds
100+
97101 // Signal handler IDs for disconnection
98102 signalIDs []uint32
99103
@@ -107,6 +111,8 @@ type WebView struct {
107111 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
108112 OnReadyToShow func() // Called when popup is ready to display
109113 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
114+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
115+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
110116
111117 logger zerolog.Logger
112118 mu sync.RWMutex
@@ -314,6 +320,8 @@ func (wv *WebView) connectSignals() {
314320 wv.connectFaviconSignal()
315321 wv.connectProgressSignal()
316322 wv.connectDecidePolicySignal()
323+ wv.connectEnterFullscreenSignal()
324+ wv.connectLeaveFullscreenSignal()
317325 }
318326
319327 func (wv *WebView) connectLoadChangedSignal() {
@@ -447,9 +455,24 @@ func (wv *WebView) connectFaviconSignal() {
447455 wv.signalIDs = append(wv.signalIDs, sigID)
448456 }
449457
458+// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
459+const progressThrottleInterval = 16 * time.Millisecond
460+
450461 func (wv *WebView) connectProgressSignal() {
451462 progressCb := func() {
452463 progress := wv.inner.GetEstimatedLoadProgress()
464+
465+ // Throttle progress updates to ~60fps (16ms interval)
466+ // Always allow 0.0 (start) and 1.0 (complete) through
467+ now := time.Now().UnixNano()
468+ last := wv.lastProgressUpdate.Load()
469+ if progress > 0.0 && progress < 1.0 {
470+ if now-last < int64(progressThrottleInterval) {
471+ return // Skip this update
472+ }
473+ }
474+ wv.lastProgressUpdate.Store(now)
475+
453476 wv.mu.Lock()
454477 wv.progress = progress
455478 wv.mu.Unlock()
@@ -520,6 +543,30 @@ func (wv *WebView) connectDecidePolicySignal() {
520543 wv.signalIDs = append(wv.signalIDs, sigID)
521544 }
522545
546+func (wv *WebView) connectEnterFullscreenSignal() {
547+ enterFullscreenCb := func(_ webkit.WebView) bool {
548+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
549+ if wv.OnEnterFullscreen != nil {
550+ return wv.OnEnterFullscreen()
551+ }
552+ return false // Allow fullscreen
553+ }
554+ sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
555+ wv.signalIDs = append(wv.signalIDs, sigID)
556+}
557+
558+func (wv *WebView) connectLeaveFullscreenSignal() {
559+ leaveFullscreenCb := func(_ webkit.WebView) bool {
560+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
561+ if wv.OnLeaveFullscreen != nil {
562+ return wv.OnLeaveFullscreen()
563+ }
564+ return false // Allow leaving fullscreen
565+ }
566+ sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
567+ wv.signalIDs = append(wv.signalIDs, sigID)
568+}
569+
523570 // ID returns the unique identifier for this WebView.
524571 func (wv *WebView) ID() WebViewID {
525572 return wv.id
@@ -701,6 +748,22 @@ func (wv *WebView) GetZoomLevel() float64 {
701748 return wv.inner.GetZoomLevel()
702749 }
703750
751+// SetBackgroundColor sets the WebView background color.
752+// This color is shown before content is painted, eliminating white flash.
753+// Values are in range 0.0-1.0 for red, green, blue, alpha.
754+func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
755+ if wv.destroyed.Load() {
756+ return
757+ }
758+ rgba := &gdk.RGBA{
759+ Red: r,
760+ Green: g,
761+ Blue: b,
762+ Alpha: a,
763+ }
764+ wv.inner.SetBackgroundColor(rgba)
765+}
766+
704767 // State returns the current WebView state as a snapshot.
705768 func (wv *WebView) State() port.WebViewState {
706769 return port.WebViewState{
@@ -812,19 +875,56 @@ func (wv *WebView) DisconnectSignals() {
812875 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
813876 }
814877
815-// Destroy cleans up the WebView resources.
878+// Destroy cleans up the WebView resources and terminates the web process.
879+// This must be called when a WebView is no longer needed to free GPU resources,
880+// VA-API decoder contexts, and DMA-BUF buffers held by the web process.
816881 func (wv *WebView) Destroy() {
817882 if wv.destroyed.Swap(true) {
818883 return // Already destroyed
819884 }
820885
821- // Disconnect all signal handlers first
886+ // 1. Disconnect all signal handlers first to prevent callbacks during cleanup
822887 wv.DisconnectSignals()
823888
824- // Unregister from global registry
889+ // 2. Clear all callbacks to prevent use-after-free
890+ wv.OnLoadChanged = nil
891+ wv.OnTitleChanged = nil
892+ wv.OnURIChanged = nil
893+ wv.OnProgressChanged = nil
894+ wv.OnFaviconChanged = nil
895+ wv.OnClose = nil
896+ wv.OnCreate = nil
897+ wv.OnReadyToShow = nil
898+ wv.OnLinkMiddleClick = nil
899+ wv.OnEnterFullscreen = nil
900+ wv.OnLeaveFullscreen = nil
901+
902+ // 3. Clear async callback references
903+ wv.mu.Lock()
904+ wv.asyncCallbacks = nil
905+ wv.mu.Unlock()
906+
907+ // 4. Unparent from GTK hierarchy (must happen before process termination)
908+ // This releases the widget from any parent container.
909+ if wv.inner != nil {
910+ wv.inner.Unparent()
911+ }
912+
913+ // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)
914+ // This is critical to prevent zombie processes that hold video decoder resources.
915+ if wv.inner != nil {
916+ wv.inner.TerminateWebProcess()
917+ }
918+
919+ // 6. Unregister from global registry
825920 globalRegistry.unregister(wv.id)
826921
827- wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed")
922+ // 7. Clear internal references to allow GC
923+ wv.inner = nil
924+ wv.ucm = nil
925+ wv.findController = nil
926+
927+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")
828928 }
829929
830930 // RunJavaScript executes script in the specified world (empty for main world).
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "sync"6 "sync"
7 "sync/atomic"7 "sync/atomic"
8+ "time"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
@@ -94,6 +95,9 @@ type WebView struct {
94 canGoFwd bool95 canGoFwd bool
95 isLoading bool96 isLoading bool
96 97
98+ // Progress throttling (~60fps)
99+ lastProgressUpdate atomic.Int64 // Unix nanoseconds
100+
97 // Signal handler IDs for disconnection101 // Signal handler IDs for disconnection
98 signalIDs []uint32102 signalIDs []uint32
99 103
@@ -107,6 +111,8 @@ type WebView struct {
107 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup111 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
108 OnReadyToShow func() // Called when popup is ready to display112 OnReadyToShow func() // Called when popup is ready to display
109 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)113 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
114+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
115+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
110 116
111 logger zerolog.Logger117 logger zerolog.Logger
112 mu sync.RWMutex118 mu sync.RWMutex
@@ -314,6 +320,8 @@ func (wv *WebView) connectSignals() {
314 wv.connectFaviconSignal()320 wv.connectFaviconSignal()
315 wv.connectProgressSignal()321 wv.connectProgressSignal()
316 wv.connectDecidePolicySignal()322 wv.connectDecidePolicySignal()
323+ wv.connectEnterFullscreenSignal()
324+ wv.connectLeaveFullscreenSignal()
317 }325 }
318 326
319 func (wv *WebView) connectLoadChangedSignal() {327 func (wv *WebView) connectLoadChangedSignal() {
@@ -447,9 +455,24 @@ func (wv *WebView) connectFaviconSignal() {
447 wv.signalIDs = append(wv.signalIDs, sigID)455 wv.signalIDs = append(wv.signalIDs, sigID)
448 }456 }
449 457
458+// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
459+const progressThrottleInterval = 16 * time.Millisecond
460+
450 func (wv *WebView) connectProgressSignal() {461 func (wv *WebView) connectProgressSignal() {
451 progressCb := func() {462 progressCb := func() {
452 progress := wv.inner.GetEstimatedLoadProgress()463 progress := wv.inner.GetEstimatedLoadProgress()
464+
465+ // Throttle progress updates to ~60fps (16ms interval)
466+ // Always allow 0.0 (start) and 1.0 (complete) through
467+ now := time.Now().UnixNano()
468+ last := wv.lastProgressUpdate.Load()
469+ if progress > 0.0 && progress < 1.0 {
470+ if now-last < int64(progressThrottleInterval) {
471+ return // Skip this update
472+ }
473+ }
474+ wv.lastProgressUpdate.Store(now)
475+
453 wv.mu.Lock()476 wv.mu.Lock()
454 wv.progress = progress477 wv.progress = progress
455 wv.mu.Unlock()478 wv.mu.Unlock()
@@ -520,6 +543,30 @@ func (wv *WebView) connectDecidePolicySignal() {
520 wv.signalIDs = append(wv.signalIDs, sigID)543 wv.signalIDs = append(wv.signalIDs, sigID)
521 }544 }
522 545
546+func (wv *WebView) connectEnterFullscreenSignal() {
547+ enterFullscreenCb := func(_ webkit.WebView) bool {
548+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
549+ if wv.OnEnterFullscreen != nil {
550+ return wv.OnEnterFullscreen()
551+ }
552+ return false // Allow fullscreen
553+ }
554+ sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
555+ wv.signalIDs = append(wv.signalIDs, sigID)
556+}
557+
558+func (wv *WebView) connectLeaveFullscreenSignal() {
559+ leaveFullscreenCb := func(_ webkit.WebView) bool {
560+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
561+ if wv.OnLeaveFullscreen != nil {
562+ return wv.OnLeaveFullscreen()
563+ }
564+ return false // Allow leaving fullscreen
565+ }
566+ sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
567+ wv.signalIDs = append(wv.signalIDs, sigID)
568+}
569+
523 // ID returns the unique identifier for this WebView.570 // ID returns the unique identifier for this WebView.
524 func (wv *WebView) ID() WebViewID {571 func (wv *WebView) ID() WebViewID {
525 return wv.id572 return wv.id
@@ -701,6 +748,22 @@ func (wv *WebView) GetZoomLevel() float64 {
701 return wv.inner.GetZoomLevel()748 return wv.inner.GetZoomLevel()
702 }749 }
703 750
751+// SetBackgroundColor sets the WebView background color.
752+// This color is shown before content is painted, eliminating white flash.
753+// Values are in range 0.0-1.0 for red, green, blue, alpha.
754+func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
755+ if wv.destroyed.Load() {
756+ return
757+ }
758+ rgba := &gdk.RGBA{
759+ Red: r,
760+ Green: g,
761+ Blue: b,
762+ Alpha: a,
763+ }
764+ wv.inner.SetBackgroundColor(rgba)
765+}
766+
704 // State returns the current WebView state as a snapshot.767 // State returns the current WebView state as a snapshot.
705 func (wv *WebView) State() port.WebViewState {768 func (wv *WebView) State() port.WebViewState {
706 return port.WebViewState{769 return port.WebViewState{
@@ -812,19 +875,56 @@ func (wv *WebView) DisconnectSignals() {
812 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")875 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
813 }876 }
814 877
815-// Destroy cleans up the WebView resources.878+// Destroy cleans up the WebView resources and terminates the web process.
879+// This must be called when a WebView is no longer needed to free GPU resources,
880+// VA-API decoder contexts, and DMA-BUF buffers held by the web process.
816 func (wv *WebView) Destroy() {881 func (wv *WebView) Destroy() {
817 if wv.destroyed.Swap(true) {882 if wv.destroyed.Swap(true) {
818 return // Already destroyed883 return // Already destroyed
819 }884 }
820 885
821- // Disconnect all signal handlers first886+ // 1. Disconnect all signal handlers first to prevent callbacks during cleanup
822 wv.DisconnectSignals()887 wv.DisconnectSignals()
823 888
824- // Unregister from global registry889+ // 2. Clear all callbacks to prevent use-after-free
890+ wv.OnLoadChanged = nil
891+ wv.OnTitleChanged = nil
892+ wv.OnURIChanged = nil
893+ wv.OnProgressChanged = nil
894+ wv.OnFaviconChanged = nil
895+ wv.OnClose = nil
896+ wv.OnCreate = nil
897+ wv.OnReadyToShow = nil
898+ wv.OnLinkMiddleClick = nil
899+ wv.OnEnterFullscreen = nil
900+ wv.OnLeaveFullscreen = nil
901+
902+ // 3. Clear async callback references
903+ wv.mu.Lock()
904+ wv.asyncCallbacks = nil
905+ wv.mu.Unlock()
906+
907+ // 4. Unparent from GTK hierarchy (must happen before process termination)
908+ // This releases the widget from any parent container.
909+ if wv.inner != nil {
910+ wv.inner.Unparent()
911+ }
912+
913+ // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)
914+ // This is critical to prevent zombie processes that hold video decoder resources.
915+ if wv.inner != nil {
916+ wv.inner.TerminateWebProcess()
917+ }
918+
919+ // 6. Unregister from global registry
825 globalRegistry.unregister(wv.id)920 globalRegistry.unregister(wv.id)
826 921
827- wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed")922+ // 7. Clear internal references to allow GC
923+ wv.inner = nil
924+ wv.ucm = nil
925+ wv.findController = nil
926+
927+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")
828 }928 }
829 929
830 // RunJavaScript executes script in the specified world (empty for main world).930 // RunJavaScript executes script in the specified world (empty for main world).
M internal/ui/app.go +36 -1
@@ -61,7 +61,8 @@ type App struct {
6161 stackedPaneMgr *component.StackedPaneManager
6262
6363 // Input handling
64- keyboardHandler *input.KeyboardHandler
64+ keyboardHandler *input.KeyboardHandler
65+ globalShortcutHandler *input.GlobalShortcutHandler
6566
6667 // Focus and border management
6768 focusMgr *focus.Manager
@@ -209,6 +210,13 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
209210 }
210211
211212 func (a *App) prewarmWebViewPool(ctx context.Context) {
213+ // Set theme background color on pool to eliminate white flash.
214+ // Must be done before prewarming so WebViews get the correct color.
215+ if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
216+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
217+ a.pool.SetBackgroundColor(r, g, b, alpha)
218+ }
219+
212220 // Prewarm WebView pool now that GTK is initialized.
213221 if a.pool == nil {
214222 return
@@ -305,6 +313,17 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
305313 return wsView.IsOmniboxVisible()
306314 })
307315 a.keyboardHandler.AttachTo(a.mainWindow.Window())
316+
317+ // Create global shortcut handler for Alt+1-9 tab switching.
318+ // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
319+ // to intercept shortcuts even when WebView has focus.
320+ a.globalShortcutHandler = input.NewGlobalShortcutHandler(
321+ ctx,
322+ a.mainWindow.Window(),
323+ func(ctx context.Context, action input.Action) error {
324+ return a.kbDispatcher.Dispatch(ctx, action)
325+ },
326+ )
308327 }
309328
310329 func (a *App) initOmniboxConfig(ctx context.Context) {
@@ -400,6 +419,12 @@ func (a *App) onShutdown(ctx context.Context) {
400419 if a.deps.Pool != nil {
401420 a.deps.Pool.Close(ctx)
402421 }
422+ // Close idle inhibitor to release D-Bus connection
423+ if a.deps.IdleInhibitor != nil {
424+ if err := a.deps.IdleInhibitor.Close(); err != nil {
425+ log.Warn().Err(err).Msg("failed to close idle inhibitor")
426+ }
427+ }
403428
404429 log.Info().Msg("application shutdown complete")
405430 }
@@ -431,6 +456,11 @@ func (a *App) initCoordinators(ctx context.Context) {
431456 a.deps.ZoomUC,
432457 )
433458
459+ // Set idle inhibitor for fullscreen video playback
460+ if a.deps.IdleInhibitor != nil {
461+ a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
462+ }
463+
434464 // 2. Tab Coordinator
435465 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
436466 TabsUC: a.tabsUC,
@@ -481,6 +511,11 @@ func (a *App) initCoordinators(ctx context.Context) {
481511 a.injector,
482512 a.router,
483513 )
514+ // Set theme background color on factory to eliminate white flash
515+ if a.deps.Theme != nil {
516+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
517+ a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
518+ }
484519 a.contentCoord.SetPopupConfig(
485520 a.webViewFactory,
486521 &a.deps.Config.Workspace.Popups,
@@ -61,7 +61,8 @@ type App struct {
61 stackedPaneMgr *component.StackedPaneManager61 stackedPaneMgr *component.StackedPaneManager
62 62
63 // Input handling63 // Input handling
64- keyboardHandler *input.KeyboardHandler64+ keyboardHandler *input.KeyboardHandler
65+ globalShortcutHandler *input.GlobalShortcutHandler
65 66
66 // Focus and border management67 // Focus and border management
67 focusMgr *focus.Manager68 focusMgr *focus.Manager
@@ -209,6 +210,13 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
209 }210 }
210 211
211 func (a *App) prewarmWebViewPool(ctx context.Context) {212 func (a *App) prewarmWebViewPool(ctx context.Context) {
213+ // Set theme background color on pool to eliminate white flash.
214+ // Must be done before prewarming so WebViews get the correct color.
215+ if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
216+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
217+ a.pool.SetBackgroundColor(r, g, b, alpha)
218+ }
219+
212 // Prewarm WebView pool now that GTK is initialized.220 // Prewarm WebView pool now that GTK is initialized.
213 if a.pool == nil {221 if a.pool == nil {
214 return222 return
@@ -305,6 +313,17 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
305 return wsView.IsOmniboxVisible()313 return wsView.IsOmniboxVisible()
306 })314 })
307 a.keyboardHandler.AttachTo(a.mainWindow.Window())315 a.keyboardHandler.AttachTo(a.mainWindow.Window())
316+
317+ // Create global shortcut handler for Alt+1-9 tab switching.
318+ // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
319+ // to intercept shortcuts even when WebView has focus.
320+ a.globalShortcutHandler = input.NewGlobalShortcutHandler(
321+ ctx,
322+ a.mainWindow.Window(),
323+ func(ctx context.Context, action input.Action) error {
324+ return a.kbDispatcher.Dispatch(ctx, action)
325+ },
326+ )
308 }327 }
309 328
310 func (a *App) initOmniboxConfig(ctx context.Context) {329 func (a *App) initOmniboxConfig(ctx context.Context) {
@@ -400,6 +419,12 @@ func (a *App) onShutdown(ctx context.Context) {
400 if a.deps.Pool != nil {419 if a.deps.Pool != nil {
401 a.deps.Pool.Close(ctx)420 a.deps.Pool.Close(ctx)
402 }421 }
422+ // Close idle inhibitor to release D-Bus connection
423+ if a.deps.IdleInhibitor != nil {
424+ if err := a.deps.IdleInhibitor.Close(); err != nil {
425+ log.Warn().Err(err).Msg("failed to close idle inhibitor")
426+ }
427+ }
403 428
404 log.Info().Msg("application shutdown complete")429 log.Info().Msg("application shutdown complete")
405 }430 }
@@ -431,6 +456,11 @@ func (a *App) initCoordinators(ctx context.Context) {
431 a.deps.ZoomUC,456 a.deps.ZoomUC,
432 )457 )
433 458
459+ // Set idle inhibitor for fullscreen video playback
460+ if a.deps.IdleInhibitor != nil {
461+ a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
462+ }
463+
434 // 2. Tab Coordinator464 // 2. Tab Coordinator
435 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{465 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
436 TabsUC: a.tabsUC,466 TabsUC: a.tabsUC,
@@ -481,6 +511,11 @@ func (a *App) initCoordinators(ctx context.Context) {
481 a.injector,511 a.injector,
482 a.router,512 a.router,
483 )513 )
514+ // Set theme background color on factory to eliminate white flash
515+ if a.deps.Theme != nil {
516+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
517+ a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
518+ }
484 a.contentCoord.SetPopupConfig(519 a.contentCoord.SetPopupConfig(
485 a.webViewFactory,520 a.webViewFactory,
486 &a.deps.Config.Workspace.Popups,521 &a.deps.Config.Workspace.Popups,
M internal/ui/component/pane_view.go +37 -1
@@ -42,10 +42,11 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
4242 overlay.SetHexpand(true)
4343 overlay.SetVexpand(true)
4444 overlay.SetVisible(true)
45+ overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
4546
4647 // Set the WebView as the main child
48+ // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
4749 if webViewWidget != nil {
48- webViewWidget.SetVisible(true)
4950 overlay.SetChild(webViewWidget)
5051 }
5152
@@ -348,3 +349,38 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
348349
349350 t.ShowZoom(ctx, zoomPercent)
350351 }
352+
353+// Cleanup removes the WebView widget from the overlay and clears references.
354+// This must be called before destroying the WebView to ensure proper GTK cleanup.
355+// After calling Cleanup, the PaneView should not be reused.
356+func (pv *PaneView) Cleanup() {
357+ pv.mu.Lock()
358+ defer pv.mu.Unlock()
359+
360+ // Clear callbacks to prevent use-after-free
361+ pv.onFocusIn = nil
362+ pv.onFocusOut = nil
363+ pv.onHover = nil
364+
365+ // Detach hover handler if present
366+ if pv.hoverHandler != nil {
367+ pv.hoverHandler.Detach()
368+ pv.hoverHandler = nil
369+ }
370+
371+ // Remove WebView from overlay (unparents it from GTK hierarchy)
372+ if pv.webViewWidget != nil {
373+ pv.overlay.SetChild(nil)
374+ pv.webViewWidget = nil
375+ }
376+
377+ // Clear other overlay children
378+ if pv.progressBar != nil {
379+ pv.overlay.RemoveOverlay(pv.progressBar.Widget())
380+ pv.progressBar = nil
381+ }
382+ if pv.toaster != nil {
383+ pv.overlay.RemoveOverlay(pv.toaster.Widget())
384+ pv.toaster = nil
385+ }
386+}
@@ -42,10 +42,11 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
42 overlay.SetHexpand(true)42 overlay.SetHexpand(true)
43 overlay.SetVexpand(true)43 overlay.SetVexpand(true)
44 overlay.SetVisible(true)44 overlay.SetVisible(true)
45+ overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
45 46
46 // Set the WebView as the main child47 // Set the WebView as the main child
48+ // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
47 if webViewWidget != nil {49 if webViewWidget != nil {
48- webViewWidget.SetVisible(true)
49 overlay.SetChild(webViewWidget)50 overlay.SetChild(webViewWidget)
50 }51 }
51 52
@@ -348,3 +349,38 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
348 349
349 t.ShowZoom(ctx, zoomPercent)350 t.ShowZoom(ctx, zoomPercent)
350 }351 }
352+
353+// Cleanup removes the WebView widget from the overlay and clears references.
354+// This must be called before destroying the WebView to ensure proper GTK cleanup.
355+// After calling Cleanup, the PaneView should not be reused.
356+func (pv *PaneView) Cleanup() {
357+ pv.mu.Lock()
358+ defer pv.mu.Unlock()
359+
360+ // Clear callbacks to prevent use-after-free
361+ pv.onFocusIn = nil
362+ pv.onFocusOut = nil
363+ pv.onHover = nil
364+
365+ // Detach hover handler if present
366+ if pv.hoverHandler != nil {
367+ pv.hoverHandler.Detach()
368+ pv.hoverHandler = nil
369+ }
370+
371+ // Remove WebView from overlay (unparents it from GTK hierarchy)
372+ if pv.webViewWidget != nil {
373+ pv.overlay.SetChild(nil)
374+ pv.webViewWidget = nil
375+ }
376+
377+ // Clear other overlay children
378+ if pv.progressBar != nil {
379+ pv.overlay.RemoveOverlay(pv.progressBar.Widget())
380+ pv.progressBar = nil
381+ }
382+ if pv.toaster != nil {
383+ pv.overlay.RemoveOverlay(pv.toaster.Widget())
384+ pv.toaster = nil
385+ }
386+}
M internal/ui/component/pane_view_test.go +4 -2
@@ -25,7 +25,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
2525 mockOverlay.EXPECT().SetHexpand(true).Once()
2626 mockOverlay.EXPECT().SetVexpand(true).Once()
2727 mockOverlay.EXPECT().SetVisible(true).Once()
28- mockWebView.EXPECT().SetVisible(true).Once()
28+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
2929 mockOverlay.EXPECT().SetChild(mockWebView).Once()
3030
3131 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -59,6 +59,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
5959 mockOverlay.EXPECT().SetHexpand(true).Once()
6060 mockOverlay.EXPECT().SetVexpand(true).Once()
6161 mockOverlay.EXPECT().SetVisible(true).Once()
62+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
6263 // SetChild should NOT be called when webview is nil
6364
6465 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -504,7 +505,7 @@ func setupPaneViewMocks(
504505 mockOverlay.EXPECT().SetHexpand(true).Once()
505506 mockOverlay.EXPECT().SetVexpand(true).Once()
506507 mockOverlay.EXPECT().SetVisible(true).Once()
507- mockWebView.EXPECT().SetVisible(true).Once()
508+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
508509 mockOverlay.EXPECT().SetChild(mockWebView).Once()
509510
510511 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -527,6 +528,7 @@ func setupPaneViewMocksNoWebView(
527528 mockOverlay.EXPECT().SetHexpand(true).Once()
528529 mockOverlay.EXPECT().SetVexpand(true).Once()
529530 mockOverlay.EXPECT().SetVisible(true).Once()
531+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
530532
531533 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
532534 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -25,7 +25,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
25 mockOverlay.EXPECT().SetHexpand(true).Once()25 mockOverlay.EXPECT().SetHexpand(true).Once()
26 mockOverlay.EXPECT().SetVexpand(true).Once()26 mockOverlay.EXPECT().SetVexpand(true).Once()
27 mockOverlay.EXPECT().SetVisible(true).Once()27 mockOverlay.EXPECT().SetVisible(true).Once()
28- mockWebView.EXPECT().SetVisible(true).Once()28+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
29 mockOverlay.EXPECT().SetChild(mockWebView).Once()29 mockOverlay.EXPECT().SetChild(mockWebView).Once()
30 30
31 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()31 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -59,6 +59,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
59 mockOverlay.EXPECT().SetHexpand(true).Once()59 mockOverlay.EXPECT().SetHexpand(true).Once()
60 mockOverlay.EXPECT().SetVexpand(true).Once()60 mockOverlay.EXPECT().SetVexpand(true).Once()
61 mockOverlay.EXPECT().SetVisible(true).Once()61 mockOverlay.EXPECT().SetVisible(true).Once()
62+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
62 // SetChild should NOT be called when webview is nil63 // SetChild should NOT be called when webview is nil
63 64
64 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()65 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -504,7 +505,7 @@ func setupPaneViewMocks(
504 mockOverlay.EXPECT().SetHexpand(true).Once()505 mockOverlay.EXPECT().SetHexpand(true).Once()
505 mockOverlay.EXPECT().SetVexpand(true).Once()506 mockOverlay.EXPECT().SetVexpand(true).Once()
506 mockOverlay.EXPECT().SetVisible(true).Once()507 mockOverlay.EXPECT().SetVisible(true).Once()
507- mockWebView.EXPECT().SetVisible(true).Once()508+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
508 mockOverlay.EXPECT().SetChild(mockWebView).Once()509 mockOverlay.EXPECT().SetChild(mockWebView).Once()
509 510
510 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()511 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -527,6 +528,7 @@ func setupPaneViewMocksNoWebView(
527 mockOverlay.EXPECT().SetHexpand(true).Once()528 mockOverlay.EXPECT().SetHexpand(true).Once()
528 mockOverlay.EXPECT().SetVexpand(true).Once()529 mockOverlay.EXPECT().SetVexpand(true).Once()
529 mockOverlay.EXPECT().SetVisible(true).Once()530 mockOverlay.EXPECT().SetVisible(true).Once()
531+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
530 532
531 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()533 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
532 mockBorderBox.EXPECT().SetCanFocus(false).Once()534 mockBorderBox.EXPECT().SetCanFocus(false).Once()
M internal/ui/component/stacked_pane_manager.go +1 -1
@@ -76,7 +76,7 @@ func (spm *StackedPaneManager) AddPaneToStack(
7676 Int("stack_size_before", stackedView.Count()).
7777 Msg("StackedPaneManager: inserting pane after active position")
7878
79- stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget)
79+ stackedView.InsertPaneAfter(ctx, currentActiveIndex, string(newPaneView.PaneID()), title, "", widget)
8080
8181 // Register the new pane in the TreeRenderer's tracking
8282 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
@@ -76,7 +76,7 @@ func (spm *StackedPaneManager) AddPaneToStack(
76 Int("stack_size_before", stackedView.Count()).76 Int("stack_size_before", stackedView.Count()).
77 Msg("StackedPaneManager: inserting pane after active position")77 Msg("StackedPaneManager: inserting pane after active position")
78 78
79- stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget)79+ stackedView.InsertPaneAfter(ctx, currentActiveIndex, string(newPaneView.PaneID()), title, "", widget)
80 80
81 // Register the new pane in the TreeRenderer's tracking81 // Register the new pane in the TreeRenderer's tracking
82 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)82 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
M internal/ui/component/workspace_view.go +9 -2
@@ -379,12 +379,19 @@ func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
379379 wv.updateSinglePaneModeInternal()
380380 }
381381
382-// UnregisterPaneView removes a PaneView from the tracking map.
383-// Use this when closing a pane incrementally.
382+// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
383+// This must be called when closing a pane to properly release GTK resources
384+// before destroying the WebView.
384385 func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
385386 wv.mu.Lock()
386387 defer wv.mu.Unlock()
387388
389+ // Call Cleanup on the PaneView to unparent widgets and clear callbacks
390+ // This must happen before WebView.Destroy() is called
391+ if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
392+ pv.Cleanup()
393+ }
394+
388395 delete(wv.paneViews, paneID)
389396 wv.updateSinglePaneModeInternal()
390397 }
@@ -379,12 +379,19 @@ func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
379 wv.updateSinglePaneModeInternal()379 wv.updateSinglePaneModeInternal()
380 }380 }
381 381
382-// UnregisterPaneView removes a PaneView from the tracking map.382+// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
383-// Use this when closing a pane incrementally.383+// This must be called when closing a pane to properly release GTK resources
384+// before destroying the WebView.
384 func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {385 func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
385 wv.mu.Lock()386 wv.mu.Lock()
386 defer wv.mu.Unlock()387 defer wv.mu.Unlock()
387 388
389+ // Call Cleanup on the PaneView to unparent widgets and clear callbacks
390+ // This must happen before WebView.Destroy() is called
391+ if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
392+ pv.Cleanup()
393+ }
394+
388 delete(wv.paneViews, paneID)395 delete(wv.paneViews, paneID)
389 wv.updateSinglePaneModeInternal()396 wv.updateSinglePaneModeInternal()
390 }397 }
M internal/ui/component/workspace_view_test.go +1 -0
@@ -41,6 +41,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
4141 mockOverlay.EXPECT().SetHexpand(true).Once()
4242 mockOverlay.EXPECT().SetVexpand(true).Once()
4343 mockOverlay.EXPECT().SetVisible(true).Once()
44+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
4445
4546 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
4647 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -41,6 +41,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
41 mockOverlay.EXPECT().SetHexpand(true).Once()41 mockOverlay.EXPECT().SetHexpand(true).Once()
42 mockOverlay.EXPECT().SetVexpand(true).Once()42 mockOverlay.EXPECT().SetVexpand(true).Once()
43 mockOverlay.EXPECT().SetVisible(true).Once()43 mockOverlay.EXPECT().SetVisible(true).Once()
44+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
44 45
45 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()46 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
46 mockBorderBox.EXPECT().SetCanFocus(false).Once()47 mockBorderBox.EXPECT().SetCanFocus(false).Once()
M internal/ui/coordinator/content.go +72 -39
@@ -63,6 +63,9 @@ type ContentCoordinator struct {
6363
6464 // ID generator for popup panes
6565 generateID func() string
66+
67+ // Idle inhibitor for fullscreen video playback
68+ idleInhibitor port.IdleInhibitor
6669 }
6770
6871 // NewContentCoordinator creates a new ContentCoordinator.
@@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler
105108 c.gestureActionHandler = handler
106109 }
107110
111+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
112+func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
113+ c.idleInhibitor = inhibitor
114+}
115+
108116 // EnsureWebView acquires or reuses a WebView for the given pane.
109117 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
110118 log := logging.FromContext(ctx)
@@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
166174 return c.handleLinkMiddleClick(ctx, paneID, uri)
167175 }
168176
177+ // Set up fullscreen handlers for idle inhibition
178+ c.setupFullscreenHandlers(ctx, paneID, wv)
179+
169180 // Set up popup handling for this WebView
170181 c.SetupPopupHandling(ctx, paneID, wv)
171182
@@ -461,7 +472,7 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
461472 if tr != nil {
462473 stackedView := tr.GetStackedViewForPane(string(paneID))
463474 if stackedView != nil {
464- c.updateStackedPaneTitle(ctx, ws, stackedView, paneID, title)
475+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
465476 }
466477 }
467478 }
@@ -485,32 +496,23 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
485496 // updateStackedPaneTitle updates the title of a pane in a StackedView.
486497 func (c *ContentCoordinator) updateStackedPaneTitle(
487498 ctx context.Context,
488- ws *entity.Workspace,
489499 sv *layout.StackedView,
490500 paneID entity.PaneID,
491501 title string,
492502 ) {
493503 log := logging.FromContext(ctx)
494504
495- if ws == nil {
505+ // Find the pane's index directly in the StackedView
506+ index := sv.FindPaneIndex(string(paneID))
507+ if index < 0 {
508+ log.Debug().
509+ Str("pane_id", string(paneID)).
510+ Msg("pane not found in StackedView for title update")
496511 return
497512 }
498513
499- paneNode := ws.FindPane(paneID)
500- if paneNode == nil {
501- return
502- }
503-
504- // If the pane is in a stacked parent, find its index
505- if paneNode.Parent != nil && paneNode.Parent.IsStacked {
506- for i, child := range paneNode.Parent.Children {
507- if child.Pane != nil && child.Pane.ID == paneID {
508- if err := sv.UpdateTitle(i, title); err != nil {
509- log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane title")
510- }
511- return
512- }
513- }
514+ if err := sv.UpdateTitle(index, title); err != nil {
515+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
514516 }
515517 }
516518
@@ -534,13 +536,13 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
534536 }
535537
536538 // Update StackedView favicon if this pane is in a stack
537- ws, wsView := c.getActiveWS()
539+ _, wsView := c.getActiveWS()
538540 if wsView != nil {
539541 tr := wsView.TreeRenderer()
540542 if tr != nil {
541543 stackedView := tr.GetStackedViewForPane(string(paneID))
542544 if stackedView != nil {
543- c.updateStackedPaneFavicon(ctx, ws, stackedView, paneID, favicon)
545+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
544546 }
545547 }
546548 }
@@ -555,32 +557,23 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
555557 // updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
556558 func (c *ContentCoordinator) updateStackedPaneFavicon(
557559 ctx context.Context,
558- ws *entity.Workspace,
559560 sv *layout.StackedView,
560561 paneID entity.PaneID,
561562 favicon *gdk.Texture,
562563 ) {
563564 log := logging.FromContext(ctx)
564565
565- if ws == nil {
566- return
567- }
568-
569- paneNode := ws.FindPane(paneID)
570- if paneNode == nil {
566+ // Find the pane's index directly in the StackedView
567+ index := sv.FindPaneIndex(string(paneID))
568+ if index < 0 {
569+ log.Debug().
570+ Str("pane_id", string(paneID)).
571+ Msg("pane not found in StackedView for favicon update")
571572 return
572573 }
573574
574- // If the pane is in a stacked parent, find its index
575- if paneNode.Parent != nil && paneNode.Parent.IsStacked {
576- for i, child := range paneNode.Parent.Children {
577- if child.Pane != nil && child.Pane.ID == paneID {
578- if err := sv.UpdateFaviconTexture(i, favicon); err != nil {
579- log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane favicon")
580- }
581- return
582- }
583- }
575+ if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
576+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
584577 }
585578 }
586579
@@ -613,13 +606,13 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
613606 }
614607
615608 // Update stacked pane favicon if applicable
616- ws, wsView := c.getActiveWS()
609+ _, wsView := c.getActiveWS()
617610 if wsView != nil {
618611 tr := wsView.TreeRenderer()
619612 if tr != nil {
620613 stackedView := tr.GetStackedViewForPane(string(paneID))
621614 if stackedView != nil {
622- c.updateStackedPaneFavicon(ctx, ws, stackedView, paneID, texture)
615+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
623616 }
624617 }
625618 }
@@ -628,7 +621,17 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
628621 // onLoadCommitted re-applies zoom when page content starts loading and records history.
629622 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
630623 // History is recorded here because the URI is guaranteed to be correct after commit.
624+// Also shows the WebView widget (it's hidden during creation to avoid white flash).
631625 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
626+ log := logging.FromContext(ctx)
627+
628+ // Show the WebView now that content is being painted
629+ // (WebViews are hidden on creation to avoid white flash)
630+ if inner := wv.Widget(); inner != nil {
631+ inner.SetVisible(true)
632+ log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed")
633+ }
634+
632635 url := wv.URI()
633636 if url == "" {
634637 return
@@ -988,6 +991,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
988991 wv.OnLinkMiddleClick = func(uri string) bool {
989992 return c.handleLinkMiddleClick(ctx, paneID, uri)
990993 }
994+
995+ // Fullscreen handlers for idle inhibition
996+ c.setupFullscreenHandlers(ctx, paneID, wv)
991997 }
992998
993999 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
@@ -1138,3 +1144,30 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
11381144
11391145 return true
11401146 }
1147+
1148+// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1149+func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1150+ log := logging.FromContext(ctx)
1151+
1152+ if wv == nil {
1153+ return
1154+ }
1155+
1156+ wv.OnEnterFullscreen = func() bool {
1157+ if c.idleInhibitor != nil {
1158+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1159+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1160+ }
1161+ }
1162+ return false // Allow fullscreen
1163+ }
1164+
1165+ wv.OnLeaveFullscreen = func() bool {
1166+ if c.idleInhibitor != nil {
1167+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1168+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1169+ }
1170+ }
1171+ return false // Allow leaving fullscreen
1172+ }
1173+}
@@ -63,6 +63,9 @@ type ContentCoordinator struct {
63 63
64 // ID generator for popup panes64 // ID generator for popup panes
65 generateID func() string65 generateID func() string
66+
67+ // Idle inhibitor for fullscreen video playback
68+ idleInhibitor port.IdleInhibitor
66 }69 }
67 70
68 // NewContentCoordinator creates a new ContentCoordinator.71 // NewContentCoordinator creates a new ContentCoordinator.
@@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler
105 c.gestureActionHandler = handler108 c.gestureActionHandler = handler
106 }109 }
107 110
111+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
112+func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
113+ c.idleInhibitor = inhibitor
114+}
115+
108 // EnsureWebView acquires or reuses a WebView for the given pane.116 // EnsureWebView acquires or reuses a WebView for the given pane.
109 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {117 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
110 log := logging.FromContext(ctx)118 log := logging.FromContext(ctx)
@@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
166 return c.handleLinkMiddleClick(ctx, paneID, uri)174 return c.handleLinkMiddleClick(ctx, paneID, uri)
167 }175 }
168 176
177+ // Set up fullscreen handlers for idle inhibition
178+ c.setupFullscreenHandlers(ctx, paneID, wv)
179+
169 // Set up popup handling for this WebView180 // Set up popup handling for this WebView
170 c.SetupPopupHandling(ctx, paneID, wv)181 c.SetupPopupHandling(ctx, paneID, wv)
171 182
@@ -461,7 +472,7 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
461 if tr != nil {472 if tr != nil {
462 stackedView := tr.GetStackedViewForPane(string(paneID))473 stackedView := tr.GetStackedViewForPane(string(paneID))
463 if stackedView != nil {474 if stackedView != nil {
464- c.updateStackedPaneTitle(ctx, ws, stackedView, paneID, title)475+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
465 }476 }
466 }477 }
467 }478 }
@@ -485,32 +496,23 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
485 // updateStackedPaneTitle updates the title of a pane in a StackedView.496 // updateStackedPaneTitle updates the title of a pane in a StackedView.
486 func (c *ContentCoordinator) updateStackedPaneTitle(497 func (c *ContentCoordinator) updateStackedPaneTitle(
487 ctx context.Context,498 ctx context.Context,
488- ws *entity.Workspace,
489 sv *layout.StackedView,499 sv *layout.StackedView,
490 paneID entity.PaneID,500 paneID entity.PaneID,
491 title string,501 title string,
492 ) {502 ) {
493 log := logging.FromContext(ctx)503 log := logging.FromContext(ctx)
494 504
495- if ws == nil {505+ // Find the pane's index directly in the StackedView
506+ index := sv.FindPaneIndex(string(paneID))
507+ if index < 0 {
508+ log.Debug().
509+ Str("pane_id", string(paneID)).
510+ Msg("pane not found in StackedView for title update")
496 return511 return
497 }512 }
498 513
499- paneNode := ws.FindPane(paneID)514+ if err := sv.UpdateTitle(index, title); err != nil {
500- if paneNode == nil {515+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
501- return
502- }
503-
504- // If the pane is in a stacked parent, find its index
505- if paneNode.Parent != nil && paneNode.Parent.IsStacked {
506- for i, child := range paneNode.Parent.Children {
507- if child.Pane != nil && child.Pane.ID == paneID {
508- if err := sv.UpdateTitle(i, title); err != nil {
509- log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane title")
510- }
511- return
512- }
513- }
514 }516 }
515 }517 }
516 518
@@ -534,13 +536,13 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
534 }536 }
535 537
536 // Update StackedView favicon if this pane is in a stack538 // Update StackedView favicon if this pane is in a stack
537- ws, wsView := c.getActiveWS()539+ _, wsView := c.getActiveWS()
538 if wsView != nil {540 if wsView != nil {
539 tr := wsView.TreeRenderer()541 tr := wsView.TreeRenderer()
540 if tr != nil {542 if tr != nil {
541 stackedView := tr.GetStackedViewForPane(string(paneID))543 stackedView := tr.GetStackedViewForPane(string(paneID))
542 if stackedView != nil {544 if stackedView != nil {
543- c.updateStackedPaneFavicon(ctx, ws, stackedView, paneID, favicon)545+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
544 }546 }
545 }547 }
546 }548 }
@@ -555,32 +557,23 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
555 // updateStackedPaneFavicon updates the favicon of a pane in a StackedView.557 // updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
556 func (c *ContentCoordinator) updateStackedPaneFavicon(558 func (c *ContentCoordinator) updateStackedPaneFavicon(
557 ctx context.Context,559 ctx context.Context,
558- ws *entity.Workspace,
559 sv *layout.StackedView,560 sv *layout.StackedView,
560 paneID entity.PaneID,561 paneID entity.PaneID,
561 favicon *gdk.Texture,562 favicon *gdk.Texture,
562 ) {563 ) {
563 log := logging.FromContext(ctx)564 log := logging.FromContext(ctx)
564 565
565- if ws == nil {566+ // Find the pane's index directly in the StackedView
566- return567+ index := sv.FindPaneIndex(string(paneID))
567- }568+ if index < 0 {
568-569+ log.Debug().
569- paneNode := ws.FindPane(paneID)570+ Str("pane_id", string(paneID)).
570- if paneNode == nil {571+ Msg("pane not found in StackedView for favicon update")
571 return572 return
572 }573 }
573 574
574- // If the pane is in a stacked parent, find its index575+ if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
575- if paneNode.Parent != nil && paneNode.Parent.IsStacked {576+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
576- for i, child := range paneNode.Parent.Children {
577- if child.Pane != nil && child.Pane.ID == paneID {
578- if err := sv.UpdateFaviconTexture(i, favicon); err != nil {
579- log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane favicon")
580- }
581- return
582- }
583- }
584 }577 }
585 }578 }
586 579
@@ -613,13 +606,13 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
613 }606 }
614 607
615 // Update stacked pane favicon if applicable608 // Update stacked pane favicon if applicable
616- ws, wsView := c.getActiveWS()609+ _, wsView := c.getActiveWS()
617 if wsView != nil {610 if wsView != nil {
618 tr := wsView.TreeRenderer()611 tr := wsView.TreeRenderer()
619 if tr != nil {612 if tr != nil {
620 stackedView := tr.GetStackedViewForPane(string(paneID))613 stackedView := tr.GetStackedViewForPane(string(paneID))
621 if stackedView != nil {614 if stackedView != nil {
622- c.updateStackedPaneFavicon(ctx, ws, stackedView, paneID, texture)615+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
623 }616 }
624 }617 }
625 }618 }
@@ -628,7 +621,17 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
628 // onLoadCommitted re-applies zoom when page content starts loading and records history.621 // onLoadCommitted re-applies zoom when page content starts loading and records history.
629 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.622 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
630 // History is recorded here because the URI is guaranteed to be correct after commit.623 // History is recorded here because the URI is guaranteed to be correct after commit.
624+// Also shows the WebView widget (it's hidden during creation to avoid white flash).
631 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {625 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
626+ log := logging.FromContext(ctx)
627+
628+ // Show the WebView now that content is being painted
629+ // (WebViews are hidden on creation to avoid white flash)
630+ if inner := wv.Widget(); inner != nil {
631+ inner.SetVisible(true)
632+ log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed")
633+ }
634+
632 url := wv.URI()635 url := wv.URI()
633 if url == "" {636 if url == "" {
634 return637 return
@@ -988,6 +991,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
988 wv.OnLinkMiddleClick = func(uri string) bool {991 wv.OnLinkMiddleClick = func(uri string) bool {
989 return c.handleLinkMiddleClick(ctx, paneID, uri)992 return c.handleLinkMiddleClick(ctx, paneID, uri)
990 }993 }
994+
995+ // Fullscreen handlers for idle inhibition
996+ c.setupFullscreenHandlers(ctx, paneID, wv)
991 }997 }
992 998
993 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.999 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
@@ -1138,3 +1144,30 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1138 1144
1139 return true1145 return true
1140 }1146 }
1147+
1148+// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1149+func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1150+ log := logging.FromContext(ctx)
1151+
1152+ if wv == nil {
1153+ return
1154+ }
1155+
1156+ wv.OnEnterFullscreen = func() bool {
1157+ if c.idleInhibitor != nil {
1158+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1159+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1160+ }
1161+ }
1162+ return false // Allow fullscreen
1163+ }
1164+
1165+ wv.OnLeaveFullscreen = func() bool {
1166+ if c.idleInhibitor != nil {
1167+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1168+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1169+ }
1170+ }
1171+ return false // Allow leaving fullscreen
1172+ }
1173+}
M internal/ui/coordinator/navigation.go +2 -2
@@ -63,8 +63,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
6363 paneID = string(pane.Pane.ID)
6464 // Track original URL for cross-domain redirect favicon caching
6565 c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
66- // Pre-load cached favicon for instant display in stacked pane title bar
67- c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
66+ // Pre-load cached favicon asynchronously (don't block navigation start)
67+ go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
6868 }
6969 }
7070
@@ -63,8 +63,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
63 paneID = string(pane.Pane.ID)63 paneID = string(pane.Pane.ID)
64 // Track original URL for cross-domain redirect favicon caching64 // Track original URL for cross-domain redirect favicon caching
65 c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)65 c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
66- // Pre-load cached favicon for instant display in stacked pane title bar66+ // Pre-load cached favicon asynchronously (don't block navigation start)
67- c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)67+ go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
68 }68 }
69 }69 }
70 70
M internal/ui/coordinator/workspace.go +2 -2
@@ -287,7 +287,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
287287
288288 // 4. Wrap the new PaneView in a StackedView
289289 newStackedView := layout.NewStackedView(factory)
290- newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
290+ newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
291291
292292 tr := wsView.TreeRenderer()
293293
@@ -461,7 +461,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
461461
462462 // 5. Wrap the new PaneView in a StackedView
463463 newStackedView := layout.NewStackedView(factory)
464- newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
464+ newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
465465
466466 orientation, existingFirst, ok := splitOrientation(direction)
467467 if !ok {
@@ -287,7 +287,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
287 287
288 // 4. Wrap the new PaneView in a StackedView288 // 4. Wrap the new PaneView in a StackedView
289 newStackedView := layout.NewStackedView(factory)289 newStackedView := layout.NewStackedView(factory)
290- newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())290+ newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
291 291
292 tr := wsView.TreeRenderer()292 tr := wsView.TreeRenderer()
293 293
@@ -461,7 +461,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
461 461
462 // 5. Wrap the new PaneView in a StackedView462 // 5. Wrap the new PaneView in a StackedView
463 newStackedView := layout.NewStackedView(factory)463 newStackedView := layout.NewStackedView(factory)
464- newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())464+ newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
465 465
466 orientation, existingFirst, ok := splitOrientation(direction)466 orientation, existingFirst, ok := splitOrientation(direction)
467 if !ok {467 if !ok {
M internal/ui/deps.go +1 -0
@@ -52,6 +52,7 @@ type Dependencies struct {
5252 Clipboard port.Clipboard
5353 FaviconService *favicon.Service
5454 FilterManager *filtering.Manager
55+ IdleInhibitor port.IdleInhibitor
5556 }
5657
5758 // Validate checks that all required dependencies are set.
@@ -52,6 +52,7 @@ type Dependencies struct {
52 Clipboard port.Clipboard52 Clipboard port.Clipboard
53 FaviconService *favicon.Service53 FaviconService *favicon.Service
54 FilterManager *filtering.Manager54 FilterManager *filtering.Manager
55+ IdleInhibitor port.IdleInhibitor
55 }56 }
56 57
57 // Validate checks that all required dependencies are set.58 // Validate checks that all required dependencies are set.
A internal/ui/input/global_shortcuts.go +159 -0
@@ -0,0 +1,159 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
14+// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
15+// to intercept shortcuts before they reach the WebView.
16+type GlobalShortcutHandler struct {
17+ controller *gtk.ShortcutController
18+ onAction ActionHandler
19+ ctx context.Context
20+
21+ // Keep references to callbacks to prevent GC from collecting them
22+ callbacks []gtk.ShortcutFunc
23+}
24+
25+// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
26+// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
27+func NewGlobalShortcutHandler(
28+ ctx context.Context,
29+ window *gtk.ApplicationWindow,
30+ onAction ActionHandler,
31+) *GlobalShortcutHandler {
32+ log := logging.FromContext(ctx)
33+ log.Debug().Msg("creating global shortcut handler")
34+
35+ h := &GlobalShortcutHandler{
36+ controller: gtk.NewShortcutController(),
37+ onAction: onAction,
38+ ctx: ctx,
39+ callbacks: make([]gtk.ShortcutFunc, 0),
40+ }
41+
42+ if h.controller == nil {
43+ log.Error().Msg("failed to create shortcut controller")
44+ return nil
45+ }
46+
47+ // Set global scope - this is the key to making shortcuts work
48+ // even when WebView has focus
49+ h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
50+
51+ // Register Alt+1 through Alt+9 for tab switching
52+ tabActions := []Action{
53+ ActionSwitchTabIndex1,
54+ ActionSwitchTabIndex2,
55+ ActionSwitchTabIndex3,
56+ ActionSwitchTabIndex4,
57+ ActionSwitchTabIndex5,
58+ ActionSwitchTabIndex6,
59+ ActionSwitchTabIndex7,
60+ ActionSwitchTabIndex8,
61+ ActionSwitchTabIndex9,
62+ }
63+
64+ for i, action := range tabActions {
65+ keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
66+ h.registerShortcut(keyval, gdk.AltMaskValue, action)
67+ log.Trace().
68+ Uint("keyval", keyval).
69+ Str("action", string(action)).
70+ Msg("registered global shortcut")
71+ }
72+
73+ // Alt+0 for tab 10
74+ h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
75+ log.Trace().
76+ Uint("keyval", uint(gdk.KEY_0)).
77+ Str("action", string(ActionSwitchTabIndex10)).
78+ Msg("registered global shortcut")
79+
80+ // Alt+Tab for switching to last active tab
81+ h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
82+ log.Trace().
83+ Uint("keyval", uint(gdk.KEY_Tab)).
84+ Str("action", string(ActionSwitchLastTab)).
85+ Msg("registered global shortcut")
86+
87+ // Attach to window
88+ window.AddController(&h.controller.EventController)
89+
90+ log.Debug().
91+ Int("shortcuts", len(h.callbacks)).
92+ Msg("global shortcut handler created and attached")
93+
94+ return h
95+}
96+
97+// registerShortcut creates and registers a single shortcut with the controller.
98+func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
99+ // Create trigger for this key combination
100+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
101+ if trigger == nil {
102+ logging.FromContext(h.ctx).Error().
103+ Uint("keyval", keyval).
104+ Msg("failed to create keyval trigger")
105+ return
106+ }
107+
108+ // Create callback action
109+ // We need to capture the action in the closure
110+ actionToDispatch := action
111+ callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
112+ log := logging.FromContext(h.ctx)
113+ log.Debug().
114+ Str("action", string(actionToDispatch)).
115+ Msg("global shortcut triggered")
116+
117+ if h.onAction != nil {
118+ if err := h.onAction(h.ctx, actionToDispatch); err != nil {
119+ log.Error().
120+ Err(err).
121+ Str("action", string(actionToDispatch)).
122+ Msg("global shortcut action failed")
123+ }
124+ }
125+ return true // Event consumed
126+ })
127+
128+ // Store callback reference to prevent GC
129+ h.callbacks = append(h.callbacks, callback)
130+
131+ // Create the shortcut action
132+ shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
133+ if shortcutAction == nil {
134+ logging.FromContext(h.ctx).Error().
135+ Uint("keyval", keyval).
136+ Msg("failed to create callback action")
137+ return
138+ }
139+
140+ // Create the shortcut combining trigger and action
141+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
142+ if shortcut == nil {
143+ logging.FromContext(h.ctx).Error().
144+ Uint("keyval", keyval).
145+ Msg("failed to create shortcut")
146+ return
147+ }
148+
149+ // Add to controller
150+ h.controller.AddShortcut(shortcut)
151+}
152+
153+// Detach removes the global shortcut handler from the window.
154+// Note: GTK handles cleanup when the widget is destroyed,
155+// but we clear our references here.
156+func (h *GlobalShortcutHandler) Detach() {
157+ h.controller = nil
158+ h.callbacks = nil
159+}
@@ -0,0 +1,159 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+)
12+
13+// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
14+// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
15+// to intercept shortcuts before they reach the WebView.
16+type GlobalShortcutHandler struct {
17+ controller *gtk.ShortcutController
18+ onAction ActionHandler
19+ ctx context.Context
20+
21+ // Keep references to callbacks to prevent GC from collecting them
22+ callbacks []gtk.ShortcutFunc
23+}
24+
25+// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
26+// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
27+func NewGlobalShortcutHandler(
28+ ctx context.Context,
29+ window *gtk.ApplicationWindow,
30+ onAction ActionHandler,
31+) *GlobalShortcutHandler {
32+ log := logging.FromContext(ctx)
33+ log.Debug().Msg("creating global shortcut handler")
34+
35+ h := &GlobalShortcutHandler{
36+ controller: gtk.NewShortcutController(),
37+ onAction: onAction,
38+ ctx: ctx,
39+ callbacks: make([]gtk.ShortcutFunc, 0),
40+ }
41+
42+ if h.controller == nil {
43+ log.Error().Msg("failed to create shortcut controller")
44+ return nil
45+ }
46+
47+ // Set global scope - this is the key to making shortcuts work
48+ // even when WebView has focus
49+ h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
50+
51+ // Register Alt+1 through Alt+9 for tab switching
52+ tabActions := []Action{
53+ ActionSwitchTabIndex1,
54+ ActionSwitchTabIndex2,
55+ ActionSwitchTabIndex3,
56+ ActionSwitchTabIndex4,
57+ ActionSwitchTabIndex5,
58+ ActionSwitchTabIndex6,
59+ ActionSwitchTabIndex7,
60+ ActionSwitchTabIndex8,
61+ ActionSwitchTabIndex9,
62+ }
63+
64+ for i, action := range tabActions {
65+ keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
66+ h.registerShortcut(keyval, gdk.AltMaskValue, action)
67+ log.Trace().
68+ Uint("keyval", keyval).
69+ Str("action", string(action)).
70+ Msg("registered global shortcut")
71+ }
72+
73+ // Alt+0 for tab 10
74+ h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
75+ log.Trace().
76+ Uint("keyval", uint(gdk.KEY_0)).
77+ Str("action", string(ActionSwitchTabIndex10)).
78+ Msg("registered global shortcut")
79+
80+ // Alt+Tab for switching to last active tab
81+ h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
82+ log.Trace().
83+ Uint("keyval", uint(gdk.KEY_Tab)).
84+ Str("action", string(ActionSwitchLastTab)).
85+ Msg("registered global shortcut")
86+
87+ // Attach to window
88+ window.AddController(&h.controller.EventController)
89+
90+ log.Debug().
91+ Int("shortcuts", len(h.callbacks)).
92+ Msg("global shortcut handler created and attached")
93+
94+ return h
95+}
96+
97+// registerShortcut creates and registers a single shortcut with the controller.
98+func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
99+ // Create trigger for this key combination
100+ trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
101+ if trigger == nil {
102+ logging.FromContext(h.ctx).Error().
103+ Uint("keyval", keyval).
104+ Msg("failed to create keyval trigger")
105+ return
106+ }
107+
108+ // Create callback action
109+ // We need to capture the action in the closure
110+ actionToDispatch := action
111+ callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
112+ log := logging.FromContext(h.ctx)
113+ log.Debug().
114+ Str("action", string(actionToDispatch)).
115+ Msg("global shortcut triggered")
116+
117+ if h.onAction != nil {
118+ if err := h.onAction(h.ctx, actionToDispatch); err != nil {
119+ log.Error().
120+ Err(err).
121+ Str("action", string(actionToDispatch)).
122+ Msg("global shortcut action failed")
123+ }
124+ }
125+ return true // Event consumed
126+ })
127+
128+ // Store callback reference to prevent GC
129+ h.callbacks = append(h.callbacks, callback)
130+
131+ // Create the shortcut action
132+ shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
133+ if shortcutAction == nil {
134+ logging.FromContext(h.ctx).Error().
135+ Uint("keyval", keyval).
136+ Msg("failed to create callback action")
137+ return
138+ }
139+
140+ // Create the shortcut combining trigger and action
141+ shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
142+ if shortcut == nil {
143+ logging.FromContext(h.ctx).Error().
144+ Uint("keyval", keyval).
145+ Msg("failed to create shortcut")
146+ return
147+ }
148+
149+ // Add to controller
150+ h.controller.AddShortcut(shortcut)
151+}
152+
153+// Detach removes the global shortcut handler from the window.
154+// Note: GTK handles cleanup when the widget is destroyed,
155+// but we clear our references here.
156+func (h *GlobalShortcutHandler) Detach() {
157+ h.controller = nil
158+ h.callbacks = nil
159+}
M internal/ui/input/shortcuts.go +6 -12
@@ -291,18 +291,12 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() {
291291 }
292292
293293 func (s *ShortcutSet) registerTabSwitchShortcuts() {
294- s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1
295- s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2
296- s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3
297- s.Global[KeyBinding{uint(gdk.KEY_4), ModAlt}] = ActionSwitchTabIndex4
298- s.Global[KeyBinding{uint(gdk.KEY_5), ModAlt}] = ActionSwitchTabIndex5
299- s.Global[KeyBinding{uint(gdk.KEY_6), ModAlt}] = ActionSwitchTabIndex6
300- s.Global[KeyBinding{uint(gdk.KEY_7), ModAlt}] = ActionSwitchTabIndex7
301- s.Global[KeyBinding{uint(gdk.KEY_8), ModAlt}] = ActionSwitchTabIndex8
302- s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
303- s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10
304-
305- s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
294+ // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
295+ // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
296+ // This is necessary because WebKitGTK's WebView consumes these key events
297+ // before they reach the EventControllerKey in capture phase.
298+ //
299+ // Only Alt+Shift+Tab remains here as a fallback binding.
306300 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
307301 }
308302
@@ -291,18 +291,12 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() {
291 }291 }
292 292
293 func (s *ShortcutSet) registerTabSwitchShortcuts() {293 func (s *ShortcutSet) registerTabSwitchShortcuts() {
294- s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1294+ // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
295- s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2295+ // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
296- s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3296+ // This is necessary because WebKitGTK's WebView consumes these key events
297- s.Global[KeyBinding{uint(gdk.KEY_4), ModAlt}] = ActionSwitchTabIndex4297+ // before they reach the EventControllerKey in capture phase.
298- s.Global[KeyBinding{uint(gdk.KEY_5), ModAlt}] = ActionSwitchTabIndex5298+ //
299- s.Global[KeyBinding{uint(gdk.KEY_6), ModAlt}] = ActionSwitchTabIndex6299+ // Only Alt+Shift+Tab remains here as a fallback binding.
300- s.Global[KeyBinding{uint(gdk.KEY_7), ModAlt}] = ActionSwitchTabIndex7
301- s.Global[KeyBinding{uint(gdk.KEY_8), ModAlt}] = ActionSwitchTabIndex8
302- s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
303- s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10
304-
305- s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
306 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab300 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
307 }301 }
308 302
M internal/ui/layout/stacked.go +19 -2
@@ -21,6 +21,7 @@ const stackedTitleMaxWidthChars = 30
2121
2222 // stackedPane represents a single pane within a stacked container.
2323 type stackedPane struct {
24+ paneID string // unique identifier for this pane
2425 titleBar BoxWidget // horizontal box with favicon + title label
2526 container Widget // the pane view widget
2627 title string
@@ -58,7 +59,7 @@ func NewStackedView(factory WidgetFactory) *StackedView {
5859
5960 // AddPane adds a new pane to the stack.
6061 // The new pane becomes active (visible).
61-func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int {
62+func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
6263 log := logging.FromContext(ctx)
6364 sv.mu.Lock()
6465 defer sv.mu.Unlock()
@@ -101,6 +102,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
101102 titleButton.SetHexpand(true) // But fill horizontal space
102103
103104 pane := &stackedPane{
105+ paneID: paneID,
104106 titleBar: titleBar,
105107 container: container,
106108 title: title,
@@ -148,7 +150,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
148150 // The new pane becomes active (visible).
149151 // Returns the index where the pane was inserted.
150152 func (sv *StackedView) InsertPaneAfter(
151- ctx context.Context, afterIndex int, title, faviconIconName string, container Widget,
153+ ctx context.Context, afterIndex int, paneID, title, faviconIconName string, container Widget,
152154 ) int {
153155 log := logging.FromContext(ctx)
154156 sv.mu.Lock()
@@ -202,6 +204,7 @@ func (sv *StackedView) InsertPaneAfter(
202204 titleButton.SetHexpand(true)
203205
204206 pane := &stackedPane{
207+ paneID: paneID,
205208 titleBar: titleBar,
206209 container: container,
207210 title: title,
@@ -420,6 +423,20 @@ func (sv *StackedView) Count() int {
420423 return len(sv.panes)
421424 }
422425
426+// FindPaneIndex returns the index of the pane with the given ID.
427+// Returns -1 if not found.
428+func (sv *StackedView) FindPaneIndex(paneID string) int {
429+ sv.mu.RLock()
430+ defer sv.mu.RUnlock()
431+
432+ for i, pane := range sv.panes {
433+ if pane.paneID == paneID {
434+ return i
435+ }
436+ }
437+ return -1
438+}
439+
423440 // SetOnActivate sets the callback for when a pane is activated via title bar click.
424441 func (sv *StackedView) SetOnActivate(fn func(index int)) {
425442 sv.mu.Lock()
@@ -21,6 +21,7 @@ const stackedTitleMaxWidthChars = 30
21 21
22 // stackedPane represents a single pane within a stacked container.22 // stackedPane represents a single pane within a stacked container.
23 type stackedPane struct {23 type stackedPane struct {
24+ paneID string // unique identifier for this pane
24 titleBar BoxWidget // horizontal box with favicon + title label25 titleBar BoxWidget // horizontal box with favicon + title label
25 container Widget // the pane view widget26 container Widget // the pane view widget
26 title string27 title string
@@ -58,7 +59,7 @@ func NewStackedView(factory WidgetFactory) *StackedView {
58 59
59 // AddPane adds a new pane to the stack.60 // AddPane adds a new pane to the stack.
60 // The new pane becomes active (visible).61 // The new pane becomes active (visible).
61-func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int {62+func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
62 log := logging.FromContext(ctx)63 log := logging.FromContext(ctx)
63 sv.mu.Lock()64 sv.mu.Lock()
64 defer sv.mu.Unlock()65 defer sv.mu.Unlock()
@@ -101,6 +102,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
101 titleButton.SetHexpand(true) // But fill horizontal space102 titleButton.SetHexpand(true) // But fill horizontal space
102 103
103 pane := &stackedPane{104 pane := &stackedPane{
105+ paneID: paneID,
104 titleBar: titleBar,106 titleBar: titleBar,
105 container: container,107 container: container,
106 title: title,108 title: title,
@@ -148,7 +150,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
148 // The new pane becomes active (visible).150 // The new pane becomes active (visible).
149 // Returns the index where the pane was inserted.151 // Returns the index where the pane was inserted.
150 func (sv *StackedView) InsertPaneAfter(152 func (sv *StackedView) InsertPaneAfter(
151- ctx context.Context, afterIndex int, title, faviconIconName string, container Widget,153+ ctx context.Context, afterIndex int, paneID, title, faviconIconName string, container Widget,
152 ) int {154 ) int {
153 log := logging.FromContext(ctx)155 log := logging.FromContext(ctx)
154 sv.mu.Lock()156 sv.mu.Lock()
@@ -202,6 +204,7 @@ func (sv *StackedView) InsertPaneAfter(
202 titleButton.SetHexpand(true)204 titleButton.SetHexpand(true)
203 205
204 pane := &stackedPane{206 pane := &stackedPane{
207+ paneID: paneID,
205 titleBar: titleBar,208 titleBar: titleBar,
206 container: container,209 container: container,
207 title: title,210 title: title,
@@ -420,6 +423,20 @@ func (sv *StackedView) Count() int {
420 return len(sv.panes)423 return len(sv.panes)
421 }424 }
422 425
426+// FindPaneIndex returns the index of the pane with the given ID.
427+// Returns -1 if not found.
428+func (sv *StackedView) FindPaneIndex(paneID string) int {
429+ sv.mu.RLock()
430+ defer sv.mu.RUnlock()
431+
432+ for i, pane := range sv.panes {
433+ if pane.paneID == paneID {
434+ return i
435+ }
436+ }
437+ return -1
438+}
439+
423 // SetOnActivate sets the callback for when a pane is activated via title bar click.440 // SetOnActivate sets the callback for when a pane is activated via title bar click.
424 func (sv *StackedView) SetOnActivate(fn func(index int)) {441 func (sv *StackedView) SetOnActivate(fn func(index int)) {
425 sv.mu.Lock()442 sv.mu.Lock()
M internal/ui/layout/stacked_test.go +31 -31
@@ -99,7 +99,7 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) {
9999 sv := layout.NewStackedView(mockFactory)
100100
101101 // Act
102- index := sv.AddPane(ctx, "Test Page", "web-browser-symbolic", mockContainer)
102+ index := sv.AddPane(ctx, "pane-1", "Test Page", "web-browser-symbolic", mockContainer)
103103
104104 // Assert
105105 assert.Equal(t, 0, index)
@@ -139,8 +139,8 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) {
139139 sv := layout.NewStackedView(mockFactory)
140140
141141 // Act
142- sv.AddPane(ctx, "Page 1", "", mockContainer1)
143- index := sv.AddPane(ctx, "Page 2", "", mockContainer2)
142+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
143+ index := sv.AddPane(ctx, "pane-2", "Page 2", "", mockContainer2)
144144
145145 // Assert
146146 assert.Equal(t, 1, index)
@@ -175,9 +175,9 @@ func TestRemovePane_MiddlePane(t *testing.T) {
175175 }
176176
177177 sv := layout.NewStackedView(mockFactory)
178- sv.AddPane(ctx, "Page 1", "", containers[0])
179- sv.AddPane(ctx, "Page 2", "", containers[1])
180- sv.AddPane(ctx, "Page 3", "", containers[2])
178+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
179+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
180+ sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2])
181181
182182 // Remove middle pane (index 1)
183183 // The parent of titleBar is the button widget
@@ -206,7 +206,7 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
206206 mockTitleBar.EXPECT().AddCssClass("active").Once()
207207
208208 sv := layout.NewStackedView(mockFactory)
209- sv.AddPane(ctx, "Only Page", "", mockContainer)
209+ sv.AddPane(ctx, "pane-1", "Only Page", "", mockContainer)
210210
211211 // Act
212212 err := sv.RemovePane(ctx, 0)
@@ -243,7 +243,7 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) {
243243 mockTitleBar.EXPECT().AddCssClass("active").Once()
244244
245245 sv := layout.NewStackedView(mockFactory)
246- sv.AddPane(ctx, "Page", "", mockContainer)
246+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
247247
248248 // Act
249249 err := sv.RemovePane(ctx, 5)
@@ -276,8 +276,8 @@ func TestSetActive_ValidIndex(t *testing.T) {
276276 }
277277
278278 sv := layout.NewStackedView(mockFactory)
279- sv.AddPane(ctx, "Page 1", "", containers[0])
280- sv.AddPane(ctx, "Page 2", "", containers[1])
279+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
280+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
281281
282282 // Act - switch back to first pane
283283 err := sv.SetActive(ctx, 0)
@@ -301,7 +301,7 @@ func TestSetActive_OutOfBounds(t *testing.T) {
301301 mockTitleBar.EXPECT().AddCssClass("active").Once()
302302
303303 sv := layout.NewStackedView(mockFactory)
304- sv.AddPane(ctx, "Page", "", mockContainer)
304+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
305305
306306 // Act
307307 err := sv.SetActive(ctx, 5)
@@ -334,7 +334,7 @@ func TestUpdateTitle(t *testing.T) {
334334 mockTitleBar.EXPECT().AddCssClass("active").Once()
335335
336336 sv := layout.NewStackedView(mockFactory)
337- sv.AddPane(ctx, "Old Title", "", mockContainer)
337+ sv.AddPane(ctx, "pane-1", "Old Title", "", mockContainer)
338338
339339 mockLabel.EXPECT().SetText("New Title").Once()
340340
@@ -368,7 +368,7 @@ func TestUpdateFavicon(t *testing.T) {
368368 mockTitleBar.EXPECT().AddCssClass("active").Once()
369369
370370 sv := layout.NewStackedView(mockFactory)
371- sv.AddPane(ctx, "Page", "", mockContainer)
371+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
372372
373373 mockFavicon.EXPECT().SetFromIconName("new-icon").Once()
374374
@@ -393,7 +393,7 @@ func TestGetContainer(t *testing.T) {
393393 mockTitleBar.EXPECT().AddCssClass("active").Once()
394394
395395 sv := layout.NewStackedView(mockFactory)
396- sv.AddPane(ctx, "Page", "", mockContainer)
396+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
397397
398398 // Act
399399 container, err := sv.GetContainer(0)
@@ -452,8 +452,8 @@ func TestNavigateNext_WrapsAround(t *testing.T) {
452452 }
453453
454454 sv := layout.NewStackedView(mockFactory)
455- sv.AddPane(ctx, "Page 1", "", containers[0])
456- sv.AddPane(ctx, "Page 2", "", containers[1])
455+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
456+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
457457
458458 // Currently at index 1 (last added)
459459 assert.Equal(t, 1, sv.ActiveIndex())
@@ -490,8 +490,8 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) {
490490 }
491491
492492 sv := layout.NewStackedView(mockFactory)
493- sv.AddPane(ctx, "Page 1", "", containers[0])
494- sv.AddPane(ctx, "Page 2", "", containers[1])
493+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
494+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
495495
496496 // Set to first pane
497497 sv.SetActive(ctx, 0)
@@ -617,7 +617,7 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
617617 mockTitleBar1.EXPECT().AddCssClass("active").Once()
618618
619619 sv := layout.NewStackedView(mockFactory)
620- sv.AddPane(ctx, "Page 1", "", mockContainer1)
620+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
621621
622622 // Second pane - inserted at beginning (afterIndex=-1)
623623 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
@@ -632,7 +632,7 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
632632 mockTitleBar2.EXPECT().AddCssClass("active").Once()
633633
634634 // Act
635- index := sv.InsertPaneAfter(ctx, -1, "Page 0", "", mockContainer2)
635+ index := sv.InsertPaneAfter(ctx, -1, "pane-0", "Page 0", "", mockContainer2)
636636
637637 // Assert
638638 assert.Equal(t, 0, index)
@@ -658,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
658658 }
659659
660660 sv := layout.NewStackedView(mockFactory)
661- sv.AddPane(ctx, "Page 1", "", containers[0])
662- sv.AddPane(ctx, "Page 2", "", containers[1])
661+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
662+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
663663
664664 // Set active to first pane
665665 require.NoError(t, sv.SetActive(ctx, 0))
@@ -672,7 +672,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
672672 mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
673673
674674 // Act
675- index := sv.InsertPaneAfter(ctx, 0, "Page 1.5", "", mockContainer3)
675+ index := sv.InsertPaneAfter(ctx, 0, "pane-1.5", "Page 1.5", "", mockContainer3)
676676
677677 // Assert
678678 assert.Equal(t, 1, index)
@@ -695,7 +695,7 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
695695 mockTitleBar1.EXPECT().AddCssClass("active").Once()
696696
697697 sv := layout.NewStackedView(mockFactory)
698- sv.AddPane(ctx, "Page 1", "", mockContainer1)
698+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
699699
700700 // Insert after last pane (afterIndex=0, becomes index 1)
701701 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
@@ -709,7 +709,7 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
709709 mockTitleBar2.EXPECT().AddCssClass("active").Once()
710710
711711 // Act
712- index := sv.InsertPaneAfter(ctx, 0, "Page 2", "", mockContainer2)
712+ index := sv.InsertPaneAfter(ctx, 0, "pane-2", "Page 2", "", mockContainer2)
713713
714714 // Assert
715715 assert.Equal(t, 1, index)
@@ -742,9 +742,9 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
742742 }
743743
744744 sv := layout.NewStackedView(mockFactory)
745- sv.AddPane(ctx, "A", "", containers[0]) // index 0
746- sv.AddPane(ctx, "B", "", containers[1]) // index 1
747- sv.AddPane(ctx, "C", "", containers[2]) // index 2
745+ sv.AddPane(ctx, "pane-a", "A", "", containers[0]) // index 0
746+ sv.AddPane(ctx, "pane-b", "B", "", containers[1]) // index 1
747+ sv.AddPane(ctx, "pane-c", "C", "", containers[2]) // index 2
748748
749749 // Navigate to pane B (index 1)
750750 require.NoError(t, sv.SetActive(ctx, 1))
@@ -757,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
757757 mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
758758
759759 // Act
760- index := sv.InsertPaneAfter(ctx, 1, "D", "", mockContainer4)
760+ index := sv.InsertPaneAfter(ctx, 1, "pane-d", "D", "", mockContainer4)
761761
762762 // Assert: Order should be A(0), B(1), D(2), C(3)
763763 assert.Equal(t, 2, index)
@@ -791,7 +791,7 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
791791 mockTitleBar1.EXPECT().AddCssClass("active").Once()
792792
793793 sv := layout.NewStackedView(mockFactory)
794- sv.AddPane(ctx, "Page 1", "", mockContainer1)
794+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
795795
796796 // Try to insert at invalid index (100) - should clamp to end
797797 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
@@ -805,7 +805,7 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
805805 mockTitleBar2.EXPECT().AddCssClass("active").Once()
806806
807807 // Act - afterIndex=100 should be clamped to 0 (last valid index)
808- index := sv.InsertPaneAfter(ctx, 100, "Page 2", "", mockContainer2)
808+ index := sv.InsertPaneAfter(ctx, 100, "pane-2", "Page 2", "", mockContainer2)
809809
810810 // Assert
811811 assert.Equal(t, 1, index)
@@ -99,7 +99,7 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) {
99 sv := layout.NewStackedView(mockFactory)99 sv := layout.NewStackedView(mockFactory)
100 100
101 // Act101 // Act
102- index := sv.AddPane(ctx, "Test Page", "web-browser-symbolic", mockContainer)102+ index := sv.AddPane(ctx, "pane-1", "Test Page", "web-browser-symbolic", mockContainer)
103 103
104 // Assert104 // Assert
105 assert.Equal(t, 0, index)105 assert.Equal(t, 0, index)
@@ -139,8 +139,8 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) {
139 sv := layout.NewStackedView(mockFactory)139 sv := layout.NewStackedView(mockFactory)
140 140
141 // Act141 // Act
142- sv.AddPane(ctx, "Page 1", "", mockContainer1)142+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
143- index := sv.AddPane(ctx, "Page 2", "", mockContainer2)143+ index := sv.AddPane(ctx, "pane-2", "Page 2", "", mockContainer2)
144 144
145 // Assert145 // Assert
146 assert.Equal(t, 1, index)146 assert.Equal(t, 1, index)
@@ -175,9 +175,9 @@ func TestRemovePane_MiddlePane(t *testing.T) {
175 }175 }
176 176
177 sv := layout.NewStackedView(mockFactory)177 sv := layout.NewStackedView(mockFactory)
178- sv.AddPane(ctx, "Page 1", "", containers[0])178+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
179- sv.AddPane(ctx, "Page 2", "", containers[1])179+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
180- sv.AddPane(ctx, "Page 3", "", containers[2])180+ sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2])
181 181
182 // Remove middle pane (index 1)182 // Remove middle pane (index 1)
183 // The parent of titleBar is the button widget183 // The parent of titleBar is the button widget
@@ -206,7 +206,7 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
206 mockTitleBar.EXPECT().AddCssClass("active").Once()206 mockTitleBar.EXPECT().AddCssClass("active").Once()
207 207
208 sv := layout.NewStackedView(mockFactory)208 sv := layout.NewStackedView(mockFactory)
209- sv.AddPane(ctx, "Only Page", "", mockContainer)209+ sv.AddPane(ctx, "pane-1", "Only Page", "", mockContainer)
210 210
211 // Act211 // Act
212 err := sv.RemovePane(ctx, 0)212 err := sv.RemovePane(ctx, 0)
@@ -243,7 +243,7 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) {
243 mockTitleBar.EXPECT().AddCssClass("active").Once()243 mockTitleBar.EXPECT().AddCssClass("active").Once()
244 244
245 sv := layout.NewStackedView(mockFactory)245 sv := layout.NewStackedView(mockFactory)
246- sv.AddPane(ctx, "Page", "", mockContainer)246+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
247 247
248 // Act248 // Act
249 err := sv.RemovePane(ctx, 5)249 err := sv.RemovePane(ctx, 5)
@@ -276,8 +276,8 @@ func TestSetActive_ValidIndex(t *testing.T) {
276 }276 }
277 277
278 sv := layout.NewStackedView(mockFactory)278 sv := layout.NewStackedView(mockFactory)
279- sv.AddPane(ctx, "Page 1", "", containers[0])279+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
280- sv.AddPane(ctx, "Page 2", "", containers[1])280+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
281 281
282 // Act - switch back to first pane282 // Act - switch back to first pane
283 err := sv.SetActive(ctx, 0)283 err := sv.SetActive(ctx, 0)
@@ -301,7 +301,7 @@ func TestSetActive_OutOfBounds(t *testing.T) {
301 mockTitleBar.EXPECT().AddCssClass("active").Once()301 mockTitleBar.EXPECT().AddCssClass("active").Once()
302 302
303 sv := layout.NewStackedView(mockFactory)303 sv := layout.NewStackedView(mockFactory)
304- sv.AddPane(ctx, "Page", "", mockContainer)304+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
305 305
306 // Act306 // Act
307 err := sv.SetActive(ctx, 5)307 err := sv.SetActive(ctx, 5)
@@ -334,7 +334,7 @@ func TestUpdateTitle(t *testing.T) {
334 mockTitleBar.EXPECT().AddCssClass("active").Once()334 mockTitleBar.EXPECT().AddCssClass("active").Once()
335 335
336 sv := layout.NewStackedView(mockFactory)336 sv := layout.NewStackedView(mockFactory)
337- sv.AddPane(ctx, "Old Title", "", mockContainer)337+ sv.AddPane(ctx, "pane-1", "Old Title", "", mockContainer)
338 338
339 mockLabel.EXPECT().SetText("New Title").Once()339 mockLabel.EXPECT().SetText("New Title").Once()
340 340
@@ -368,7 +368,7 @@ func TestUpdateFavicon(t *testing.T) {
368 mockTitleBar.EXPECT().AddCssClass("active").Once()368 mockTitleBar.EXPECT().AddCssClass("active").Once()
369 369
370 sv := layout.NewStackedView(mockFactory)370 sv := layout.NewStackedView(mockFactory)
371- sv.AddPane(ctx, "Page", "", mockContainer)371+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
372 372
373 mockFavicon.EXPECT().SetFromIconName("new-icon").Once()373 mockFavicon.EXPECT().SetFromIconName("new-icon").Once()
374 374
@@ -393,7 +393,7 @@ func TestGetContainer(t *testing.T) {
393 mockTitleBar.EXPECT().AddCssClass("active").Once()393 mockTitleBar.EXPECT().AddCssClass("active").Once()
394 394
395 sv := layout.NewStackedView(mockFactory)395 sv := layout.NewStackedView(mockFactory)
396- sv.AddPane(ctx, "Page", "", mockContainer)396+ sv.AddPane(ctx, "pane-1", "Page", "", mockContainer)
397 397
398 // Act398 // Act
399 container, err := sv.GetContainer(0)399 container, err := sv.GetContainer(0)
@@ -452,8 +452,8 @@ func TestNavigateNext_WrapsAround(t *testing.T) {
452 }452 }
453 453
454 sv := layout.NewStackedView(mockFactory)454 sv := layout.NewStackedView(mockFactory)
455- sv.AddPane(ctx, "Page 1", "", containers[0])455+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
456- sv.AddPane(ctx, "Page 2", "", containers[1])456+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
457 457
458 // Currently at index 1 (last added)458 // Currently at index 1 (last added)
459 assert.Equal(t, 1, sv.ActiveIndex())459 assert.Equal(t, 1, sv.ActiveIndex())
@@ -490,8 +490,8 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) {
490 }490 }
491 491
492 sv := layout.NewStackedView(mockFactory)492 sv := layout.NewStackedView(mockFactory)
493- sv.AddPane(ctx, "Page 1", "", containers[0])493+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
494- sv.AddPane(ctx, "Page 2", "", containers[1])494+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
495 495
496 // Set to first pane496 // Set to first pane
497 sv.SetActive(ctx, 0)497 sv.SetActive(ctx, 0)
@@ -617,7 +617,7 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
617 mockTitleBar1.EXPECT().AddCssClass("active").Once()617 mockTitleBar1.EXPECT().AddCssClass("active").Once()
618 618
619 sv := layout.NewStackedView(mockFactory)619 sv := layout.NewStackedView(mockFactory)
620- sv.AddPane(ctx, "Page 1", "", mockContainer1)620+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
621 621
622 // Second pane - inserted at beginning (afterIndex=-1)622 // Second pane - inserted at beginning (afterIndex=-1)
623 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)623 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
@@ -632,7 +632,7 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
632 mockTitleBar2.EXPECT().AddCssClass("active").Once()632 mockTitleBar2.EXPECT().AddCssClass("active").Once()
633 633
634 // Act634 // Act
635- index := sv.InsertPaneAfter(ctx, -1, "Page 0", "", mockContainer2)635+ index := sv.InsertPaneAfter(ctx, -1, "pane-0", "Page 0", "", mockContainer2)
636 636
637 // Assert637 // Assert
638 assert.Equal(t, 0, index)638 assert.Equal(t, 0, index)
@@ -658,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
658 }658 }
659 659
660 sv := layout.NewStackedView(mockFactory)660 sv := layout.NewStackedView(mockFactory)
661- sv.AddPane(ctx, "Page 1", "", containers[0])661+ sv.AddPane(ctx, "pane-1", "Page 1", "", containers[0])
662- sv.AddPane(ctx, "Page 2", "", containers[1])662+ sv.AddPane(ctx, "pane-2", "Page 2", "", containers[1])
663 663
664 // Set active to first pane664 // Set active to first pane
665 require.NoError(t, sv.SetActive(ctx, 0))665 require.NoError(t, sv.SetActive(ctx, 0))
@@ -672,7 +672,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
672 mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()672 mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
673 673
674 // Act674 // Act
675- index := sv.InsertPaneAfter(ctx, 0, "Page 1.5", "", mockContainer3)675+ index := sv.InsertPaneAfter(ctx, 0, "pane-1.5", "Page 1.5", "", mockContainer3)
676 676
677 // Assert677 // Assert
678 assert.Equal(t, 1, index)678 assert.Equal(t, 1, index)
@@ -695,7 +695,7 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
695 mockTitleBar1.EXPECT().AddCssClass("active").Once()695 mockTitleBar1.EXPECT().AddCssClass("active").Once()
696 696
697 sv := layout.NewStackedView(mockFactory)697 sv := layout.NewStackedView(mockFactory)
698- sv.AddPane(ctx, "Page 1", "", mockContainer1)698+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
699 699
700 // Insert after last pane (afterIndex=0, becomes index 1)700 // Insert after last pane (afterIndex=0, becomes index 1)
701 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)701 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
@@ -709,7 +709,7 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
709 mockTitleBar2.EXPECT().AddCssClass("active").Once()709 mockTitleBar2.EXPECT().AddCssClass("active").Once()
710 710
711 // Act711 // Act
712- index := sv.InsertPaneAfter(ctx, 0, "Page 2", "", mockContainer2)712+ index := sv.InsertPaneAfter(ctx, 0, "pane-2", "Page 2", "", mockContainer2)
713 713
714 // Assert714 // Assert
715 assert.Equal(t, 1, index)715 assert.Equal(t, 1, index)
@@ -742,9 +742,9 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
742 }742 }
743 743
744 sv := layout.NewStackedView(mockFactory)744 sv := layout.NewStackedView(mockFactory)
745- sv.AddPane(ctx, "A", "", containers[0]) // index 0745+ sv.AddPane(ctx, "pane-a", "A", "", containers[0]) // index 0
746- sv.AddPane(ctx, "B", "", containers[1]) // index 1746+ sv.AddPane(ctx, "pane-b", "B", "", containers[1]) // index 1
747- sv.AddPane(ctx, "C", "", containers[2]) // index 2747+ sv.AddPane(ctx, "pane-c", "C", "", containers[2]) // index 2
748 748
749 // Navigate to pane B (index 1)749 // Navigate to pane B (index 1)
750 require.NoError(t, sv.SetActive(ctx, 1))750 require.NoError(t, sv.SetActive(ctx, 1))
@@ -757,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
757 mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()757 mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
758 758
759 // Act759 // Act
760- index := sv.InsertPaneAfter(ctx, 1, "D", "", mockContainer4)760+ index := sv.InsertPaneAfter(ctx, 1, "pane-d", "D", "", mockContainer4)
761 761
762 // Assert: Order should be A(0), B(1), D(2), C(3)762 // Assert: Order should be A(0), B(1), D(2), C(3)
763 assert.Equal(t, 2, index)763 assert.Equal(t, 2, index)
@@ -791,7 +791,7 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
791 mockTitleBar1.EXPECT().AddCssClass("active").Once()791 mockTitleBar1.EXPECT().AddCssClass("active").Once()
792 792
793 sv := layout.NewStackedView(mockFactory)793 sv := layout.NewStackedView(mockFactory)
794- sv.AddPane(ctx, "Page 1", "", mockContainer1)794+ sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1)
795 795
796 // Try to insert at invalid index (100) - should clamp to end796 // Try to insert at invalid index (100) - should clamp to end
797 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)797 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
@@ -805,7 +805,7 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
805 mockTitleBar2.EXPECT().AddCssClass("active").Once()805 mockTitleBar2.EXPECT().AddCssClass("active").Once()
806 806
807 // Act - afterIndex=100 should be clamped to 0 (last valid index)807 // Act - afterIndex=100 should be clamped to 0 (last valid index)
808- index := sv.InsertPaneAfter(ctx, 100, "Page 2", "", mockContainer2)808+ index := sv.InsertPaneAfter(ctx, 100, "pane-2", "Page 2", "", mockContainer2)
809 809
810 // Assert810 // Assert
811 assert.Equal(t, 1, index)811 assert.Equal(t, 1, index)
M internal/ui/layout/tree.go +6 -4
@@ -135,7 +135,7 @@ func (tr *TreeRenderer) renderLeaf(ctx context.Context, node *entity.PaneNode) W
135135 Str("title", title).
136136 Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")
137137
138- stackedView.AddPane(ctx, title, "", paneWidget)
138+ stackedView.AddPane(ctx, paneID, title, "", paneWidget)
139139
140140 // Track this pane's StackedView for later stacking operations
141141 if node.Pane != nil {
@@ -185,22 +185,24 @@ func (tr *TreeRenderer) renderStacked(ctx context.Context, node *entity.PaneNode
185185 return nil, err
186186 }
187187
188- // Get title and favicon from pane if available
188+ // Get pane ID, title, and favicon from pane if available
189+ paneID := ""
189190 title := "Untitled"
190191 favicon := ""
191192 if child.Pane != nil {
193+ paneID = string(child.Pane.ID)
192194 if child.Pane.Title != "" {
193195 title = child.Pane.Title
194196 }
195197 favicon = child.Pane.FaviconURL
196198 }
197199
198- stackedView.AddPane(ctx, title, favicon, childWidget)
200+ stackedView.AddPane(ctx, paneID, title, favicon, childWidget)
199201
200202 // Override paneToStack to point to THIS stacked view, not the child's wrapper.
201203 // This ensures geometric navigation and stack sync work correctly.
202204 if child.Pane != nil {
203- tr.paneToStack[string(child.Pane.ID)] = stackedView
205+ tr.paneToStack[paneID] = stackedView
204206 }
205207 }
206208
@@ -135,7 +135,7 @@ func (tr *TreeRenderer) renderLeaf(ctx context.Context, node *entity.PaneNode) W
135 Str("title", title).135 Str("title", title).
136 Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")136 Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView")
137 137
138- stackedView.AddPane(ctx, title, "", paneWidget)138+ stackedView.AddPane(ctx, paneID, title, "", paneWidget)
139 139
140 // Track this pane's StackedView for later stacking operations140 // Track this pane's StackedView for later stacking operations
141 if node.Pane != nil {141 if node.Pane != nil {
@@ -185,22 +185,24 @@ func (tr *TreeRenderer) renderStacked(ctx context.Context, node *entity.PaneNode
185 return nil, err185 return nil, err
186 }186 }
187 187
188- // Get title and favicon from pane if available188+ // Get pane ID, title, and favicon from pane if available
189+ paneID := ""
189 title := "Untitled"190 title := "Untitled"
190 favicon := ""191 favicon := ""
191 if child.Pane != nil {192 if child.Pane != nil {
193+ paneID = string(child.Pane.ID)
192 if child.Pane.Title != "" {194 if child.Pane.Title != "" {
193 title = child.Pane.Title195 title = child.Pane.Title
194 }196 }
195 favicon = child.Pane.FaviconURL197 favicon = child.Pane.FaviconURL
196 }198 }
197 199
198- stackedView.AddPane(ctx, title, favicon, childWidget)200+ stackedView.AddPane(ctx, paneID, title, favicon, childWidget)
199 201
200 // Override paneToStack to point to THIS stacked view, not the child's wrapper.202 // Override paneToStack to point to THIS stacked view, not the child's wrapper.
201 // This ensures geometric navigation and stack sync work correctly.203 // This ensures geometric navigation and stack sync work correctly.
202 if child.Pane != nil {204 if child.Pane != nil {
203- tr.paneToStack[string(child.Pane.ID)] = stackedView205+ tr.paneToStack[paneID] = stackedView
204 }206 }
205 }207 }
206 208
M internal/ui/theme/css.go +5 -0
@@ -434,6 +434,11 @@ entry.find-bar-entry:focus-visible {
434434 func generatePaneCSS(p Palette) string {
435435 return `/* ===== Pane Styling ===== */
436436
437+/* Pane overlay container - theme background prevents white flash */
438+.pane-overlay {
439+ background-color: var(--bg);
440+}
441+
437442 /* Pane border - default transparent */
438443 .pane-border {
439444 border: 0.0625em solid transparent;
@@ -434,6 +434,11 @@ entry.find-bar-entry:focus-visible {
434 func generatePaneCSS(p Palette) string {434 func generatePaneCSS(p Palette) string {
435 return `/* ===== Pane Styling ===== */435 return `/* ===== Pane Styling ===== */
436 436
437+/* Pane overlay container - theme background prevents white flash */
438+.pane-overlay {
439+ background-color: var(--bg);
440+}
441+
437 /* Pane border - default transparent */442 /* Pane border - default transparent */
438 .pane-border {443 .pane-border {
439 border: 0.0625em solid transparent;444 border: 0.0625em solid transparent;
M internal/ui/theme/manager.go +7 -0
@@ -87,6 +87,13 @@ func (m *Manager) GetCurrentPalette() Palette {
8787 return m.lightPalette
8888 }
8989
90+// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
91+// Used to set WebView background color to eliminate white flash during loading.
92+func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
93+ palette := m.GetCurrentPalette()
94+ return HexToRGBA(palette.Background)
95+}
96+
9097 // GetLightPalette returns the light theme palette.
9198 func (m *Manager) GetLightPalette() Palette {
9299 return m.lightPalette
@@ -87,6 +87,13 @@ func (m *Manager) GetCurrentPalette() Palette {
87 return m.lightPalette87 return m.lightPalette
88 }88 }
89 89
90+// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
91+// Used to set WebView background color to eliminate white flash during loading.
92+func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
93+ palette := m.GetCurrentPalette()
94+ return HexToRGBA(palette.Background)
95+}
96+
90 // GetLightPalette returns the light theme palette.97 // GetLightPalette returns the light theme palette.
91 func (m *Manager) GetLightPalette() Palette {98 func (m *Manager) GetLightPalette() Palette {
92 return m.lightPalette99 return m.lightPalette
M internal/ui/theme/palette.go +64 -0
@@ -97,6 +97,70 @@ func Coalesce(values ...string) string {
9797 // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
9898 var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
9999
100+// Hex format length constants.
101+const (
102+ hexLenShort = 3 // #RGB
103+ hexLenStandard = 6 // #RRGGBB
104+ hexLenWithAlha = 8 // #RRGGBBAA
105+ maxColorValue = 255 // Maximum value for 8-bit color channel
106+)
107+
108+// HexToRGBA converts a hex color string to RGBA float32 values (0.0-1.0).
109+// Supports #RGB, #RRGGBB, and #RRGGBBAA formats.
110+// Returns opaque black (0,0,0,1) if parsing fails.
111+func HexToRGBA(hex string) (r, g, b, a float32) {
112+ // Default to opaque black
113+ if hex == "" || hex[0] != '#' {
114+ return 0, 0, 0, 1
115+ }
116+ hex = hex[1:]
117+
118+ var ri, gi, bi, ai uint64
119+ switch len(hex) {
120+ case hexLenShort: // #RGB
121+ ri, _ = parseHex(hex[0:1])
122+ gi, _ = parseHex(hex[1:2])
123+ bi, _ = parseHex(hex[2:3])
124+ ri *= 17 // 0xF -> 0xFF
125+ gi *= 17
126+ bi *= 17
127+ ai = maxColorValue
128+ case hexLenStandard: // #RRGGBB
129+ ri, _ = parseHex(hex[0:2])
130+ gi, _ = parseHex(hex[2:4])
131+ bi, _ = parseHex(hex[4:6])
132+ ai = maxColorValue
133+ case hexLenWithAlha: // #RRGGBBAA
134+ ri, _ = parseHex(hex[0:2])
135+ gi, _ = parseHex(hex[2:4])
136+ bi, _ = parseHex(hex[4:6])
137+ ai, _ = parseHex(hex[6:8])
138+ default:
139+ return 0, 0, 0, 1
140+ }
141+
142+ return float32(ri) / maxColorValue, float32(gi) / maxColorValue, float32(bi) / maxColorValue, float32(ai) / maxColorValue
143+}
144+
145+// parseHex parses a hex string to uint64.
146+func parseHex(s string) (uint64, error) {
147+ var result uint64
148+ for _, c := range s {
149+ result *= 16
150+ switch {
151+ case c >= '0' && c <= '9':
152+ result += uint64(c - '0')
153+ case c >= 'a' && c <= 'f':
154+ result += uint64(c - 'a' + 10)
155+ case c >= 'A' && c <= 'F':
156+ result += uint64(c - 'A' + 10)
157+ default:
158+ return 0, fmt.Errorf("invalid hex char: %c", c)
159+ }
160+ }
161+ return result, nil
162+}
163+
100164 // ValidateHexColor checks if a string is a valid hex color.
101165 func ValidateHexColor(color string) error {
102166 if color == "" {
@@ -97,6 +97,70 @@ func Coalesce(values ...string) string {
97 // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).97 // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
98 var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)98 var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
99 99
100+// Hex format length constants.
101+const (
102+ hexLenShort = 3 // #RGB
103+ hexLenStandard = 6 // #RRGGBB
104+ hexLenWithAlha = 8 // #RRGGBBAA
105+ maxColorValue = 255 // Maximum value for 8-bit color channel
106+)
107+
108+// HexToRGBA converts a hex color string to RGBA float32 values (0.0-1.0).
109+// Supports #RGB, #RRGGBB, and #RRGGBBAA formats.
110+// Returns opaque black (0,0,0,1) if parsing fails.
111+func HexToRGBA(hex string) (r, g, b, a float32) {
112+ // Default to opaque black
113+ if hex == "" || hex[0] != '#' {
114+ return 0, 0, 0, 1
115+ }
116+ hex = hex[1:]
117+
118+ var ri, gi, bi, ai uint64
119+ switch len(hex) {
120+ case hexLenShort: // #RGB
121+ ri, _ = parseHex(hex[0:1])
122+ gi, _ = parseHex(hex[1:2])
123+ bi, _ = parseHex(hex[2:3])
124+ ri *= 17 // 0xF -> 0xFF
125+ gi *= 17
126+ bi *= 17
127+ ai = maxColorValue
128+ case hexLenStandard: // #RRGGBB
129+ ri, _ = parseHex(hex[0:2])
130+ gi, _ = parseHex(hex[2:4])
131+ bi, _ = parseHex(hex[4:6])
132+ ai = maxColorValue
133+ case hexLenWithAlha: // #RRGGBBAA
134+ ri, _ = parseHex(hex[0:2])
135+ gi, _ = parseHex(hex[2:4])
136+ bi, _ = parseHex(hex[4:6])
137+ ai, _ = parseHex(hex[6:8])
138+ default:
139+ return 0, 0, 0, 1
140+ }
141+
142+ return float32(ri) / maxColorValue, float32(gi) / maxColorValue, float32(bi) / maxColorValue, float32(ai) / maxColorValue
143+}
144+
145+// parseHex parses a hex string to uint64.
146+func parseHex(s string) (uint64, error) {
147+ var result uint64
148+ for _, c := range s {
149+ result *= 16
150+ switch {
151+ case c >= '0' && c <= '9':
152+ result += uint64(c - '0')
153+ case c >= 'a' && c <= 'f':
154+ result += uint64(c - 'a' + 10)
155+ case c >= 'A' && c <= 'F':
156+ result += uint64(c - 'A' + 10)
157+ default:
158+ return 0, fmt.Errorf("invalid hex char: %c", c)
159+ }
160+ }
161+ return result, nil
162+}
163+
100 // ValidateHexColor checks if a string is a valid hex color.164 // ValidateHexColor checks if a string is a valid hex color.
101 func ValidateHexColor(color string) error {165 func ValidateHexColor(color string) error {
102 if color == "" {166 if color == "" {
M webui/package-lock.json +32 -0 generated file: collapsed
M webui/package.json +1 -0
@@ -29,6 +29,7 @@
2929 "eslint": "^8.57.0",
3030 "postcss": "^8.4.47",
3131 "svelte": "^5.0.0",
32+ "svelte-awesome-color-picker": "^4.1.0",
3233 "svelte-check": "^4.0.0",
3334 "tailwind-variants": "^3.2.2",
3435 "tailwindcss": "^4.0.0",
@@ -29,6 +29,7 @@
29 "eslint": "^8.57.0",29 "eslint": "^8.57.0",
30 "postcss": "^8.4.47",30 "postcss": "^8.4.47",
31 "svelte": "^5.0.0",31 "svelte": "^5.0.0",
32+ "svelte-awesome-color-picker": "^4.1.0",
32 "svelte-check": "^4.0.0",33 "svelte-check": "^4.0.0",
33 "tailwind-variants": "^3.2.2",34 "tailwind-variants": "^3.2.2",
34 "tailwindcss": "^4.0.0",35 "tailwindcss": "^4.0.0",
A webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +17 -0
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.ActionProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Action
14+ bind:ref
15+ class={cn(buttonVariants(), className)}
16+ {...restProps}
17+/>
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.ActionProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Action
14+ bind:ref
15+ class={cn(buttonVariants(), className)}
16+ {...restProps}
17+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +17 -0
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.CancelProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Cancel
14+ bind:ref
15+ class={cn(buttonVariants({ variant: "outline" }), className)}
16+ {...restProps}
17+/>
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { buttonVariants } from "$lib/components/ui/button/index.js";
4+ import { cn } from "$lib/utils.js";
5+
6+ let {
7+ ref = $bindable(null),
8+ class: className,
9+ ...restProps
10+ }: AlertDialogPrimitive.CancelProps = $props();
11+</script>
12+
13+<AlertDialogPrimitive.Cancel
14+ bind:ref
15+ class={cn(buttonVariants({ variant: "outline" }), className)}
16+ {...restProps}
17+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +24 -0
@@ -0,0 +1,24 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+ import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5+ import AlertDialogPortal from "./alert-dialog-portal.svelte";
6+
7+ let {
8+ ref = $bindable(null),
9+ class: className,
10+ ...restProps
11+ }: AlertDialogPrimitive.ContentProps = $props();
12+</script>
13+
14+<AlertDialogPortal>
15+ <AlertDialogOverlay />
16+ <AlertDialogPrimitive.Content
17+ bind:ref
18+ class={cn(
19+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20+ className
21+ )}
22+ {...restProps}
23+ />
24+</AlertDialogPortal>
@@ -0,0 +1,24 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+ import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5+ import AlertDialogPortal from "./alert-dialog-portal.svelte";
6+
7+ let {
8+ ref = $bindable(null),
9+ class: className,
10+ ...restProps
11+ }: AlertDialogPrimitive.ContentProps = $props();
12+</script>
13+
14+<AlertDialogPortal>
15+ <AlertDialogOverlay />
16+ <AlertDialogPrimitive.Content
17+ bind:ref
18+ class={cn(
19+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20+ className
21+ )}
22+ {...restProps}
23+ />
24+</AlertDialogPortal>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.DescriptionProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Description
13+ bind:ref
14+ class={cn("text-sm text-muted-foreground", className)}
15+ {...restProps}
16+/>
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.DescriptionProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Description
13+ bind:ref
14+ class={cn("text-sm text-muted-foreground", className)}
15+ {...restProps}
16+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +19 -0
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +19 -0
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import type { HTMLAttributes } from "svelte/elements";
3+ import { cn, type WithElementRef } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ children,
9+ ...restProps
10+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11+</script>
12+
13+<div
14+ bind:this={ref}
15+ class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16+ {...restProps}
17+>
18+ {@render children?.()}
19+</div>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +19 -0
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.OverlayProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Overlay
13+ bind:ref
14+ class={cn(
15+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16+ className
17+ )}
18+ {...restProps}
19+/>
@@ -0,0 +1,19 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.OverlayProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Overlay
13+ bind:ref
14+ class={cn(
15+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16+ className
17+ )}
18+ {...restProps}
19+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
@@ -0,0 +1,7 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5+</script>
6+
7+<AlertDialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5+</script>
6+
7+<AlertDialogPrimitive.Portal {...restProps} />
A webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TitleProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Title
13+ bind:ref
14+ class={cn("text-lg font-semibold", className)}
15+ {...restProps}
16+/>
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TitleProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Title
13+ bind:ref
14+ class={cn("text-lg font-semibold", className)}
15+ {...restProps}
16+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +16 -0
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TriggerProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Trigger
13+ bind:ref
14+ class={cn(className)}
15+ {...restProps}
16+/>
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+ import { cn } from "$lib/utils.js";
4+
5+ let {
6+ ref = $bindable(null),
7+ class: className,
8+ ...restProps
9+ }: AlertDialogPrimitive.TriggerProps = $props();
10+</script>
11+
12+<AlertDialogPrimitive.Trigger
13+ bind:ref
14+ class={cn(className)}
15+ {...restProps}
16+/>
A webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +10 -0
@@ -0,0 +1,10 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let {
5+ open = $bindable(false),
6+ ...restProps
7+ }: AlertDialogPrimitive.RootProps = $props();
8+</script>
9+
10+<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,10 @@
1+<script lang="ts">
2+ import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3+
4+ let {
5+ open = $bindable(false),
6+ ...restProps
7+ }: AlertDialogPrimitive.RootProps = $props();
8+</script>
9+
10+<AlertDialogPrimitive.Root bind:open {...restProps} />
A webui/src/lib/components/ui/alert-dialog/index.ts +37 -0
@@ -0,0 +1,37 @@
1+import Root from "./alert-dialog.svelte";
2+import Trigger from "./alert-dialog-trigger.svelte";
3+import Portal from "./alert-dialog-portal.svelte";
4+import Overlay from "./alert-dialog-overlay.svelte";
5+import Content from "./alert-dialog-content.svelte";
6+import Header from "./alert-dialog-header.svelte";
7+import Footer from "./alert-dialog-footer.svelte";
8+import Title from "./alert-dialog-title.svelte";
9+import Description from "./alert-dialog-description.svelte";
10+import Action from "./alert-dialog-action.svelte";
11+import Cancel from "./alert-dialog-cancel.svelte";
12+
13+export {
14+ Root,
15+ Trigger,
16+ Portal,
17+ Overlay,
18+ Content,
19+ Header,
20+ Footer,
21+ Title,
22+ Description,
23+ Action,
24+ Cancel,
25+ //
26+ Root as AlertDialog,
27+ Trigger as AlertDialogTrigger,
28+ Portal as AlertDialogPortal,
29+ Overlay as AlertDialogOverlay,
30+ Content as AlertDialogContent,
31+ Header as AlertDialogHeader,
32+ Footer as AlertDialogFooter,
33+ Title as AlertDialogTitle,
34+ Description as AlertDialogDescription,
35+ Action as AlertDialogAction,
36+ Cancel as AlertDialogCancel,
37+};
@@ -0,0 +1,37 @@
1+import Root from "./alert-dialog.svelte";
2+import Trigger from "./alert-dialog-trigger.svelte";
3+import Portal from "./alert-dialog-portal.svelte";
4+import Overlay from "./alert-dialog-overlay.svelte";
5+import Content from "./alert-dialog-content.svelte";
6+import Header from "./alert-dialog-header.svelte";
7+import Footer from "./alert-dialog-footer.svelte";
8+import Title from "./alert-dialog-title.svelte";
9+import Description from "./alert-dialog-description.svelte";
10+import Action from "./alert-dialog-action.svelte";
11+import Cancel from "./alert-dialog-cancel.svelte";
12+
13+export {
14+ Root,
15+ Trigger,
16+ Portal,
17+ Overlay,
18+ Content,
19+ Header,
20+ Footer,
21+ Title,
22+ Description,
23+ Action,
24+ Cancel,
25+ //
26+ Root as AlertDialog,
27+ Trigger as AlertDialogTrigger,
28+ Portal as AlertDialogPortal,
29+ Overlay as AlertDialogOverlay,
30+ Content as AlertDialogContent,
31+ Header as AlertDialogHeader,
32+ Footer as AlertDialogFooter,
33+ Title as AlertDialogTitle,
34+ Description as AlertDialogDescription,
35+ Action as AlertDialogAction,
36+ Cancel as AlertDialogCancel,
37+};
A webui/src/lib/components/ui/color-picker/color-picker.svelte +89 -0
@@ -0,0 +1,89 @@
1+<script lang="ts" module>
2+ export type ColorPickerProps = {
3+ value: string;
4+ onValueChange?: (value: string) => void;
5+ id?: string;
6+ class?: string;
7+ };
8+</script>
9+
10+<script lang="ts">
11+ import ColorPicker from "svelte-awesome-color-picker";
12+ import { Popover } from "bits-ui";
13+ import { cn } from "$lib/utils.js";
14+
15+ let {
16+ class: className,
17+ value = $bindable("#000000"),
18+ onValueChange,
19+ id,
20+ }: ColorPickerProps = $props();
21+
22+ let open = $state(false);
23+
24+ // svelte-awesome-color-picker uses hex binding
25+ let hex = $state(value);
26+
27+ // Sync hex when value changes externally
28+ $effect(() => {
29+ hex = value;
30+ });
31+
32+ function handleInput(event: { hex: string | null }) {
33+ if (event.hex) {
34+ hex = event.hex;
35+ value = event.hex;
36+ onValueChange?.(event.hex);
37+ }
38+ }
39+</script>
40+
41+<Popover.Root bind:open>
42+ <Popover.Trigger
43+ {id}
44+ class={cn(
45+ "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
46+ className
47+ )}
48+ >
49+ <span
50+ class="block h-6 w-8 border border-border/50"
51+ style="background-color: {value};"
52+ aria-hidden="true"
53+ ></span>
54+ <span class="sr-only">Choose color: {value}</span>
55+ </Popover.Trigger>
56+
57+ <Popover.Portal>
58+ <Popover.Content
59+ class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
60+ sideOffset={4}
61+ >
62+ <ColorPicker
63+ bind:hex
64+ onInput={handleInput}
65+ isDialog={false}
66+ />
67+ <!-- Hex value display -->
68+ <div class="mt-2 flex items-center gap-2">
69+ <span
70+ class="block h-6 w-6 shrink-0 border border-border"
71+ style="background-color: {hex};"
72+ aria-hidden="true"
73+ ></span>
74+ <span class="font-mono text-sm text-foreground">{hex}</span>
75+ </div>
76+ </Popover.Content>
77+ </Popover.Portal>
78+</Popover.Root>
79+
80+<style>
81+ /* Override svelte-awesome-color-picker styles to match our theme */
82+ :global(.color-picker-popover) {
83+ --cp-bg-color: var(--background);
84+ --cp-border-color: var(--border);
85+ --cp-text-color: var(--foreground);
86+ --cp-input-color: var(--background);
87+ --cp-button-hover-color: var(--accent);
88+ }
89+</style>
@@ -0,0 +1,89 @@
1+<script lang="ts" module>
2+ export type ColorPickerProps = {
3+ value: string;
4+ onValueChange?: (value: string) => void;
5+ id?: string;
6+ class?: string;
7+ };
8+</script>
9+
10+<script lang="ts">
11+ import ColorPicker from "svelte-awesome-color-picker";
12+ import { Popover } from "bits-ui";
13+ import { cn } from "$lib/utils.js";
14+
15+ let {
16+ class: className,
17+ value = $bindable("#000000"),
18+ onValueChange,
19+ id,
20+ }: ColorPickerProps = $props();
21+
22+ let open = $state(false);
23+
24+ // svelte-awesome-color-picker uses hex binding
25+ let hex = $state(value);
26+
27+ // Sync hex when value changes externally
28+ $effect(() => {
29+ hex = value;
30+ });
31+
32+ function handleInput(event: { hex: string | null }) {
33+ if (event.hex) {
34+ hex = event.hex;
35+ value = event.hex;
36+ onValueChange?.(event.hex);
37+ }
38+ }
39+</script>
40+
41+<Popover.Root bind:open>
42+ <Popover.Trigger
43+ {id}
44+ class={cn(
45+ "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
46+ className
47+ )}
48+ >
49+ <span
50+ class="block h-6 w-8 border border-border/50"
51+ style="background-color: {value};"
52+ aria-hidden="true"
53+ ></span>
54+ <span class="sr-only">Choose color: {value}</span>
55+ </Popover.Trigger>
56+
57+ <Popover.Portal>
58+ <Popover.Content
59+ class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
60+ sideOffset={4}
61+ >
62+ <ColorPicker
63+ bind:hex
64+ onInput={handleInput}
65+ isDialog={false}
66+ />
67+ <!-- Hex value display -->
68+ <div class="mt-2 flex items-center gap-2">
69+ <span
70+ class="block h-6 w-6 shrink-0 border border-border"
71+ style="background-color: {hex};"
72+ aria-hidden="true"
73+ ></span>
74+ <span class="font-mono text-sm text-foreground">{hex}</span>
75+ </div>
76+ </Popover.Content>
77+ </Popover.Portal>
78+</Popover.Root>
79+
80+<style>
81+ /* Override svelte-awesome-color-picker styles to match our theme */
82+ :global(.color-picker-popover) {
83+ --cp-bg-color: var(--background);
84+ --cp-border-color: var(--border);
85+ --cp-text-color: var(--foreground);
86+ --cp-input-color: var(--background);
87+ --cp-button-hover-color: var(--accent);
88+ }
89+</style>
A webui/src/lib/components/ui/color-picker/index.ts +8 -0
@@ -0,0 +1,8 @@
1+import Root, { type ColorPickerProps } from "./color-picker.svelte";
2+
3+export {
4+ Root,
5+ Root as ColorPicker,
6+ type ColorPickerProps,
7+ type ColorPickerProps as Props,
8+};
@@ -0,0 +1,8 @@
1+import Root, { type ColorPickerProps } from "./color-picker.svelte";
2+
3+export {
4+ Root,
5+ Root as ColorPicker,
6+ type ColorPickerProps,
7+ type ColorPickerProps as Props,
8+};
M webui/src/pages/ConfigPage.svelte +34 -19
@@ -2,7 +2,9 @@
22 import { onMount } from "svelte";
33 import ConfigShell from "./config/ConfigShell.svelte";
44 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
56 import { Button } from "$lib/components/ui/button";
7+ import { ColorPicker } from "$lib/components/ui/color-picker";
68 import { Input } from "$lib/components/ui/input";
79 import { Label } from "$lib/components/ui/label";
810 import { Spinner } from "$lib/components/ui/spinner";
@@ -84,13 +86,10 @@
8486 loadConfig();
8587 }
8688
87- async function resetToDefaults() {
88- saveSuccess = false;
89- const confirmed = window.confirm(
90- "Reset this page to default settings? (You still need to click Save to persist.)",
91- );
92- if (!confirmed) return;
89+ let resetDialogOpen = $state(false);
9390
91+ async function doResetToDefaults() {
92+ saveSuccess = false;
9493 try {
9594 const response = await fetch("/api/config/default");
9695 if (!response.ok) throw new Error("Failed to fetch default config");
@@ -98,6 +97,7 @@
9897 } catch (e: any) {
9998 console.error("[config] reset defaults failed", e);
10099 }
100+ resetDialogOpen = false;
101101 }
102102
103103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
@@ -305,15 +305,12 @@
305305 <div class="space-y-2">
306306 <Label for={`light_${field.key}`}>{field.label}</Label>
307307 <div class="flex items-center gap-3">
308- <input
308+ <ColorPicker
309309 id={`light_${field.key}`}
310- type="color"
311310 value={config.appearance.light_palette[field.key]}
312- oninput={(e) => {
313- const v = (e.currentTarget as HTMLInputElement).value;
311+ onValueChange={(v) => {
314312 config!.appearance.light_palette[field.key] = v;
315313 }}
316- class="h-10 w-12 border border-border bg-background"
317314 />
318315 <Input bind:value={config.appearance.light_palette[field.key]} />
319316 </div>
@@ -334,15 +331,12 @@
334331 <div class="space-y-2">
335332 <Label for={`dark_${field.key}`}>{field.label}</Label>
336333 <div class="flex items-center gap-3">
337- <input
334+ <ColorPicker
338335 id={`dark_${field.key}`}
339- type="color"
340336 value={config.appearance.dark_palette[field.key]}
341- oninput={(e) => {
342- const v = (e.currentTarget as HTMLInputElement).value;
337+ onValueChange={(v) => {
343338 config!.appearance.dark_palette[field.key] = v;
344339 }}
345- class="h-10 w-12 border border-border bg-background"
346340 />
347341 <Input bind:value={config.appearance.dark_palette[field.key]} />
348342 </div>
@@ -394,9 +388,30 @@
394388 {/if}
395389 </div>
396390 <div class="flex items-center gap-3">
397- <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button">
398- Reset Defaults
399- </Button>
391+ <AlertDialog.Root bind:open={resetDialogOpen}>
392+ <AlertDialog.Trigger disabled={saving}>
393+ {#snippet child({ props })}
394+ <Button variant="outline" {...props} type="button">
395+ Reset Defaults
396+ </Button>
397+ {/snippet}
398+ </AlertDialog.Trigger>
399+ <AlertDialog.Content>
400+ <AlertDialog.Header>
401+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
402+ <AlertDialog.Description>
403+ This will reset all settings on this page to their default values.
404+ You will still need to click Save to persist the changes.
405+ </AlertDialog.Description>
406+ </AlertDialog.Header>
407+ <AlertDialog.Footer>
408+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
409+ <AlertDialog.Action onclick={doResetToDefaults}>
410+ Reset Defaults
411+ </AlertDialog.Action>
412+ </AlertDialog.Footer>
413+ </AlertDialog.Content>
414+ </AlertDialog.Root>
400415 <Button onclick={saveConfig} disabled={saving} type="button">
401416 {saving ? "Saving…" : "Save"}
402417 </Button>
@@ -2,7 +2,9 @@
2 import { onMount } from "svelte";2 import { onMount } from "svelte";
3 import ConfigShell from "./config/ConfigShell.svelte";3 import ConfigShell from "./config/ConfigShell.svelte";
4 import ShortcutsTable from "./config/ShortcutsTable.svelte";4 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5+ import * as AlertDialog from "$lib/components/ui/alert-dialog";
5 import { Button } from "$lib/components/ui/button";6 import { Button } from "$lib/components/ui/button";
7+ import { ColorPicker } from "$lib/components/ui/color-picker";
6 import { Input } from "$lib/components/ui/input";8 import { Input } from "$lib/components/ui/input";
7 import { Label } from "$lib/components/ui/label";9 import { Label } from "$lib/components/ui/label";
8 import { Spinner } from "$lib/components/ui/spinner";10 import { Spinner } from "$lib/components/ui/spinner";
@@ -84,13 +86,10 @@
84 loadConfig();86 loadConfig();
85 }87 }
86 88
87- async function resetToDefaults() {89+ let resetDialogOpen = $state(false);
88- saveSuccess = false;
89- const confirmed = window.confirm(
90- "Reset this page to default settings? (You still need to click Save to persist.)",
91- );
92- if (!confirmed) return;
93 90
91+ async function doResetToDefaults() {
92+ saveSuccess = false;
94 try {93 try {
95 const response = await fetch("/api/config/default");94 const response = await fetch("/api/config/default");
96 if (!response.ok) throw new Error("Failed to fetch default config");95 if (!response.ok) throw new Error("Failed to fetch default config");
@@ -98,6 +97,7 @@
98 } catch (e: any) {97 } catch (e: any) {
99 console.error("[config] reset defaults failed", e);98 console.error("[config] reset defaults failed", e);
100 }99 }
100+ resetDialogOpen = false;
101 }101 }
102 102
103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {103 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
@@ -305,15 +305,12 @@
305 <div class="space-y-2">305 <div class="space-y-2">
306 <Label for={`light_${field.key}`}>{field.label}</Label>306 <Label for={`light_${field.key}`}>{field.label}</Label>
307 <div class="flex items-center gap-3">307 <div class="flex items-center gap-3">
308- <input308+ <ColorPicker
309 id={`light_${field.key}`}309 id={`light_${field.key}`}
310- type="color"
311 value={config.appearance.light_palette[field.key]}310 value={config.appearance.light_palette[field.key]}
312- oninput={(e) => {311+ onValueChange={(v) => {
313- const v = (e.currentTarget as HTMLInputElement).value;
314 config!.appearance.light_palette[field.key] = v;312 config!.appearance.light_palette[field.key] = v;
315 }}313 }}
316- class="h-10 w-12 border border-border bg-background"
317 />314 />
318 <Input bind:value={config.appearance.light_palette[field.key]} />315 <Input bind:value={config.appearance.light_palette[field.key]} />
319 </div>316 </div>
@@ -334,15 +331,12 @@
334 <div class="space-y-2">331 <div class="space-y-2">
335 <Label for={`dark_${field.key}`}>{field.label}</Label>332 <Label for={`dark_${field.key}`}>{field.label}</Label>
336 <div class="flex items-center gap-3">333 <div class="flex items-center gap-3">
337- <input334+ <ColorPicker
338 id={`dark_${field.key}`}335 id={`dark_${field.key}`}
339- type="color"
340 value={config.appearance.dark_palette[field.key]}336 value={config.appearance.dark_palette[field.key]}
341- oninput={(e) => {337+ onValueChange={(v) => {
342- const v = (e.currentTarget as HTMLInputElement).value;
343 config!.appearance.dark_palette[field.key] = v;338 config!.appearance.dark_palette[field.key] = v;
344 }}339 }}
345- class="h-10 w-12 border border-border bg-background"
346 />340 />
347 <Input bind:value={config.appearance.dark_palette[field.key]} />341 <Input bind:value={config.appearance.dark_palette[field.key]} />
348 </div>342 </div>
@@ -394,9 +388,30 @@
394 {/if}388 {/if}
395 </div>389 </div>
396 <div class="flex items-center gap-3">390 <div class="flex items-center gap-3">
397- <Button variant="outline" onclick={resetToDefaults} disabled={saving} type="button">391+ <AlertDialog.Root bind:open={resetDialogOpen}>
398- Reset Defaults392+ <AlertDialog.Trigger disabled={saving}>
399- </Button>393+ {#snippet child({ props })}
394+ <Button variant="outline" {...props} type="button">
395+ Reset Defaults
396+ </Button>
397+ {/snippet}
398+ </AlertDialog.Trigger>
399+ <AlertDialog.Content>
400+ <AlertDialog.Header>
401+ <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
402+ <AlertDialog.Description>
403+ This will reset all settings on this page to their default values.
404+ You will still need to click Save to persist the changes.
405+ </AlertDialog.Description>
406+ </AlertDialog.Header>
407+ <AlertDialog.Footer>
408+ <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
409+ <AlertDialog.Action onclick={doResetToDefaults}>
410+ Reset Defaults
411+ </AlertDialog.Action>
412+ </AlertDialog.Footer>
413+ </AlertDialog.Content>
414+ </AlertDialog.Root>
400 <Button onclick={saveConfig} disabled={saving} type="button">415 <Button onclick={saveConfig} disabled={saving} type="button">
401 {saving ? "Saving…" : "Save"}416 {saving ? "Saving…" : "Save"}
402 </Button>417 </Button>

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