Merge pull request #19 from bnema/dev
Fix navigation perf, Wayland dialogs, idle inhibition, and stacked pane updates
56 files · +1854 -207
M .github/workflows/ci.yml +3 -1
| @@ -65,7 +65,9 @@ jobs: | |||
| 65 | 65 | run: | | |
| 66 | 66 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.2 | |
| 67 | 67 | - 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 | |
| 69 | 71 | ||
| 70 | 72 | test: | |
| 71 | 73 | 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.2 | 66 | 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-lint | 67 | - 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 | |||
| 69 | 71 | ||||
| 70 | test: | 72 | test: | ||
| 71 | runs-on: ubuntu-latest | 73 | 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. | |||
| 4 | 4 | ||
| 5 | 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 | 26 | ### 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 | 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 | 29 | - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI. | |
| 10 | 30 | - **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 | ### Added | 26 | ### 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 ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/bootstrap" | |
| 16 | 16 | "github.com/bnema/dumber/internal/cli/cmd" | |
| 17 | 17 | "github.com/bnema/dumber/internal/domain/build" | |
| 18 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 18 | 19 | "github.com/bnema/dumber/internal/domain/repository" | |
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/cache" | |
| 19 | 21 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 20 | 22 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 21 | 23 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 22 | 24 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 25 | + | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 23 | 26 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 24 | 27 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 25 | 28 | "github.com/bnema/dumber/internal/logging" | |
| @@ -108,8 +111,14 @@ func runGUI() int { | |||
| 108 | 111 | themeManager := theme.NewManager(ctx, cfg) | |
| 109 | 112 | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | |
| 110 | 113 | 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) | |
| 113 | 122 | ||
| 114 | 123 | app, err := ui.New(uiDeps) | |
| 115 | 124 | if err != nil { | |
| @@ -247,7 +256,7 @@ type useCases struct { | |||
| 247 | 256 | 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 | 260 | const idAlphabetSize = 26 | |
| 252 | 261 | idCounter := uint64(0) | |
| 253 | 262 | idGenerator := func() string { | |
| @@ -257,13 +266,17 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases { | |||
| 257 | 266 | ||
| 258 | 267 | clipboardAdapter := clipboard.New() | |
| 259 | 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 | 274 | return &useCases{ | |
| 262 | 275 | tabs: usecase.NewManageTabsUseCase(idGenerator), | |
| 263 | 276 | panes: usecase.NewManagePanesUseCase(idGenerator), | |
| 264 | 277 | history: usecase.NewSearchHistoryUseCase(repos.history), | |
| 265 | 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 | 280 | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | |
| 268 | 281 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | |
| 269 | 282 | clipboard: clipboardAdapter, | |
| @@ -278,6 +291,7 @@ func buildUIDependencies( | |||
| 278 | 291 | stack *bootstrap.WebKitStack, | |
| 279 | 292 | repos *repositories, | |
| 280 | 293 | uc *useCases, | |
| 294 | + | idleInhibitor port.IdleInhibitor, | |
| 281 | 295 | ) *ui.Dependencies { | |
| 282 | 296 | return &ui.Dependencies{ | |
| 283 | 297 | Ctx: ctx, | |
| @@ -302,5 +316,6 @@ func buildUIDependencies( | |||
| 302 | 316 | CopyURLUC: uc.copyURL, | |
| 303 | 317 | Clipboard: uc.clipboard, | |
| 304 | 318 | FaviconService: uc.favicon, | |
| 319 | + | IdleInhibitor: idleInhibitor, | |
| 305 | 320 | } | |
| 306 | 321 | } | |
| @@ -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.Service | 256 | 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 = 26 | 260 | 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" | |||
| 182 | 182 | | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) | | |
| 183 | 183 | | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) | | |
| 184 | 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 | 186 | | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) | | |
| 187 | 187 | | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) | | |
| 188 | 188 | | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) | | |
| @@ -282,13 +282,11 @@ cancel = ["escape"] | |||
| 282 | 282 | | Key | Type | Default | Valid Values | Description | | |
| 283 | 283 | |-----|------|---------|--------------|-------------| | |
| 284 | 284 | | `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 | | |
| 287 | 287 | | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) | | |
| 288 | 288 | | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering | | |
| 289 | 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 | 291 | **Hardware decoding modes:** | |
| 294 | 292 | - `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 | 305 | - **Intel**: Uses `iHD` driver (modern, for Broadwell+) | |
| 308 | 306 | - **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 | 308 | **Example:** | |
| 315 | 309 | ```toml | |
| 316 | 310 | [media] | |
| 317 | 311 | 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 | |
| 320 | 314 | force_vsync = false # Let compositor handle VSync | |
| 321 | 315 | gl_rendering_mode = "auto" # GStreamer picks best GL API | |
| 322 | 316 | 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 | 319 | **Diagnostics CLI:** | |
| @@ -368,7 +360,7 @@ All config values can be overridden via environment variables with the prefix `D | |||
| 368 | 360 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | |
| 369 | 361 | ||
| 370 | 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 | 364 | DUMBER_RENDERING_MODE=cpu | |
| 373 | 365 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | |
| 374 | 366 | # 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 #4000 | 292 | - `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 platform | 306 | - **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 | ```toml | 309 | ```toml | ||
| 316 | [media] | 310 | [media] | ||
| 317 | hardware_decoding = "auto" # HW preferred, SW fallback | 311 | hardware_decoding = "auto" # HW preferred, SW fallback | ||
| 318 | - | prefer_av1 = true # AV1 is most efficient codec | 312 | + | prefer_av1 = false # Let site choose codec |
| 319 | - | show_diagnostics = true # Log warnings if HW accel unavailable | 313 | + | show_diagnostics = false # Enable for debugging |
| 320 | force_vsync = false # Let compositor handle VSync | 314 | force_vsync = false # Let compositor handle VSync | ||
| 321 | gl_rendering_mode = "auto" # GStreamer picks best GL API | 315 | gl_rendering_mode = "auto" # GStreamer picks best GL API | ||
| 322 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | 316 | 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.sqlite | 360 | 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=cpu | 364 | DUMBER_RENDERING_MODE=cpu | ||
| 373 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | 365 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | ||
| 374 | # DUMBER_RENDERING_GSK_RENDERER=opengl | 366 | # DUMBER_RENDERING_GSK_RENDERER=opengl | ||
M go.mod +1 -0
| @@ -30,6 +30,7 @@ require ( | |||
| 30 | 30 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 31 | 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 32 | 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 33 | + | github.com/godbus/dbus/v5 v5.2.1 // indirect | |
| 33 | 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 34 | 35 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | |
| 35 | 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| @@ -30,6 +30,7 @@ require ( | |||||
| 30 | github.com/davecgh/go-spew v1.1.1 // indirect | 30 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 32 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 32 | 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 // indirect | 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 34 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | 35 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | ||
| 35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | 36 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | ||
M go.sum +2 -0 generated file: collapsed
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 ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // ManageZoomUseCase handles per-domain zoom level operations. | |
| 16 | + | // It uses an LRU cache to avoid database queries on every navigation. | |
| 16 | 17 | type ManageZoomUseCase struct { | |
| 17 | 18 | zoomRepo repository.ZoomRepository | |
| 18 | 19 | defaultZoom float64 | |
| 20 | + | cache port.Cache[string, *entity.ZoomLevel] | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | // NewManageZoomUseCase creates a new zoom management use case. | |
| 22 | 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 | 31 | if defaultZoom <= 0 { | |
| 25 | 32 | defaultZoom = entity.ZoomDefault | |
| 26 | 33 | } | |
| 27 | 34 | return &ManageZoomUseCase{ | |
| 28 | 35 | zoomRepo: zoomRepo, | |
| 29 | 36 | defaultZoom: defaultZoom, | |
| 37 | + | cache: cache, | |
| 30 | 38 | } | |
| 31 | 39 | } | |
| 32 | 40 | ||
| @@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 { | |||
| 37 | 45 | ||
| 38 | 46 | // GetZoom retrieves the zoom level for a domain. | |
| 39 | 47 | // Returns the configured default zoom level if none is set. | |
| 48 | + | // Uses LRU cache to avoid database queries on repeat visits. | |
| 40 | 49 | func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) { | |
| 41 | 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 | 62 | zoom, err := uc.zoomRepo.Get(ctx, domain) | |
| 45 | 63 | if err != nil { | |
| 46 | 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 | 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 | 77 | return zoom, nil | |
| 55 | 78 | } | |
| 56 | 79 | ||
| @@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor | |||
| 64 | 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 | 95 | log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved") | |
| 68 | 96 | return nil | |
| 69 | 97 | } | |
| @@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error | |||
| 77 | 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 | 113 | log.Info().Str("domain", domain).Msg("zoom level reset to default") | |
| 81 | 114 | return nil | |
| 82 | 115 | } | |
| @@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current | |||
| 98 | 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 | 139 | return zoom, nil | |
| 102 | 140 | } | |
| 103 | 141 | ||
| @@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current | |||
| 118 | 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 | 164 | return zoom, nil | |
| 122 | 165 | } | |
| 123 | 166 | ||
| @@ -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.ZoomRepository | 18 | zoomRepo repository.ZoomRepository | ||
| 18 | defaultZoom float64 | 19 | 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.ZoomDefault | 32 | 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, nil | 77 | 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 nil | 96 | 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 nil | 114 | 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, nil | 139 | 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, nil | 164 | return zoom, nil | ||
| 122 | } | 165 | } | ||
| 123 | 166 | ||||
M internal/application/usecase/navigate.go +92 -11
| @@ -13,12 +13,30 @@ import ( | |||
| 13 | 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 | 28 | // NavigateUseCase handles URL navigation with history recording and zoom application. | |
| 17 | 29 | type NavigateUseCase struct { | |
| 18 | 30 | historyRepo repository.HistoryRepository | |
| 19 | 31 | zoomRepo repository.ZoomRepository | |
| 20 | 32 | defaultZoom float64 | |
| 21 | 33 | 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 | 42 | // NewNavigateUseCase creates a new navigation use case. | |
| @@ -31,11 +49,28 @@ func NewNavigateUseCase( | |||
| 31 | 49 | if defaultZoom <= 0 { | |
| 32 | 50 | defaultZoom = entity.ZoomDefault | |
| 33 | 51 | } | |
| 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(), | |
| 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 | 76 | // NavigateInput contains parameters for navigation. | |
| @@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||
| 79 | 114 | // This prevents inflation from redirects and rapid navigation. | |
| 80 | 115 | 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 | 119 | // Should be called on LoadCommitted when URI is guaranteed correct. | |
| 84 | 120 | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |
| 85 | 121 | log := logging.FromContext(ctx) | |
| @@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |||
| 88 | 124 | url = normalizeURLForHistory(url) | |
| 89 | 125 | ||
| 90 | 126 | // 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 | 128 | now := time.Now() | |
| 92 | 129 | if lastTime, ok := uc.recentVisits.Load(url); ok { | |
| 93 | 130 | if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow { | |
| @@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |||
| 97 | 134 | } | |
| 98 | 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 | 181 | // Check if entry exists | |
| 101 | - | existing, err := uc.historyRepo.FindByURL(ctx, url) | |
| 182 | + | existing, err := uc.historyRepo.FindByURL(ctx, record.url) | |
| 102 | 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 | 185 | return | |
| 105 | 186 | } | |
| 106 | 187 | ||
| 107 | 188 | if existing != nil { | |
| 108 | 189 | // 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") | |
| 111 | 192 | } | |
| 112 | 193 | } else { | |
| 113 | 194 | // Create new entry | |
| 114 | - | entry := entity.NewHistoryEntry(url, "") | |
| 195 | + | entry := entity.NewHistoryEntry(record.url, "") | |
| 115 | 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 | } | |
| @@ -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.HistoryRepository | 30 | historyRepo repository.HistoryRepository | ||
| 19 | zoomRepo repository.ZoomRepository | 31 | zoomRepo repository.ZoomRepository | ||
| 20 | defaultZoom float64 | 32 | defaultZoom float64 | ||
| 21 | recentVisits sync.Map // key: url, value: time.Time - for deduplication | 33 | 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.ZoomDefault | 50 | 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.Second | 115 | 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 recently | 126 | // 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 exists | 181 | // 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 | return | 185 | return | ||
| 105 | } | 186 | } | ||
| 106 | 187 | ||||
| 107 | if existing != nil { | 188 | if existing != nil { | ||
| 108 | // Increment visit count | 189 | // 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 entry | 194 | // 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( | |||
| 103 | 103 | ||
| 104 | 104 | messageRouter := webkit.NewMessageRouter(ctx) | |
| 105 | 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 | 110 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | |
| 107 | 111 | ||
| 108 | 112 | 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 ( | |||
| 42 | 42 | // Other styling | |
| 43 | 43 | defaultTransitionDuration = 120 | |
| 44 | 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 | 51 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| @@ -117,7 +121,7 @@ func DefaultConfig() *Config { | |||
| 117 | 121 | DisableDMABufRenderer: false, | |
| 118 | 122 | ForceCompositingMode: false, | |
| 119 | 123 | DisableCompositingMode: false, | |
| 120 | - | GSKRenderer: GSKRendererVulkan, | |
| 124 | + | GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF | |
| 121 | 125 | DisableMipmaps: false, | |
| 122 | 126 | PreferGL: false, | |
| 123 | 127 | DrawCompositingIndicators: false, | |
| @@ -194,17 +198,21 @@ func DefaultConfig() *Config { | |||
| 194 | 198 | }, | |
| 195 | 199 | Media: MediaConfig{ | |
| 196 | 200 | 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 | |
| 199 | 203 | ForceVSync: false, // Let compositor handle VSync | |
| 200 | 204 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | |
| 201 | 205 | 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 | |
| 204 | 208 | }, | |
| 205 | 209 | Runtime: RuntimeConfig{ | |
| 206 | 210 | Prefix: "", | |
| 207 | 211 | }, | |
| 212 | + | Performance: PerformanceConfig{ | |
| 213 | + | ZoomCacheSize: defaultZoomCacheSize, | |
| 214 | + | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 215 | + | }, | |
| 208 | 216 | } | |
| 209 | 217 | } | |
| 210 | 218 | ||
| @@ -42,6 +42,10 @@ const ( | |||||
| 42 | // Other styling | 42 | // Other styling | ||
| 43 | defaultTransitionDuration = 120 | 43 | 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 error | 51 | // 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 fallback | 200 | HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback | ||
| 197 | - | PreferAV1: true, // AV1 is most efficient codec | 201 | + | PreferAV1: false, // Don't force codec preference, let site choose |
| 198 | - | ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable | 202 | + | ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy |
| 199 | ForceVSync: false, // Let compositor handle VSync | 203 | ForceVSync: false, // Let compositor handle VSync | ||
| 200 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | 204 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | ||
| 201 | GStreamerDebugLevel: 0, // Disabled by default | 205 | 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 playback | 207 | + | 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 { | |||
| 27 | 27 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | |
| 28 | 28 | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | |
| 29 | 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 | 34 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -172,6 +174,20 @@ type HistoryConfig struct { | |||
| 172 | 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 | 191 | // SearchShortcut represents a search shortcut configuration. | |
| 176 | 192 | type SearchShortcut struct { | |
| 177 | 193 | 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) { | |||
| 91 | 91 | m.setEnv("GST_DEBUG", fmt.Sprintf("%d", level)) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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. | |
| 106 | 98 | } | |
| 107 | 99 | ||
| 108 | 100 | 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 size | 94 | + | // 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 * 1024 | 96 | + | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "sync" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| @@ -18,6 +19,10 @@ type WebViewFactory struct { | |||
| 18 | 19 | injector *ContentInjector | |
| 19 | 20 | router *MessageRouter | |
| 20 | 21 | 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 | 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 | 68 | // Create creates a new WebView instance. | |
| 52 | 69 | // If a pool is configured, it will try to acquire from the pool first. | |
| 53 | 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 | 105 | return nil, fmt.Errorf("create related webview: %w", err) | |
| 89 | 106 | } | |
| 90 | 107 | ||
| 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) | |
| 93 | 118 | ||
| 94 | 119 | // Attach frontend (scripts, message handler) | |
| 95 | 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 | 143 | return nil, err | |
| 119 | 144 | } | |
| 120 | 145 | ||
| 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) | |
| 123 | 156 | ||
| 124 | 157 | // Attach frontend | |
| 125 | 158 | 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 *ContentInjector | 19 | injector *ContentInjector | ||
| 19 | router *MessageRouter | 20 | router *MessageRouter | ||
| 20 | filterApplier FilterApplier // Optional content filter applier | 21 | 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 visible | 108 | + | // 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, err | 143 | return nil, err | ||
| 119 | } | 144 | } | ||
| 120 | 145 | ||||
| 121 | - | // Ensure visible | 146 | + | // 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 frontend | 157 | // 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() { | |||
| 27 | 27 | ||
| 28 | 28 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | |
| 29 | 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 | 40 | 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); | |
| 33 | 43 | ||
| 34 | 44 | // Apply dark/light class to document element for CSS theming | |
| 35 | 45 | if (prefersDark) { | |
| @@ -41,44 +51,74 @@ const darkModeScript = `(function() { | |||
| 41 | 51 | } | |
| 42 | 52 | ||
| 43 | 53 | // Inject color-scheme meta tag | |
| 44 | - | const meta = document.createElement('meta'); | |
| 54 | + | var meta = document.createElement('meta'); | |
| 45 | 55 | meta.name = 'color-scheme'; | |
| 46 | 56 | meta.content = prefersDark ? 'dark light' : 'light dark'; | |
| 47 | 57 | document.documentElement.appendChild(meta); | |
| 48 | 58 | ||
| 49 | 59 | // Inject root color-scheme style | |
| 50 | - | const style = document.createElement('style'); | |
| 60 | + | var style = document.createElement('style'); | |
| 51 | 61 | style.setAttribute('data-dumber-color-scheme', ''); | |
| 52 | 62 | style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}'; | |
| 53 | 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 | 115 | // Patch matchMedia for prefers-color-scheme queries | |
| 56 | 116 | 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); | |
| 82 | 122 | } | |
| 83 | 123 | return originalMatchMedia(query); | |
| 84 | 124 | }; | |
| @@ -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 theming | 44 | // 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 tag | 53 | // 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 style | 59 | // 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 queries | 115 | // 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 { | |||
| 33 | 33 | return PoolConfig{ | |
| 34 | 34 | MinSize: 2, | |
| 35 | 35 | MaxSize: 8, | |
| 36 | - | PrewarmCount: 2, | |
| 36 | + | PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation | |
| 37 | 37 | IdleTimeout: 5 * time.Minute, | |
| 38 | 38 | } | |
| 39 | 39 | } | |
| @@ -49,6 +49,10 @@ type WebViewPool struct { | |||
| 49 | 49 | router *MessageRouter | |
| 50 | 50 | 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 | 56 | closed atomic.Bool | |
| 53 | 57 | wg sync.WaitGroup | |
| 54 | 58 | } | |
| @@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||
| 96 | 100 | 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 | 111 | // Acquire gets a WebView from the pool or creates a new one. | |
| 100 | 112 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |
| 101 | 113 | log := logging.FromContext(ctx) | |
| @@ -135,8 +147,16 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||
| 135 | 147 | return nil, err | |
| 136 | 148 | } | |
| 137 | 149 | ||
| 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) | |
| 140 | 160 | ||
| 141 | 161 | if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil { | |
| 142 | 162 | 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 *MessageRouter | 49 | router *MessageRouter | ||
| 50 | filterApplier FilterApplier // Optional content filter applier | 50 | 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.Bool | 56 | closed atomic.Bool | ||
| 53 | wg sync.WaitGroup | 57 | wg sync.WaitGroup | ||
| 54 | } | 58 | } | ||
| @@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||||
| 96 | p.filterApplier = applier | 100 | 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, err | 147 | return nil, err | ||
| 136 | } | 148 | } | ||
| 137 | 149 | ||||
| 138 | - | // Ensure WebView is visible when used | 150 | + | // 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "sync" | |
| 7 | 7 | "sync/atomic" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| @@ -94,6 +95,9 @@ type WebView struct { | |||
| 94 | 95 | canGoFwd bool | |
| 95 | 96 | isLoading bool | |
| 96 | 97 | ||
| 98 | + | // Progress throttling (~60fps) | |
| 99 | + | lastProgressUpdate atomic.Int64 // Unix nanoseconds | |
| 100 | + | ||
| 97 | 101 | // Signal handler IDs for disconnection | |
| 98 | 102 | signalIDs []uint32 | |
| 99 | 103 | ||
| @@ -107,6 +111,8 @@ type WebView struct { | |||
| 107 | 111 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | |
| 108 | 112 | OnReadyToShow func() // Called when popup is ready to display | |
| 109 | 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 | 117 | logger zerolog.Logger | |
| 112 | 118 | mu sync.RWMutex | |
| @@ -314,6 +320,8 @@ func (wv *WebView) connectSignals() { | |||
| 314 | 320 | wv.connectFaviconSignal() | |
| 315 | 321 | wv.connectProgressSignal() | |
| 316 | 322 | wv.connectDecidePolicySignal() | |
| 323 | + | wv.connectEnterFullscreenSignal() | |
| 324 | + | wv.connectLeaveFullscreenSignal() | |
| 317 | 325 | } | |
| 318 | 326 | ||
| 319 | 327 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -447,9 +455,24 @@ func (wv *WebView) connectFaviconSignal() { | |||
| 447 | 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 | 461 | func (wv *WebView) connectProgressSignal() { | |
| 451 | 462 | progressCb := func() { | |
| 452 | 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 | 476 | wv.mu.Lock() | |
| 454 | 477 | wv.progress = progress | |
| 455 | 478 | wv.mu.Unlock() | |
| @@ -520,6 +543,30 @@ func (wv *WebView) connectDecidePolicySignal() { | |||
| 520 | 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 | 570 | // ID returns the unique identifier for this WebView. | |
| 524 | 571 | func (wv *WebView) ID() WebViewID { | |
| 525 | 572 | return wv.id | |
| @@ -701,6 +748,22 @@ func (wv *WebView) GetZoomLevel() float64 { | |||
| 701 | 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 | 767 | // State returns the current WebView state as a snapshot. | |
| 705 | 768 | func (wv *WebView) State() port.WebViewState { | |
| 706 | 769 | return port.WebViewState{ | |
| @@ -812,19 +875,56 @@ func (wv *WebView) DisconnectSignals() { | |||
| 812 | 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 | 881 | func (wv *WebView) Destroy() { | |
| 817 | 882 | if wv.destroyed.Swap(true) { | |
| 818 | 883 | return // Already destroyed | |
| 819 | 884 | } | |
| 820 | 885 | ||
| 821 | - | // Disconnect all signal handlers first | |
| 886 | + | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup | |
| 822 | 887 | wv.DisconnectSignals() | |
| 823 | 888 | ||
| 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 | |
| 825 | 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 | 930 | // 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 bool | 95 | canGoFwd bool | ||
| 95 | isLoading bool | 96 | isLoading bool | ||
| 96 | 97 | ||||
| 98 | + | // Progress throttling (~60fps) | |||
| 99 | + | lastProgressUpdate atomic.Int64 // Unix nanoseconds | |||
| 100 | + | ||||
| 97 | // Signal handler IDs for disconnection | 101 | // Signal handler IDs for disconnection | ||
| 98 | signalIDs []uint32 | 102 | signalIDs []uint32 | ||
| 99 | 103 | ||||
| @@ -107,6 +111,8 @@ type WebView struct { | |||||
| 107 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | 111 | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | ||
| 108 | OnReadyToShow func() // Called when popup is ready to display | 112 | 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.Logger | 117 | logger zerolog.Logger | ||
| 112 | mu sync.RWMutex | 118 | 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 = progress | 477 | 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.id | 572 | 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 destroyed | 883 | return // Already destroyed | ||
| 819 | } | 884 | } | ||
| 820 | 885 | ||||
| 821 | - | // Disconnect all signal handlers first | 886 | + | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup |
| 822 | wv.DisconnectSignals() | 887 | wv.DisconnectSignals() | ||
| 823 | 888 | ||||
| 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 | |||
| 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 { | |||
| 61 | 61 | stackedPaneMgr *component.StackedPaneManager | |
| 62 | 62 | ||
| 63 | 63 | // Input handling | |
| 64 | - | keyboardHandler *input.KeyboardHandler | |
| 64 | + | keyboardHandler *input.KeyboardHandler | |
| 65 | + | globalShortcutHandler *input.GlobalShortcutHandler | |
| 65 | 66 | ||
| 66 | 67 | // Focus and border management | |
| 67 | 68 | focusMgr *focus.Manager | |
| @@ -209,6 +210,13 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||
| 209 | 210 | } | |
| 210 | 211 | ||
| 211 | 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 | 220 | // Prewarm WebView pool now that GTK is initialized. | |
| 213 | 221 | if a.pool == nil { | |
| 214 | 222 | return | |
| @@ -305,6 +313,17 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 305 | 313 | return wsView.IsOmniboxVisible() | |
| 306 | 314 | }) | |
| 307 | 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 | 329 | func (a *App) initOmniboxConfig(ctx context.Context) { | |
| @@ -400,6 +419,12 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 400 | 419 | if a.deps.Pool != nil { | |
| 401 | 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 | 429 | log.Info().Msg("application shutdown complete") | |
| 405 | 430 | } | |
| @@ -431,6 +456,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 431 | 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 | 464 | // 2. Tab Coordinator | |
| 435 | 465 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | |
| 436 | 466 | TabsUC: a.tabsUC, | |
| @@ -481,6 +511,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 481 | 511 | a.injector, | |
| 482 | 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 | 519 | a.contentCoord.SetPopupConfig( | |
| 485 | 520 | a.webViewFactory, | |
| 486 | 521 | &a.deps.Config.Workspace.Popups, | |
| @@ -61,7 +61,8 @@ type App struct { | |||||
| 61 | stackedPaneMgr *component.StackedPaneManager | 61 | stackedPaneMgr *component.StackedPaneManager | ||
| 62 | 62 | ||||
| 63 | // Input handling | 63 | // Input handling | ||
| 64 | - | keyboardHandler *input.KeyboardHandler | 64 | + | keyboardHandler *input.KeyboardHandler |
| 65 | + | globalShortcutHandler *input.GlobalShortcutHandler | |||
| 65 | 66 | ||||
| 66 | // Focus and border management | 67 | // Focus and border management | ||
| 67 | focusMgr *focus.Manager | 68 | 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 | return | 222 | 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 Coordinator | 464 | // 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 | |||
| 42 | 42 | overlay.SetHexpand(true) | |
| 43 | 43 | overlay.SetVexpand(true) | |
| 44 | 44 | overlay.SetVisible(true) | |
| 45 | + | overlay.AddCssClass("pane-overlay") // Theme background prevents white flash | |
| 45 | 46 | ||
| 46 | 47 | // Set the WebView as the main child | |
| 48 | + | // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted | |
| 47 | 49 | if webViewWidget != nil { | |
| 48 | - | webViewWidget.SetVisible(true) | |
| 49 | 50 | overlay.SetChild(webViewWidget) | |
| 50 | 51 | } | |
| 51 | 52 | ||
| @@ -348,3 +349,38 @@ func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) { | |||
| 348 | 349 | ||
| 349 | 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 | + | } | |
| @@ -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 child | 47 | // 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) { | |||
| 25 | 25 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 26 | 26 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 27 | 27 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 28 | - | mockWebView.EXPECT().SetVisible(true).Once() | |
| 28 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 29 | 29 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | |
| 30 | 30 | ||
| 31 | 31 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| @@ -59,6 +59,7 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||
| 59 | 59 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 60 | 60 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 61 | 61 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 62 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 62 | 63 | // SetChild should NOT be called when webview is nil | |
| 63 | 64 | ||
| 64 | 65 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| @@ -504,7 +505,7 @@ func setupPaneViewMocks( | |||
| 504 | 505 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 505 | 506 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 506 | 507 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 507 | - | mockWebView.EXPECT().SetVisible(true).Once() | |
| 508 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 508 | 509 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | |
| 509 | 510 | ||
| 510 | 511 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| @@ -527,6 +528,7 @@ func setupPaneViewMocksNoWebView( | |||
| 527 | 528 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 528 | 529 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 529 | 530 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 531 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 530 | 532 | ||
| 531 | 533 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 532 | 534 | 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 nil | 63 | // 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( | |||
| 76 | 76 | Int("stack_size_before", stackedView.Count()). | |
| 77 | 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 | 81 | // Register the new pane in the TreeRenderer's tracking | |
| 82 | 82 | 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 tracking | 81 | // 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) { | |||
| 379 | 379 | wv.updateSinglePaneModeInternal() | |
| 380 | 380 | } | |
| 381 | 381 | ||
| 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. | |
| 384 | 385 | func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) { | |
| 385 | 386 | wv.mu.Lock() | |
| 386 | 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 | 395 | delete(wv.paneViews, paneID) | |
| 389 | 396 | wv.updateSinglePaneModeInternal() | |
| 390 | 397 | } | |
| @@ -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 | |||
| 41 | 41 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 42 | 42 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 43 | 43 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 44 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() | |
| 44 | 45 | ||
| 45 | 46 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 46 | 47 | 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 { | |||
| 63 | 63 | ||
| 64 | 64 | // ID generator for popup panes | |
| 65 | 65 | generateID func() string | |
| 66 | + | ||
| 67 | + | // Idle inhibitor for fullscreen video playback | |
| 68 | + | idleInhibitor port.IdleInhibitor | |
| 66 | 69 | } | |
| 67 | 70 | ||
| 68 | 71 | // NewContentCoordinator creates a new ContentCoordinator. | |
| @@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler | |||
| 105 | 108 | 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 | 116 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| 109 | 117 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 110 | 118 | log := logging.FromContext(ctx) | |
| @@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||
| 166 | 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 | 180 | // Set up popup handling for this WebView | |
| 170 | 181 | c.SetupPopupHandling(ctx, paneID, wv) | |
| 171 | 182 | ||
| @@ -461,7 +472,7 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||
| 461 | 472 | if tr != nil { | |
| 462 | 473 | stackedView := tr.GetStackedViewForPane(string(paneID)) | |
| 463 | 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 | 496 | // updateStackedPaneTitle updates the title of a pane in a StackedView. | |
| 486 | 497 | func (c *ContentCoordinator) updateStackedPaneTitle( | |
| 487 | 498 | ctx context.Context, | |
| 488 | - | ws *entity.Workspace, | |
| 489 | 499 | sv *layout.StackedView, | |
| 490 | 500 | paneID entity.PaneID, | |
| 491 | 501 | title string, | |
| 492 | 502 | ) { | |
| 493 | 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 | 511 | return | |
| 497 | 512 | } | |
| 498 | 513 | ||
| 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") | |
| 514 | 516 | } | |
| 515 | 517 | } | |
| 516 | 518 | ||
| @@ -534,13 +536,13 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity | |||
| 534 | 536 | } | |
| 535 | 537 | ||
| 536 | 538 | // Update StackedView favicon if this pane is in a stack | |
| 537 | - | ws, wsView := c.getActiveWS() | |
| 539 | + | _, wsView := c.getActiveWS() | |
| 538 | 540 | if wsView != nil { | |
| 539 | 541 | tr := wsView.TreeRenderer() | |
| 540 | 542 | if tr != nil { | |
| 541 | 543 | stackedView := tr.GetStackedViewForPane(string(paneID)) | |
| 542 | 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 | 557 | // updateStackedPaneFavicon updates the favicon of a pane in a StackedView. | |
| 556 | 558 | func (c *ContentCoordinator) updateStackedPaneFavicon( | |
| 557 | 559 | ctx context.Context, | |
| 558 | - | ws *entity.Workspace, | |
| 559 | 560 | sv *layout.StackedView, | |
| 560 | 561 | paneID entity.PaneID, | |
| 561 | 562 | favicon *gdk.Texture, | |
| 562 | 563 | ) { | |
| 563 | 564 | log := logging.FromContext(ctx) | |
| 564 | 565 | ||
| 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") | |
| 571 | 572 | return | |
| 572 | 573 | } | |
| 573 | 574 | ||
| 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") | |
| 584 | 577 | } | |
| 585 | 578 | } | |
| 586 | 579 | ||
| @@ -613,13 +606,13 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en | |||
| 613 | 606 | } | |
| 614 | 607 | ||
| 615 | 608 | // Update stacked pane favicon if applicable | |
| 616 | - | ws, wsView := c.getActiveWS() | |
| 609 | + | _, wsView := c.getActiveWS() | |
| 617 | 610 | if wsView != nil { | |
| 618 | 611 | tr := wsView.TreeRenderer() | |
| 619 | 612 | if tr != nil { | |
| 620 | 613 | stackedView := tr.GetStackedViewForPane(string(paneID)) | |
| 621 | 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 | 621 | // onLoadCommitted re-applies zoom when page content starts loading and records history. | |
| 629 | 622 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | |
| 630 | 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 | 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 | 635 | url := wv.URI() | |
| 633 | 636 | if url == "" { | |
| 634 | 637 | return | |
| @@ -988,6 +991,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 988 | 991 | wv.OnLinkMiddleClick = func(uri string) bool { | |
| 989 | 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 | 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 | 1145 | 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 | + | } | |
| @@ -63,6 +63,9 @@ type ContentCoordinator struct { | |||||
| 63 | 63 | ||||
| 64 | // ID generator for popup panes | 64 | // ID generator for popup panes | ||
| 65 | generateID func() string | 65 | 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 = handler | 108 | 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 WebView | 180 | // 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 | return | 511 | 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 stack | 538 | // 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 | - | return | 567 | + | 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 | return | 572 | return | ||
| 572 | } | 573 | } | ||
| 573 | 574 | ||||
| 574 | - | // If the pane is in a stacked parent, find its index | 575 | + | 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 applicable | 608 | // 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 | return | 637 | 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 true | 1145 | 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 | |||
| 63 | 63 | paneID = string(pane.Pane.ID) | |
| 64 | 64 | // Track original URL for cross-domain redirect favicon caching | |
| 65 | 65 | 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) | |
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | 70 | ||
| @@ -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 caching | 64 | // 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 bar | 66 | + | // 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( | |||
| 287 | 287 | ||
| 288 | 288 | // 4. Wrap the new PaneView in a StackedView | |
| 289 | 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 | 292 | tr := wsView.TreeRenderer() | |
| 293 | 293 | ||
| @@ -461,7 +461,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 461 | 461 | ||
| 462 | 462 | // 5. Wrap the new PaneView in a StackedView | |
| 463 | 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 | 466 | orientation, existingFirst, ok := splitOrientation(direction) | |
| 467 | 467 | if !ok { | |
| @@ -287,7 +287,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||||
| 287 | 287 | ||||
| 288 | // 4. Wrap the new PaneView in a StackedView | 288 | // 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 StackedView | 462 | // 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 { | |||
| 52 | 52 | Clipboard port.Clipboard | |
| 53 | 53 | FaviconService *favicon.Service | |
| 54 | 54 | FilterManager *filtering.Manager | |
| 55 | + | IdleInhibitor port.IdleInhibitor | |
| 55 | 56 | } | |
| 56 | 57 | ||
| 57 | 58 | // Validate checks that all required dependencies are set. | |
| @@ -52,6 +52,7 @@ type Dependencies struct { | |||||
| 52 | Clipboard port.Clipboard | 52 | Clipboard port.Clipboard | ||
| 53 | FaviconService *favicon.Service | 53 | FaviconService *favicon.Service | ||
| 54 | FilterManager *filtering.Manager | 54 | 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() { | |||
| 291 | 291 | } | |
| 292 | 292 | ||
| 293 | 293 | 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. | |
| 306 | 300 | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab | |
| 307 | 301 | } | |
| 308 | 302 | ||
| @@ -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}] = ActionSwitchTabIndex1 | 294 | + | // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler |
| 295 | - | s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2 | 295 | + | // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL. |
| 296 | - | s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3 | 296 | + | // This is necessary because WebKitGTK's WebView consumes these key events |
| 297 | - | s.Global[KeyBinding{uint(gdk.KEY_4), ModAlt}] = ActionSwitchTabIndex4 | 297 | + | // before they reach the EventControllerKey in capture phase. |
| 298 | - | s.Global[KeyBinding{uint(gdk.KEY_5), ModAlt}] = ActionSwitchTabIndex5 | 298 | + | // |
| 299 | - | s.Global[KeyBinding{uint(gdk.KEY_6), ModAlt}] = ActionSwitchTabIndex6 | 299 | + | // 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}] = ActionSwitchLastTab | 300 | 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 | |||
| 21 | 21 | ||
| 22 | 22 | // stackedPane represents a single pane within a stacked container. | |
| 23 | 23 | type stackedPane struct { | |
| 24 | + | paneID string // unique identifier for this pane | |
| 24 | 25 | titleBar BoxWidget // horizontal box with favicon + title label | |
| 25 | 26 | container Widget // the pane view widget | |
| 26 | 27 | title string | |
| @@ -58,7 +59,7 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||
| 58 | 59 | ||
| 59 | 60 | // AddPane adds a new pane to the stack. | |
| 60 | 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 | 63 | log := logging.FromContext(ctx) | |
| 63 | 64 | sv.mu.Lock() | |
| 64 | 65 | defer sv.mu.Unlock() | |
| @@ -101,6 +102,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin | |||
| 101 | 102 | titleButton.SetHexpand(true) // But fill horizontal space | |
| 102 | 103 | ||
| 103 | 104 | pane := &stackedPane{ | |
| 105 | + | paneID: paneID, | |
| 104 | 106 | titleBar: titleBar, | |
| 105 | 107 | container: container, | |
| 106 | 108 | title: title, | |
| @@ -148,7 +150,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin | |||
| 148 | 150 | // The new pane becomes active (visible). | |
| 149 | 151 | // Returns the index where the pane was inserted. | |
| 150 | 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 | 154 | ) int { | |
| 153 | 155 | log := logging.FromContext(ctx) | |
| 154 | 156 | sv.mu.Lock() | |
| @@ -202,6 +204,7 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 202 | 204 | titleButton.SetHexpand(true) | |
| 203 | 205 | ||
| 204 | 206 | pane := &stackedPane{ | |
| 207 | + | paneID: paneID, | |
| 205 | 208 | titleBar: titleBar, | |
| 206 | 209 | container: container, | |
| 207 | 210 | title: title, | |
| @@ -420,6 +423,20 @@ func (sv *StackedView) Count() int { | |||
| 420 | 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 | 440 | // SetOnActivate sets the callback for when a pane is activated via title bar click. | |
| 424 | 441 | func (sv *StackedView) SetOnActivate(fn func(index int)) { | |
| 425 | 442 | 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 label | 25 | titleBar BoxWidget // horizontal box with favicon + title label | ||
| 25 | container Widget // the pane view widget | 26 | container Widget // the pane view widget | ||
| 26 | title string | 27 | 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 space | 102 | 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) { | |||
| 99 | 99 | sv := layout.NewStackedView(mockFactory) | |
| 100 | 100 | ||
| 101 | 101 | // 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 | 104 | // Assert | |
| 105 | 105 | assert.Equal(t, 0, index) | |
| @@ -139,8 +139,8 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) { | |||
| 139 | 139 | sv := layout.NewStackedView(mockFactory) | |
| 140 | 140 | ||
| 141 | 141 | // 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) | |
| 144 | 144 | ||
| 145 | 145 | // Assert | |
| 146 | 146 | assert.Equal(t, 1, index) | |
| @@ -175,9 +175,9 @@ func TestRemovePane_MiddlePane(t *testing.T) { | |||
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | 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]) | |
| 181 | 181 | ||
| 182 | 182 | // Remove middle pane (index 1) | |
| 183 | 183 | // The parent of titleBar is the button widget | |
| @@ -206,7 +206,7 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) { | |||
| 206 | 206 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 207 | 207 | ||
| 208 | 208 | sv := layout.NewStackedView(mockFactory) | |
| 209 | - | sv.AddPane(ctx, "Only Page", "", mockContainer) | |
| 209 | + | sv.AddPane(ctx, "pane-1", "Only Page", "", mockContainer) | |
| 210 | 210 | ||
| 211 | 211 | // Act | |
| 212 | 212 | err := sv.RemovePane(ctx, 0) | |
| @@ -243,7 +243,7 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) { | |||
| 243 | 243 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 244 | 244 | ||
| 245 | 245 | sv := layout.NewStackedView(mockFactory) | |
| 246 | - | sv.AddPane(ctx, "Page", "", mockContainer) | |
| 246 | + | sv.AddPane(ctx, "pane-1", "Page", "", mockContainer) | |
| 247 | 247 | ||
| 248 | 248 | // Act | |
| 249 | 249 | err := sv.RemovePane(ctx, 5) | |
| @@ -276,8 +276,8 @@ func TestSetActive_ValidIndex(t *testing.T) { | |||
| 276 | 276 | } | |
| 277 | 277 | ||
| 278 | 278 | 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]) | |
| 281 | 281 | ||
| 282 | 282 | // Act - switch back to first pane | |
| 283 | 283 | err := sv.SetActive(ctx, 0) | |
| @@ -301,7 +301,7 @@ func TestSetActive_OutOfBounds(t *testing.T) { | |||
| 301 | 301 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 302 | 302 | ||
| 303 | 303 | sv := layout.NewStackedView(mockFactory) | |
| 304 | - | sv.AddPane(ctx, "Page", "", mockContainer) | |
| 304 | + | sv.AddPane(ctx, "pane-1", "Page", "", mockContainer) | |
| 305 | 305 | ||
| 306 | 306 | // Act | |
| 307 | 307 | err := sv.SetActive(ctx, 5) | |
| @@ -334,7 +334,7 @@ func TestUpdateTitle(t *testing.T) { | |||
| 334 | 334 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 335 | 335 | ||
| 336 | 336 | sv := layout.NewStackedView(mockFactory) | |
| 337 | - | sv.AddPane(ctx, "Old Title", "", mockContainer) | |
| 337 | + | sv.AddPane(ctx, "pane-1", "Old Title", "", mockContainer) | |
| 338 | 338 | ||
| 339 | 339 | mockLabel.EXPECT().SetText("New Title").Once() | |
| 340 | 340 | ||
| @@ -368,7 +368,7 @@ func TestUpdateFavicon(t *testing.T) { | |||
| 368 | 368 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 369 | 369 | ||
| 370 | 370 | sv := layout.NewStackedView(mockFactory) | |
| 371 | - | sv.AddPane(ctx, "Page", "", mockContainer) | |
| 371 | + | sv.AddPane(ctx, "pane-1", "Page", "", mockContainer) | |
| 372 | 372 | ||
| 373 | 373 | mockFavicon.EXPECT().SetFromIconName("new-icon").Once() | |
| 374 | 374 | ||
| @@ -393,7 +393,7 @@ func TestGetContainer(t *testing.T) { | |||
| 393 | 393 | mockTitleBar.EXPECT().AddCssClass("active").Once() | |
| 394 | 394 | ||
| 395 | 395 | sv := layout.NewStackedView(mockFactory) | |
| 396 | - | sv.AddPane(ctx, "Page", "", mockContainer) | |
| 396 | + | sv.AddPane(ctx, "pane-1", "Page", "", mockContainer) | |
| 397 | 397 | ||
| 398 | 398 | // Act | |
| 399 | 399 | container, err := sv.GetContainer(0) | |
| @@ -452,8 +452,8 @@ func TestNavigateNext_WrapsAround(t *testing.T) { | |||
| 452 | 452 | } | |
| 453 | 453 | ||
| 454 | 454 | 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]) | |
| 457 | 457 | ||
| 458 | 458 | // Currently at index 1 (last added) | |
| 459 | 459 | assert.Equal(t, 1, sv.ActiveIndex()) | |
| @@ -490,8 +490,8 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) { | |||
| 490 | 490 | } | |
| 491 | 491 | ||
| 492 | 492 | 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]) | |
| 495 | 495 | ||
| 496 | 496 | // Set to first pane | |
| 497 | 497 | sv.SetActive(ctx, 0) | |
| @@ -617,7 +617,7 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||
| 617 | 617 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 618 | 618 | ||
| 619 | 619 | sv := layout.NewStackedView(mockFactory) | |
| 620 | - | sv.AddPane(ctx, "Page 1", "", mockContainer1) | |
| 620 | + | sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1) | |
| 621 | 621 | ||
| 622 | 622 | // Second pane - inserted at beginning (afterIndex=-1) | |
| 623 | 623 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil) | |
| @@ -632,7 +632,7 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||
| 632 | 632 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 633 | 633 | ||
| 634 | 634 | // Act | |
| 635 | - | index := sv.InsertPaneAfter(ctx, -1, "Page 0", "", mockContainer2) | |
| 635 | + | index := sv.InsertPaneAfter(ctx, -1, "pane-0", "Page 0", "", mockContainer2) | |
| 636 | 636 | ||
| 637 | 637 | // Assert | |
| 638 | 638 | assert.Equal(t, 0, index) | |
| @@ -658,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) { | |||
| 658 | 658 | } | |
| 659 | 659 | ||
| 660 | 660 | 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]) | |
| 663 | 663 | ||
| 664 | 664 | // Set active to first pane | |
| 665 | 665 | require.NoError(t, sv.SetActive(ctx, 0)) | |
| @@ -672,7 +672,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) { | |||
| 672 | 672 | mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe() | |
| 673 | 673 | ||
| 674 | 674 | // 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 | 677 | // Assert | |
| 678 | 678 | assert.Equal(t, 1, index) | |
| @@ -695,7 +695,7 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||
| 695 | 695 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 696 | 696 | ||
| 697 | 697 | sv := layout.NewStackedView(mockFactory) | |
| 698 | - | sv.AddPane(ctx, "Page 1", "", mockContainer1) | |
| 698 | + | sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1) | |
| 699 | 699 | ||
| 700 | 700 | // Insert after last pane (afterIndex=0, becomes index 1) | |
| 701 | 701 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | |
| @@ -709,7 +709,7 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||
| 709 | 709 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 710 | 710 | ||
| 711 | 711 | // Act | |
| 712 | - | index := sv.InsertPaneAfter(ctx, 0, "Page 2", "", mockContainer2) | |
| 712 | + | index := sv.InsertPaneAfter(ctx, 0, "pane-2", "Page 2", "", mockContainer2) | |
| 713 | 713 | ||
| 714 | 714 | // Assert | |
| 715 | 715 | assert.Equal(t, 1, index) | |
| @@ -742,9 +742,9 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||
| 742 | 742 | } | |
| 743 | 743 | ||
| 744 | 744 | 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 | |
| 748 | 748 | ||
| 749 | 749 | // Navigate to pane B (index 1) | |
| 750 | 750 | require.NoError(t, sv.SetActive(ctx, 1)) | |
| @@ -757,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||
| 757 | 757 | mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe() | |
| 758 | 758 | ||
| 759 | 759 | // Act | |
| 760 | - | index := sv.InsertPaneAfter(ctx, 1, "D", "", mockContainer4) | |
| 760 | + | index := sv.InsertPaneAfter(ctx, 1, "pane-d", "D", "", mockContainer4) | |
| 761 | 761 | ||
| 762 | 762 | // Assert: Order should be A(0), B(1), D(2), C(3) | |
| 763 | 763 | assert.Equal(t, 2, index) | |
| @@ -791,7 +791,7 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||
| 791 | 791 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | |
| 792 | 792 | ||
| 793 | 793 | sv := layout.NewStackedView(mockFactory) | |
| 794 | - | sv.AddPane(ctx, "Page 1", "", mockContainer1) | |
| 794 | + | sv.AddPane(ctx, "pane-1", "Page 1", "", mockContainer1) | |
| 795 | 795 | ||
| 796 | 796 | // Try to insert at invalid index (100) - should clamp to end | |
| 797 | 797 | mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1) | |
| @@ -805,7 +805,7 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||
| 805 | 805 | mockTitleBar2.EXPECT().AddCssClass("active").Once() | |
| 806 | 806 | ||
| 807 | 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 | 810 | // Assert | |
| 811 | 811 | 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 | // Act | 101 | // 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 | // Assert | 104 | // 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 | // Act | 141 | // 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 | // Assert | 145 | // 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 widget | 183 | // 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 | // Act | 211 | // 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 | // Act | 248 | // 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 pane | 282 | // 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 | // Act | 306 | // 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 | // Act | 398 | // 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 pane | 496 | // 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 | // Act | 634 | // Act | ||
| 635 | - | index := sv.InsertPaneAfter(ctx, -1, "Page 0", "", mockContainer2) | 635 | + | index := sv.InsertPaneAfter(ctx, -1, "pane-0", "Page 0", "", mockContainer2) |
| 636 | 636 | ||||
| 637 | // Assert | 637 | // 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 pane | 664 | // 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 | // Act | 674 | // 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 | // Assert | 677 | // 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 | // Act | 711 | // Act | ||
| 712 | - | index := sv.InsertPaneAfter(ctx, 0, "Page 2", "", mockContainer2) | 712 | + | index := sv.InsertPaneAfter(ctx, 0, "pane-2", "Page 2", "", mockContainer2) |
| 713 | 713 | ||||
| 714 | // Assert | 714 | // 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 0 | 745 | + | sv.AddPane(ctx, "pane-a", "A", "", containers[0]) // index 0 |
| 746 | - | sv.AddPane(ctx, "B", "", containers[1]) // index 1 | 746 | + | sv.AddPane(ctx, "pane-b", "B", "", containers[1]) // index 1 |
| 747 | - | sv.AddPane(ctx, "C", "", containers[2]) // index 2 | 747 | + | 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 | // Act | 759 | // 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 end | 796 | // 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 | // Assert | 810 | // 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 | |||
| 135 | 135 | Str("title", title). | |
| 136 | 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 | 140 | // Track this pane's StackedView for later stacking operations | |
| 141 | 141 | if node.Pane != nil { | |
| @@ -185,22 +185,24 @@ func (tr *TreeRenderer) renderStacked(ctx context.Context, node *entity.PaneNode | |||
| 185 | 185 | return nil, err | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | - | // Get title and favicon from pane if available | |
| 188 | + | // Get pane ID, title, and favicon from pane if available | |
| 189 | + | paneID := "" | |
| 189 | 190 | title := "Untitled" | |
| 190 | 191 | favicon := "" | |
| 191 | 192 | if child.Pane != nil { | |
| 193 | + | paneID = string(child.Pane.ID) | |
| 192 | 194 | if child.Pane.Title != "" { | |
| 193 | 195 | title = child.Pane.Title | |
| 194 | 196 | } | |
| 195 | 197 | 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 | 202 | // Override paneToStack to point to THIS stacked view, not the child's wrapper. | |
| 201 | 203 | // This ensures geometric navigation and stack sync work correctly. | |
| 202 | 204 | if child.Pane != nil { | |
| 203 | - | tr.paneToStack[string(child.Pane.ID)] = stackedView | |
| 205 | + | tr.paneToStack[paneID] = stackedView | |
| 204 | 206 | } | |
| 205 | 207 | } | |
| 206 | 208 | ||
| @@ -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 operations | 140 | // 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, err | 185 | return nil, err | ||
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | - | // Get title and favicon from pane if available | 188 | + | // 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.Title | 195 | title = child.Pane.Title | ||
| 194 | } | 196 | } | ||
| 195 | favicon = child.Pane.FaviconURL | 197 | 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)] = stackedView | 205 | + | 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 { | |||
| 434 | 434 | func generatePaneCSS(p Palette) string { | |
| 435 | 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 | 442 | /* Pane border - default transparent */ | |
| 438 | 443 | .pane-border { | |
| 439 | 444 | 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 { | |||
| 87 | 87 | 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 | 97 | // GetLightPalette returns the light theme palette. | |
| 91 | 98 | func (m *Manager) GetLightPalette() Palette { | |
| 92 | 99 | return m.lightPalette | |
| @@ -87,6 +87,13 @@ func (m *Manager) GetCurrentPalette() Palette { | |||||
| 87 | return m.lightPalette | 87 | 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.lightPalette | 99 | return m.lightPalette | ||
M internal/ui/theme/palette.go +64 -0
| @@ -97,6 +97,70 @@ func Coalesce(values ...string) string { | |||
| 97 | 97 | // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA). | |
| 98 | 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 | 164 | // ValidateHexColor checks if a string is a valid hex color. | |
| 101 | 165 | func ValidateHexColor(color string) error { | |
| 102 | 166 | 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 @@ | |||
| 29 | 29 | "eslint": "^8.57.0", | |
| 30 | 30 | "postcss": "^8.4.47", | |
| 31 | 31 | "svelte": "^5.0.0", | |
| 32 | + | "svelte-awesome-color-picker": "^4.1.0", | |
| 32 | 33 | "svelte-check": "^4.0.0", | |
| 33 | 34 | "tailwind-variants": "^3.2.2", | |
| 34 | 35 | "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 @@ | |||
| 2 | 2 | import { onMount } from "svelte"; | |
| 3 | 3 | import ConfigShell from "./config/ConfigShell.svelte"; | |
| 4 | 4 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |
| 5 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |
| 5 | 6 | import { Button } from "$lib/components/ui/button"; | |
| 7 | + | import { ColorPicker } from "$lib/components/ui/color-picker"; | |
| 6 | 8 | import { Input } from "$lib/components/ui/input"; | |
| 7 | 9 | import { Label } from "$lib/components/ui/label"; | |
| 8 | 10 | import { Spinner } from "$lib/components/ui/spinner"; | |
| @@ -84,13 +86,10 @@ | |||
| 84 | 86 | loadConfig(); | |
| 85 | 87 | } | |
| 86 | 88 | ||
| 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); | |
| 93 | 90 | ||
| 91 | + | async function doResetToDefaults() { | |
| 92 | + | saveSuccess = false; | |
| 94 | 93 | try { | |
| 95 | 94 | const response = await fetch("/api/config/default"); | |
| 96 | 95 | if (!response.ok) throw new Error("Failed to fetch default config"); | |
| @@ -98,6 +97,7 @@ | |||
| 98 | 97 | } catch (e: any) { | |
| 99 | 98 | console.error("[config] reset defaults failed", e); | |
| 100 | 99 | } | |
| 100 | + | resetDialogOpen = false; | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | 103 | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |
| @@ -305,15 +305,12 @@ | |||
| 305 | 305 | <div class="space-y-2"> | |
| 306 | 306 | <Label for={`light_${field.key}`}>{field.label}</Label> | |
| 307 | 307 | <div class="flex items-center gap-3"> | |
| 308 | - | <input | |
| 308 | + | <ColorPicker | |
| 309 | 309 | id={`light_${field.key}`} | |
| 310 | - | type="color" | |
| 311 | 310 | value={config.appearance.light_palette[field.key]} | |
| 312 | - | oninput={(e) => { | |
| 313 | - | const v = (e.currentTarget as HTMLInputElement).value; | |
| 311 | + | onValueChange={(v) => { | |
| 314 | 312 | config!.appearance.light_palette[field.key] = v; | |
| 315 | 313 | }} | |
| 316 | - | class="h-10 w-12 border border-border bg-background" | |
| 317 | 314 | /> | |
| 318 | 315 | <Input bind:value={config.appearance.light_palette[field.key]} /> | |
| 319 | 316 | </div> | |
| @@ -334,15 +331,12 @@ | |||
| 334 | 331 | <div class="space-y-2"> | |
| 335 | 332 | <Label for={`dark_${field.key}`}>{field.label}</Label> | |
| 336 | 333 | <div class="flex items-center gap-3"> | |
| 337 | - | <input | |
| 334 | + | <ColorPicker | |
| 338 | 335 | id={`dark_${field.key}`} | |
| 339 | - | type="color" | |
| 340 | 336 | value={config.appearance.dark_palette[field.key]} | |
| 341 | - | oninput={(e) => { | |
| 342 | - | const v = (e.currentTarget as HTMLInputElement).value; | |
| 337 | + | onValueChange={(v) => { | |
| 343 | 338 | config!.appearance.dark_palette[field.key] = v; | |
| 344 | 339 | }} | |
| 345 | - | class="h-10 w-12 border border-border bg-background" | |
| 346 | 340 | /> | |
| 347 | 341 | <Input bind:value={config.appearance.dark_palette[field.key]} /> | |
| 348 | 342 | </div> | |
| @@ -394,9 +388,30 @@ | |||
| 394 | 388 | {/if} | |
| 395 | 389 | </div> | |
| 396 | 390 | <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> | |
| 400 | 415 | <Button onclick={saveConfig} disabled={saving} type="button"> | |
| 401 | 416 | {saving ? "Saving…" : "Save"} | |
| 402 | 417 | </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 | - | <input | 308 | + | <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 | - | <input | 334 | + | <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 Defaults | 392 | + | <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> | ||