detached 15f97d3

Merge pull request #74 from bnema/release/v0.23.0

Release v0.23.0
commit
15f97d315df9a6e1a6e4dd56aa3e4ee6bbb3504b
author
bnema · · 2025-12-30 17:40 UTC
committer
GitHub · · 2025-12-30 17:40 UTC
merge of
a85da87 + b4e0a39 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
81 files · +9275 -810
M .github/workflows/ci.yml +4 -1
@@ -77,7 +77,7 @@ jobs:
7777 - name: Install dependencies
7878 run: |
7979 pacman -Syu --noconfirm
80- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel
80+ pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
8181 - uses: actions/checkout@v6
8282 - name: Cache Go modules
8383 uses: actions/cache@v4
@@ -103,4 +103,7 @@ jobs:
103103 - name: Build frontend assets
104104 run: make build-frontend
105105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
106109 run: go test -v ./...
M .mockery.yaml +8 -0
@@ -18,6 +18,14 @@ packages:
1818 WebView: {}
1919 WebViewFactory: {}
2020 WebViewPool: {}
21+ MemoryPressureApplier: {}
22+ WebKitContextProvider: {}
23+ HardwareSurveyor: {}
24+ ColorSchemeDetector: {}
25+ ColorSchemeResolver: {}
26+ TextInputTarget: {}
27+ FocusedInputProvider: {}
28+ AccentPickerUI: {}
2129 github.com/bnema/dumber/internal/domain/repository:
2230 config:
2331 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +27 -0
@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+
9+### Changed
10+
11+### Fixed
12+
13+## [0.23.0] - 2025-12-30
14+
15+### Added
16+- **macOS-style accent picker**: Long-press (400ms) on keys with accent variants (e, a, o, u, i, c, n, s, y, z) shows a popup with accented character options. Select with number keys 1-9, arrow keys + Enter, or click. Works in omnibox, find bar (Ctrl+F), and web page inputs. Implements dead keys/compose sequence alternative for users who can't use system-level input methods.
17+- **Performance profiles (WebKit tuning presets)**: New `performance.profile` setting (`default`, `lite`, `max`, `custom`) plus WebUI support for selecting and editing performance tuning values.
18+- **dmenu history enhancements**: New `dumber dmenu --days N` (time-window history) and `--most-visited` (sort by visit count).
19+
20+### Changed
21+- **Color scheme detection architecture**: Refactored theme preference detection into a priority-based `ColorSchemeResolver` with multiple detectors (libadwaita/GTK/gsettings/env) and a change notification API for consistent dark-mode behavior.
22+- **dmenu history limiting**: dmenu history limiting is now time-based (days) instead of a fixed max item count.
23+- **WebKit dependency**: Bumped `github.com/bnema/puregotk-webkit` to `v0.0.9`.
24+
25+### Fixed
26+- **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.
27+- **External site form theming artifacts**: Removed injected `color-scheme` overrides that caused odd styling in some forms/inputs on regular websites.
28+- **WebKit memory pressure stability**: Fixed an assertion by applying strict memory thresholds before conservative thresholds when configuring WebKit’s memory pressure handlers.
29+- **Web process termination on perf tuning**: Removed the “memory kill” threshold because it could terminate the web process immediately in some environments.
30+- **`dumb://` scheme context mismatch**: Fixed internal pages sometimes using a different WebKit context than expected by always using the default context.
31+- **Accent picker Escape behavior**: Fixed cases where Escape wouldn’t dismiss the accent picker after mouse interaction by restoring focus appropriately.
32+- **dmenu history days edge case**: Fixed `dumber dmenu --days 0` to mean “all history”, and added validation for invalid day values.
33+
734 ## [0.22.0] - 2025-12-28
835
936 ### Added
M README.md +3 -1
@@ -125,6 +125,7 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
125125 | **Space** | Toggle Favorite | Omnibox mode: favorite/unfavorite selected item |
126126 | **Ctrl+1-9, 0** | Quick Navigate | Jump to result by number (1-10) |
127127 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |
128+| **Hold key 400ms** | Accent Picker | Shows accented variants in any text input (e.g., hold 'e' for è, é, ê, ë) |
128129
129130 All zoom changes are automatically persisted per-domain and restored on next visit.
130131
@@ -251,7 +252,8 @@ sudo apt install va-driver-all
251252 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
252253 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
253254 - `dumber dmenu --interactive` # built-in TUI fuzzy finder
254- - `dumber dmenu --max 200` # limit output entries
255+ - `dumber dmenu --days 7` # show history from last 7 days (default: 30)
256+ - `dumber dmenu --most-visited` # sort by visit count instead of recency
255257 - Manage browsing history:
256258 - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
257259 - `dumber history --json` # output recent entries as JSON
M cmd/dumber/main.go +25 -7
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/dumber/internal/domain/repository"
1919 "github.com/bnema/dumber/internal/infrastructure/cache"
2020 "github.com/bnema/dumber/internal/infrastructure/clipboard"
21+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
2122 "github.com/bnema/dumber/internal/infrastructure/config"
2223 "github.com/bnema/dumber/internal/infrastructure/deps"
2324 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -130,7 +131,11 @@ func runGUI() int {
130131 timer.Mark("use_cases")
131132
132133 // Build UI
133- uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ uiDeps := buildUIDependencies(
135+ ctx, cfg, initResult.ThemeManager,
136+ initResult.ColorResolver, initResult.AdwaitaDetector,
137+ &stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
138+ )
134139 configureDeferredInit(uiDeps, cfg, browserSession)
135140 app, err := ui.New(uiDeps)
136141 if err != nil {
@@ -171,11 +176,12 @@ func initStackAndRepos(
171176 if needsEagerDB {
172177 // Parallel phase 2: Database + WebKit stack initialize concurrently
173178 dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174- Ctx: ctx,
175- Config: cfg,
176- DataDir: initResult.DataDir,
177- CacheDir: initResult.CacheDir,
178- ThemeManager: initResult.ThemeManager,
179+ Ctx: ctx,
180+ Config: cfg,
181+ DataDir: initResult.DataDir,
182+ CacheDir: initResult.CacheDir,
183+ ThemeManager: initResult.ThemeManager,
184+ ColorResolver: initResult.ColorResolver,
179185 })
180186 if err != nil {
181187 return bootstrap.WebKitStack{}, nil, nil, err
@@ -184,7 +190,15 @@ func initStackAndRepos(
184190 }
185191
186192 log := logging.FromContext(ctx)
187- stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
193+ stack := bootstrap.BuildWebKitStack(bootstrap.WebKitStackInput{
194+ Ctx: ctx,
195+ Config: cfg,
196+ DataDir: initResult.DataDir,
197+ CacheDir: initResult.CacheDir,
198+ ThemeManager: initResult.ThemeManager,
199+ ColorResolver: initResult.ColorResolver,
200+ Logger: *log,
201+ })
188202
189203 lazyDB, err := bootstrap.CreateLazyDatabase()
190204 if err != nil {
@@ -398,6 +412,8 @@ func buildUIDependencies(
398412 ctx context.Context,
399413 cfg *config.Config,
400414 themeManager *theme.Manager,
415+ colorResolver port.ColorSchemeResolver,
416+ adwaitaDetector *colorscheme.AdwaitaDetector,
401417 stack *bootstrap.WebKitStack,
402418 repos *repositories,
403419 uc *useCases,
@@ -410,6 +426,8 @@ func buildUIDependencies(
410426 InitialURL: initialURL,
411427 RestoreSessionID: restoreSessionID,
412428 Theme: themeManager,
429+ ColorResolver: colorResolver,
430+ AdwaitaDetector: adwaitaDetector,
413431 WebContext: stack.Context,
414432 Pool: stack.Pool,
415433 Settings: stack.Settings,
M docs/CONFIG.md +96 -4
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
8383
8484 | Key | Type | Default | Description |
8585 |-----|------|---------|-------------|
86-| `dmenu.max_history_items` | int | `20` | Max items shown in launcher |
86+| `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) |
8787 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
8888 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
8989 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
9090 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
9191 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
9292 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |
93-| `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity |
93+| `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency |
9494
9595 **CLI Usage:**
9696 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101101 # Interactive TUI mode
102102 dumber dmenu --interactive
103103
104-# Override max items via CLI flag
105-dumber dmenu --max 50
104+# Override history days via CLI flag (show last 7 days)
105+dumber dmenu --days 7
106+
107+# Sort by most visited instead of recency
108+dumber dmenu --most-visited
109+
110+# Combined: most visited from last 14 days
111+dumber dmenu --days 14 --most-visited
106112 ```
107113
108114 ## Omnibox
@@ -491,6 +497,92 @@ dumber config migrate
491497 dumber config migrate --yes
492498 ```
493499
500+## Performance Profiles
501+
502+Performance profiles provide preset configurations for WebKitGTK tuning. These settings affect Skia rendering threads, memory pressure handling, and WebView pool behavior.
503+
504+> **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect.
505+
506+| Key | Type | Default | Valid Values | Description |
507+|-----|------|---------|--------------|-------------|
508+| `performance.profile` | string | `"default"` | `default`, `lite`, `max`, `custom` | Performance profile selection |
509+
510+### Profiles
511+
512+| Profile | Description | Use Case |
513+|---------|-------------|----------|
514+| `default` | No tuning, uses WebKit defaults | Normal browsing, most users |
515+| `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |
516+| `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |
517+| `custom` | Manual control over all settings | Advanced users who want fine-grained control |
518+
519+### Profile Settings Matrix
520+
521+| Setting | default | lite | max |
522+|---------|---------|------|-----|
523+| Skia CPU threads | unset | 2 | `NumCPU()/2` (min 4) |
524+| Skia GPU threads | unset | unset | scales with VRAM |
525+| Web process memory (MB) | unset | 768 | unset |
526+| Network process memory (MB) | unset | 384 | unset |
527+| Conservative threshold | unset | 0.25 | unset |
528+| Strict threshold | unset | 0.4 | unset |
529+| WebView pool prewarm | 4 | 2 | scales with RAM |
530+
531+**Example:**
532+```toml
533+[performance]
534+profile = "default" # Most users - no tuning needed
535+
536+# For low-RAM systems:
537+# profile = "lite"
538+
539+# For heavy pages (GitHub PRs, complex web apps):
540+# profile = "max"
541+
542+# For manual control:
543+# profile = "custom"
544+```
545+
546+### Custom Profile Settings
547+
548+When `profile = "custom"`, you can configure individual tuning options:
549+
550+| Key | Type | Default | Description |
551+|-----|------|---------|-------------|
552+| `performance.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) |
553+| `performance.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) |
554+| `performance.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering |
555+| `performance.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) |
556+| `performance.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) |
557+| `performance.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) |
558+| `performance.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) |
559+| `performance.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB |
560+| `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval |
561+| `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold |
562+| `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
563+| `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup |
564+| `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache |
565+
566+**Custom profile example:**
567+```toml
568+[performance]
569+profile = "custom"
570+
571+# Skia threading - tune for your CPU
572+skia_cpu_painting_threads = 4
573+skia_gpu_painting_threads = 2
574+
575+# Web process memory pressure
576+web_process_memory_limit_mb = 1024
577+web_process_memory_conservative_threshold = 0.4
578+web_process_memory_strict_threshold = 0.6
579+
580+# WebView pool
581+webview_pool_prewarm_count = 6
582+```
583+
584+> **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.
585+
494586 ## Environment Variables
495587
496588 All config values can be overridden via environment variables with the prefix `DUMBER_`:
M go.mod +2 -2
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.7
6+ github.com/bnema/puregotk-webkit v0.0.9
77 github.com/charmbracelet/bubbles v0.21.0
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
@@ -19,6 +19,7 @@ require (
1919 github.com/stretchr/testify v1.11.1
2020 golang.org/x/image v0.34.0
2121 golang.org/x/sys v0.38.0
22+ gopkg.in/yaml.v3 v3.0.1
2223 )
2324
2425 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -64,5 +65,4 @@ require (
6465 go.yaml.in/yaml/v3 v3.0.4 // indirect
6566 golang.org/x/sync v0.19.0 // indirect
6667 golang.org/x/text v0.32.0 // indirect
67- gopkg.in/yaml.v3 v3.0.1 // indirect
6868 )
M go.sum +2 -2
@@ -6,8 +6,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
66 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
77 github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 h1:CXA7347oo/SI8NqQz9um1u5WRpume5Y63U6OK4cNJEg=
88 github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.7 h1:bwBQ0QkJHEp6A2WfKhNnV6Pb0jXZ236LFSlidX5HC0U=
10-github.com/bnema/puregotk-webkit v0.0.7/go.mod h1:vOXdcQ9LZmIhUn5Q8M/sHEwZ/NkvDtB4/8fnJaFbxS8=
9+github.com/bnema/puregotk-webkit v0.0.9 h1:vE4mtgoQM/aOS3dz27PF2MMvaorXnrVEwQrawVj49u4=
10+github.com/bnema/puregotk-webkit v0.0.9/go.mod h1:vOXdcQ9LZmIhUn5Q8M/sHEwZ/NkvDtB4/8fnJaFbxS8=
1111 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
1212 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
1313 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
A internal/application/port/accent_picker.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+// AccentPickerUI defines the interface for displaying an accent selection overlay.
4+// The picker shows a list of accented characters and allows the user to select one
5+// via arrow keys, number keys (1-9), or Enter.
6+type AccentPickerUI interface {
7+ // Show displays the accent picker with the given accent variants.
8+ // selectedCallback is called when the user selects an accent.
9+ // cancelCallback is called when the user cancels (Escape key).
10+ Show(accents []rune, selectedCallback func(accent rune), cancelCallback func())
11+
12+ // Hide hides the accent picker.
13+ Hide()
14+
15+ // IsVisible returns true if the accent picker is currently visible.
16+ IsVisible() bool
17+}
A internal/application/port/color_scheme.go +57 -0
@@ -0,0 +1,57 @@
1+package port
2+
3+// ColorSchemePreference represents the resolved color scheme preference.
4+type ColorSchemePreference struct {
5+ // PrefersDark indicates whether dark mode is preferred.
6+ PrefersDark bool
7+
8+ // Source identifies which detector provided this preference.
9+ // Empty string means fallback was used.
10+ Source string
11+}
12+
13+// ColorSchemeDetector detects the system's color scheme preference.
14+// Multiple detectors can be registered with different priorities.
15+type ColorSchemeDetector interface {
16+ // Name returns a human-readable name for this detector.
17+ Name() string
18+
19+ // Priority returns the detector's priority.
20+ // Higher values = higher priority (checked first).
21+ // Recommended ranges:
22+ // - 100+: Runtime detectors (libadwaita, GTK Settings)
23+ // - 50+: Config file detectors
24+ // - 10+: Fallback detectors (gsettings, env vars)
25+ Priority() int
26+
27+ // Available returns true if this detector can be used.
28+ // For example, libadwaita detector returns false before adw.Init().
29+ Available() bool
30+
31+ // Detect returns the detected preference and whether detection succeeded.
32+ // Returns (preference, true) on success, (_, false) if unavailable or detection failed.
33+ Detect() (prefersDark bool, ok bool)
34+}
35+
36+// ColorSchemeResolver resolves the effective color scheme preference.
37+// It manages multiple detectors and respects config overrides.
38+type ColorSchemeResolver interface {
39+ // Resolve returns the current color scheme preference.
40+ // It checks config for explicit overrides, then queries detectors by priority.
41+ // If all detectors fail, defaults to dark mode.
42+ Resolve() ColorSchemePreference
43+
44+ // RegisterDetector adds a detector to the resolver.
45+ // Safe to call at any time; the resolver re-evaluates on next Resolve().
46+ RegisterDetector(detector ColorSchemeDetector)
47+
48+ // Refresh forces re-evaluation of the color scheme.
49+ // Call this after registering new detectors or when system preferences change.
50+ // Returns the new preference.
51+ Refresh() ColorSchemePreference
52+
53+ // OnChange registers a callback for color scheme changes.
54+ // The callback is invoked when Refresh() results in a different preference.
55+ // Returns a function to unregister the callback.
56+ OnChange(callback func(ColorSchemePreference)) func()
57+}
A internal/application/port/hardware.go +69 -0
@@ -0,0 +1,69 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import "context"
5+
6+// HardwareInfo contains detected system hardware specifications.
7+// Used for performance profile auto-tuning.
8+type HardwareInfo struct {
9+ // CPU information
10+ CPUCores int // Number of physical CPU cores
11+ CPUThreads int // Number of logical CPU threads (with hyperthreading)
12+
13+ // Memory information (in bytes)
14+ TotalRAM uint64 // Total system RAM
15+ AvailableRAM uint64 // Currently available RAM
16+
17+ // GPU information
18+ GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown
19+ GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT")
20+ VRAM uint64 // Video memory in bytes (0 if unknown)
21+}
22+
23+// CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback.
24+func (h HardwareInfo) CPUCoresOrDefault(fallback int) int {
25+ if h.CPUCores > 0 {
26+ return h.CPUCores
27+ }
28+ return fallback
29+}
30+
31+// CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback.
32+func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int {
33+ if h.CPUThreads > 0 {
34+ return h.CPUThreads
35+ }
36+ return fallback
37+}
38+
39+// TotalRAMMB returns total RAM in megabytes.
40+// Returns 0 if RAM exceeds int range (unlikely on any real system).
41+func (h HardwareInfo) TotalRAMMB() int {
42+ mb := h.TotalRAM / (1024 * 1024)
43+ if mb > uint64(^uint(0)>>1) { // Check against max int
44+ return 0
45+ }
46+ return int(mb)
47+}
48+
49+// VRAMMB returns VRAM in megabytes.
50+// Returns 0 if VRAM exceeds int range (unlikely on any real system).
51+func (h HardwareInfo) VRAMMB() int {
52+ mb := h.VRAM / (1024 * 1024)
53+ if mb > uint64(^uint(0)>>1) { // Check against max int
54+ return 0
55+ }
56+ return int(mb)
57+}
58+
59+// HasDedicatedGPU returns true if a discrete GPU with VRAM was detected.
60+func (h HardwareInfo) HasDedicatedGPU() bool {
61+ return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown
62+}
63+
64+// HardwareSurveyor detects system hardware specifications.
65+type HardwareSurveyor interface {
66+ // Survey detects and returns hardware information.
67+ // This should be called once at startup and cached.
68+ Survey(ctx context.Context) HardwareInfo
69+}
M internal/application/port/mocks/mocks.go +1704 -388 large diff (2092 lines changed): collapsed
M internal/application/port/rendering_env.go +10 -0
@@ -46,6 +46,16 @@ type RenderingEnvSettings struct {
4646 ShowFPS bool
4747 SampleMemory bool
4848 DebugFrames bool
49+
50+ // --- Skia rendering thread settings (from PerformanceConfig) ---
51+ // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS.
52+ // 0 means unset (use WebKit default).
53+ SkiaCPUPaintingThreads int
54+ // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS.
55+ // -1 means unset; 0 disables GPU tile painting.
56+ SkiaGPUPaintingThreads int
57+ // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1.
58+ SkiaEnableCPURendering bool
4959 }
5060
5161 // RenderingEnvManager configures rendering environment variables
A internal/application/port/text_input.go +25 -0
@@ -0,0 +1,25 @@
1+package port
2+
3+import "context"
4+
5+// TextInputTarget represents a text input field that can receive text insertions.
6+// Implementations include GTK Entry widgets and WebView text fields.
7+type TextInputTarget interface {
8+ // InsertText inserts text at the current cursor position.
9+ InsertText(ctx context.Context, text string) error
10+
11+ // DeleteBeforeCursor deletes n characters before the cursor position.
12+ DeleteBeforeCursor(ctx context.Context, n int) error
13+}
14+
15+// FocusedInputProvider tracks which text input target currently has focus.
16+// This allows the accent picker to insert text into the correct input field.
17+type FocusedInputProvider interface {
18+ // GetFocusedInput returns the currently focused text input target.
19+ // Returns nil if no text input has focus.
20+ GetFocusedInput() TextInputTarget
21+
22+ // SetFocusedInput sets the currently focused text input target.
23+ // Pass nil to clear focus.
24+ SetFocusedInput(target TextInputTarget)
25+}
A internal/application/port/webkit_context.go +102 -0
@@ -0,0 +1,102 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import "context"
5+
6+// MemoryPressureConfig holds memory pressure settings for a WebKit process.
7+// Zero values mean "use WebKit defaults".
8+type MemoryPressureConfig struct {
9+ // MemoryLimitMB sets the memory limit in megabytes.
10+ // 0 means unset (uses WebKit default: system RAM capped at 3GB).
11+ MemoryLimitMB int
12+
13+ // PollIntervalSec sets the interval for memory usage checks.
14+ // 0 means unset (uses WebKit default: 30 seconds).
15+ PollIntervalSec float64
16+
17+ // ConservativeThreshold sets threshold for conservative memory release.
18+ // Must be in (0, 1). 0 means unset (uses WebKit default: 0.33).
19+ ConservativeThreshold float64
20+
21+ // StrictThreshold sets threshold for strict memory release.
22+ // Must be in (0, 1). 0 means unset (uses WebKit default: 0.5).
23+ StrictThreshold float64
24+}
25+
26+// IsConfigured returns true if any memory pressure setting is configured.
27+func (c *MemoryPressureConfig) IsConfigured() bool {
28+ if c == nil {
29+ return false
30+ }
31+ return c.MemoryLimitMB > 0 ||
32+ c.PollIntervalSec > 0 ||
33+ c.ConservativeThreshold > 0 ||
34+ c.StrictThreshold > 0
35+}
36+
37+// WebKitContextOptions configures WebKitContext creation.
38+type WebKitContextOptions struct {
39+ // DataDir is the directory for persistent data (cookies, storage, etc.).
40+ DataDir string
41+
42+ // CacheDir is the directory for cache data.
43+ CacheDir string
44+
45+ // WebProcessMemory configures memory pressure for web processes.
46+ // nil means use WebKit defaults.
47+ WebProcessMemory *MemoryPressureConfig
48+
49+ // NetworkProcessMemory configures memory pressure for the network process.
50+ // nil means use WebKit defaults.
51+ NetworkProcessMemory *MemoryPressureConfig
52+}
53+
54+// IsWebProcessMemoryConfigured returns true if web process memory settings are configured.
55+func (o *WebKitContextOptions) IsWebProcessMemoryConfigured() bool {
56+ if o == nil {
57+ return false
58+ }
59+ return o.WebProcessMemory.IsConfigured()
60+}
61+
62+// IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured.
63+func (o *WebKitContextOptions) IsNetworkProcessMemoryConfigured() bool {
64+ if o == nil {
65+ return false
66+ }
67+ return o.NetworkProcessMemory.IsConfigured()
68+}
69+
70+// MemoryPressureApplier applies memory pressure settings to WebKit processes.
71+// This interface abstracts the WebKit-specific memory pressure configuration
72+// to allow testing without WebKit dependencies.
73+type MemoryPressureApplier interface {
74+ // ApplyNetworkProcessSettings applies memory pressure settings to the network process.
75+ // Must be called BEFORE creating any NetworkSession.
76+ ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error
77+
78+ // ApplyWebProcessSettings applies memory pressure settings to web processes.
79+ // Returns an opaque settings object that should be passed to WebContext creation.
80+ // Returns nil if no settings are configured.
81+ ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error)
82+}
83+
84+// WebKitContextProvider creates and manages WebKit contexts.
85+// This interface abstracts WebKit context creation for testability.
86+type WebKitContextProvider interface {
87+ // Initialize sets up the WebKit context with the given options.
88+ // Must be called before any other methods.
89+ Initialize(ctx context.Context, opts WebKitContextOptions) error
90+
91+ // DataDir returns the data directory path.
92+ DataDir() string
93+
94+ // CacheDir returns the cache directory path.
95+ CacheDir() string
96+
97+ // PrefetchDNS prefetches DNS for the given hostname.
98+ PrefetchDNS(hostname string)
99+
100+ // Close releases resources held by the context.
101+ Close() error
102+}
A internal/application/port/webkit_context_test.go +156 -0
@@ -0,0 +1,156 @@
1+package port_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestMemoryPressureConfig_IsConfigured(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ config *port.MemoryPressureConfig
14+ expected bool
15+ }{
16+ {
17+ name: "nil config",
18+ config: nil,
19+ expected: false,
20+ },
21+ {
22+ name: "empty config",
23+ config: &port.MemoryPressureConfig{},
24+ expected: false,
25+ },
26+ {
27+ name: "only MemoryLimitMB set",
28+ config: &port.MemoryPressureConfig{
29+ MemoryLimitMB: 1024,
30+ },
31+ expected: true,
32+ },
33+ {
34+ name: "only PollIntervalSec set",
35+ config: &port.MemoryPressureConfig{
36+ PollIntervalSec: 30.0,
37+ },
38+ expected: true,
39+ },
40+ {
41+ name: "only ConservativeThreshold set",
42+ config: &port.MemoryPressureConfig{
43+ ConservativeThreshold: 0.33,
44+ },
45+ expected: true,
46+ },
47+ {
48+ name: "only StrictThreshold set",
49+ config: &port.MemoryPressureConfig{
50+ StrictThreshold: 0.5,
51+ },
52+ expected: true,
53+ },
54+ {
55+ name: "all defaults (unset)",
56+ config: &port.MemoryPressureConfig{
57+ MemoryLimitMB: 0,
58+ PollIntervalSec: 0,
59+ ConservativeThreshold: 0,
60+ StrictThreshold: 0,
61+ },
62+ expected: false,
63+ },
64+ {
65+ name: "fully configured",
66+ config: &port.MemoryPressureConfig{
67+ MemoryLimitMB: 2048,
68+ PollIntervalSec: 15.0,
69+ ConservativeThreshold: 0.25,
70+ StrictThreshold: 0.6,
71+ },
72+ expected: true,
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ result := tt.config.IsConfigured()
79+ assert.Equal(t, tt.expected, result)
80+ })
81+ }
82+}
83+
84+func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ opts port.WebKitContextOptions
88+ expected bool
89+ }{
90+ {
91+ name: "nil WebProcessMemory",
92+ opts: port.WebKitContextOptions{},
93+ expected: false,
94+ },
95+ {
96+ name: "empty WebProcessMemory",
97+ opts: port.WebKitContextOptions{
98+ WebProcessMemory: &port.MemoryPressureConfig{},
99+ },
100+ expected: false,
101+ },
102+ {
103+ name: "configured WebProcessMemory",
104+ opts: port.WebKitContextOptions{
105+ WebProcessMemory: &port.MemoryPressureConfig{
106+ MemoryLimitMB: 1024,
107+ },
108+ },
109+ expected: true,
110+ },
111+ }
112+
113+ for _, tt := range tests {
114+ t.Run(tt.name, func(t *testing.T) {
115+ result := tt.opts.IsWebProcessMemoryConfigured()
116+ assert.Equal(t, tt.expected, result)
117+ })
118+ }
119+}
120+
121+func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) {
122+ tests := []struct {
123+ name string
124+ opts port.WebKitContextOptions
125+ expected bool
126+ }{
127+ {
128+ name: "nil NetworkProcessMemory",
129+ opts: port.WebKitContextOptions{},
130+ expected: false,
131+ },
132+ {
133+ name: "empty NetworkProcessMemory",
134+ opts: port.WebKitContextOptions{
135+ NetworkProcessMemory: &port.MemoryPressureConfig{},
136+ },
137+ expected: false,
138+ },
139+ {
140+ name: "configured NetworkProcessMemory",
141+ opts: port.WebKitContextOptions{
142+ NetworkProcessMemory: &port.MemoryPressureConfig{
143+ PollIntervalSec: 20.0,
144+ },
145+ },
146+ expected: true,
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ result := tt.opts.IsNetworkProcessMemoryConfigured()
153+ assert.Equal(t, tt.expected, result)
154+ })
155+ }
156+}
M internal/application/port/webui_config.go +16 -0
@@ -5,11 +5,27 @@ import "context"
55 // WebUIConfig represents the subset of configuration editable in dumb://config.
66 type WebUIConfig struct {
77 Appearance WebUIAppearanceConfig `json:"appearance"`
8+ Performance WebUIPerformanceConfig `json:"performance"`
89 DefaultUIScale float64 `json:"default_ui_scale"`
910 DefaultSearchEngine string `json:"default_search_engine"`
1011 SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
1112 }
1213
14+// WebUIPerformanceConfig represents performance settings editable in dumb://config.
15+type WebUIPerformanceConfig struct {
16+ Profile string `json:"profile"`
17+ Custom WebUICustomPerformanceConfig `json:"custom"`
18+}
19+
20+// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
21+type WebUICustomPerformanceConfig struct {
22+ SkiaCPUThreads int `json:"skia_cpu_threads"`
23+ SkiaGPUThreads int `json:"skia_gpu_threads"`
24+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
25+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
26+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
27+}
28+
1329 type WebUIAppearanceConfig struct {
1430 SansFont string `json:"sans_font"`
1531 SerifFont string `json:"serif_font"`
A internal/application/usecase/insert_accent.go +277 -0
@@ -0,0 +1,277 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// LongPressDelay is the duration a key must be held to trigger the accent picker.
15+const LongPressDelay = 400 * time.Millisecond
16+
17+// InsertAccentUseCase handles long-press detection and accent selection.
18+// When a key with accent variants is held for 400ms, it shows an accent picker
19+// and inserts the selected accent into the focused text input.
20+type InsertAccentUseCase struct {
21+ focusProvider port.FocusedInputProvider
22+ accentPicker port.AccentPickerUI
23+
24+ // Current long-press state
25+ pressedChar rune
26+ shiftHeld bool
27+ timer *time.Timer
28+ pickerVisible bool
29+ repeatCount int // Number of key repeats while timer is pending
30+ targetInput port.TextInputTarget // The input that was focused when long-press started
31+
32+ // Callback to show picker on GTK main thread
33+ showPickerOnMainThread func(fn func())
34+
35+ mu sync.Mutex
36+}
37+
38+// NewInsertAccentUseCase creates a new accent insertion use case.
39+// showOnMainThread is a callback that runs a function on the GTK main thread
40+// (typically using glib.IdleAdd). This is required because the timer fires
41+// on a background goroutine.
42+func NewInsertAccentUseCase(
43+ focusProvider port.FocusedInputProvider,
44+ accentPicker port.AccentPickerUI,
45+ showOnMainThread func(fn func()),
46+) *InsertAccentUseCase {
47+ return &InsertAccentUseCase{
48+ focusProvider: focusProvider,
49+ accentPicker: accentPicker,
50+ showPickerOnMainThread: showOnMainThread,
51+ }
52+}
53+
54+// OnKeyPressed handles a key press event.
55+// Returns true if the use case is handling this key and the caller should suppress it.
56+// Note: Due to WebKit's input handling, key suppression may not be fully effective.
57+// When the picker shows, we delete any repeated characters that slipped through.
58+func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
59+ log := logging.FromContext(ctx)
60+
61+ uc.mu.Lock()
62+ defer uc.mu.Unlock()
63+
64+ // If picker is visible, block all keys (picker handles them)
65+ if uc.pickerVisible {
66+ return true
67+ }
68+
69+ // If the same key is still pressed (key repeat), count it
70+ // We try to block it but also track repeats in case blocking doesn't work
71+ if uc.pressedChar == char && uc.timer != nil {
72+ uc.repeatCount++
73+ log.Debug().
74+ Str("char", string(char)).
75+ Int("repeat_count", uc.repeatCount).
76+ Msg("key repeat detected")
77+ return true // Try to block repeat keys
78+ }
79+
80+ // Cancel any pending timer for a different key
81+ uc.cancelTimerLocked()
82+
83+ // Check if this character has accents
84+ if !entity.HasAccents(char) {
85+ return false
86+ }
87+
88+ // Capture the currently focused input - we'll insert into this target
89+ // even if focus changes when the picker appears
90+ uc.targetInput = uc.focusProvider.GetFocusedInput()
91+ if uc.targetInput == nil {
92+ log.Debug().Msg("no focused input, skipping accent detection")
93+ return false
94+ }
95+
96+ // Start long-press detection
97+ uc.pressedChar = char
98+ uc.shiftHeld = shiftHeld
99+ uc.repeatCount = 0 // Reset repeat counter
100+
101+ log.Debug().
102+ Str("char", string(char)).
103+ Bool("shift", shiftHeld).
104+ Msg("starting long-press detection")
105+
106+ // Use context.Background() since timer fires after delay and original ctx may be canceled
107+ uc.timer = time.AfterFunc(LongPressDelay, func() {
108+ uc.onLongPressTriggered(context.Background())
109+ })
110+
111+ // Allow the first key press through (user might just want to type quickly)
112+ return false
113+}
114+
115+// OnKeyReleased handles a key release event.
116+// This cancels any pending long-press detection.
117+func (uc *InsertAccentUseCase) OnKeyReleased(ctx context.Context, char rune) {
118+ log := logging.FromContext(ctx)
119+
120+ uc.mu.Lock()
121+ defer uc.mu.Unlock()
122+
123+ // Only cancel if it's the same key that was pressed
124+ if uc.pressedChar == char && uc.timer != nil {
125+ log.Debug().
126+ Str("char", string(char)).
127+ Msg("key released before long-press threshold")
128+ uc.cancelTimerLocked()
129+ }
130+}
131+
132+// Cancel cancels any pending long-press detection and hides the picker.
133+func (uc *InsertAccentUseCase) Cancel(ctx context.Context) {
134+ log := logging.FromContext(ctx)
135+
136+ uc.mu.Lock()
137+ defer uc.mu.Unlock()
138+
139+ if uc.timer != nil || uc.pickerVisible {
140+ log.Debug().Msg("canceling accent picker")
141+ }
142+
143+ uc.cancelTimerLocked()
144+ if uc.pickerVisible {
145+ uc.accentPicker.Hide()
146+ uc.pickerVisible = false
147+ }
148+}
149+
150+// IsPickerVisible returns true if the accent picker is currently visible.
151+func (uc *InsertAccentUseCase) IsPickerVisible() bool {
152+ uc.mu.Lock()
153+ defer uc.mu.Unlock()
154+ return uc.pickerVisible
155+}
156+
157+// onLongPressTriggered is called when the long-press timer fires.
158+// This runs on a background goroutine, so we use showPickerOnMainThread.
159+func (uc *InsertAccentUseCase) onLongPressTriggered(ctx context.Context) {
160+ log := logging.FromContext(ctx)
161+
162+ uc.mu.Lock()
163+ char := uc.pressedChar
164+ uppercase := uc.shiftHeld
165+ repeatCount := uc.repeatCount
166+ uc.timer = nil
167+ uc.mu.Unlock()
168+
169+ accents := entity.GetAccents(char, uppercase)
170+ if len(accents) == 0 {
171+ return
172+ }
173+
174+ log.Debug().
175+ Str("char", string(char)).
176+ Bool("uppercase", uppercase).
177+ Int("accents", len(accents)).
178+ Int("repeat_count", repeatCount).
179+ Msg("long-press triggered, showing accent picker")
180+
181+ // Show picker on main thread
182+ uc.showPickerOnMainThread(func() {
183+ uc.showPicker(ctx, accents, repeatCount)
184+ })
185+}
186+
187+// showPicker displays the accent picker UI.
188+// Must be called on the GTK main thread.
189+// repeatCount is the number of repeated characters that may have been typed
190+// (despite our attempt to block them) and need to be deleted.
191+func (uc *InsertAccentUseCase) showPicker(ctx context.Context, accents []rune, repeatCount int) {
192+ log := logging.FromContext(ctx)
193+
194+ uc.mu.Lock()
195+ target := uc.targetInput // Use the captured target, not current focus
196+ uc.mu.Unlock()
197+
198+ // Delete any repeated characters that slipped through
199+ // +1 for the initial character that was typed
200+ charsToDelete := repeatCount + 1
201+ if charsToDelete > 0 && target != nil {
202+ log.Debug().
203+ Int("chars_to_delete", charsToDelete).
204+ Msg("deleting repeated characters before showing picker")
205+ if err := target.DeleteBeforeCursor(ctx, charsToDelete); err != nil {
206+ log.Warn().Err(err).Msg("failed to delete repeated characters")
207+ }
208+ }
209+
210+ uc.mu.Lock()
211+ uc.pickerVisible = true
212+ uc.mu.Unlock()
213+
214+ uc.accentPicker.Show(accents, func(accent rune) {
215+ uc.onAccentSelected(ctx, accent)
216+ }, func() {
217+ uc.onPickerCanceled(ctx)
218+ })
219+
220+ log.Debug().Msg("accent picker shown")
221+}
222+
223+// onAccentSelected is called when the user selects an accent.
224+func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune) {
225+ log := logging.FromContext(ctx)
226+
227+ uc.mu.Lock()
228+ target := uc.targetInput // Use the captured target, not current focus
229+ uc.pickerVisible = false
230+ uc.pressedChar = 0
231+ uc.repeatCount = 0
232+ uc.targetInput = nil
233+ uc.mu.Unlock()
234+
235+ uc.accentPicker.Hide()
236+
237+ if target == nil {
238+ log.Warn().Msg("no target input for accent insertion")
239+ return
240+ }
241+
242+ log.Debug().
243+ Str("accent", string(accent)).
244+ Msg("inserting accent")
245+
246+ if err := target.InsertText(ctx, string(accent)); err != nil {
247+ log.Error().Err(err).Msg("failed to insert accent")
248+ }
249+}
250+
251+// onPickerCanceled is called when the user cancels the picker (Escape).
252+func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
253+ log := logging.FromContext(ctx)
254+
255+ uc.mu.Lock()
256+ uc.pickerVisible = false
257+ uc.pressedChar = 0
258+ uc.repeatCount = 0
259+ uc.targetInput = nil
260+ uc.mu.Unlock()
261+
262+ uc.accentPicker.Hide()
263+
264+ log.Debug().Msg("accent picker canceled")
265+}
266+
267+// cancelTimerLocked cancels the long-press timer.
268+// Must be called with uc.mu held.
269+func (uc *InsertAccentUseCase) cancelTimerLocked() {
270+ if uc.timer != nil {
271+ uc.timer.Stop()
272+ uc.timer = nil
273+ }
274+ uc.pressedChar = 0
275+ uc.repeatCount = 0
276+ uc.targetInput = nil
277+}
A internal/application/usecase/insert_accent_test.go +219 -0
@@ -0,0 +1,219 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
15+ ctx := context.Background()
16+ focusProvider := mocks.NewMockFocusedInputProvider(t)
17+ accentPicker := mocks.NewMockAccentPickerUI(t)
18+
19+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
20+
21+ // Key 'x' has no accents - should return false and not start timer
22+ handled := uc.OnKeyPressed(ctx, 'x', false)
23+ assert.False(t, handled, "OnKeyPressed should return false for key without accents")
24+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible")
25+}
26+
27+func TestInsertAccentUseCase_OnKeyPressed_HasAccents(t *testing.T) {
28+ ctx := context.Background()
29+ focusProvider := mocks.NewMockFocusedInputProvider(t)
30+ accentPicker := mocks.NewMockAccentPickerUI(t)
31+ textInput := mocks.NewMockTextInputTarget(t)
32+
33+ // GetFocusedInput is called when starting long-press to capture target
34+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
35+
36+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
37+
38+ // Key 'e' has accents - should return false initially (timer started)
39+ handled := uc.OnKeyPressed(ctx, 'e', false)
40+ assert.False(t, handled, "OnKeyPressed should return false while timer is pending")
41+
42+ // Cancel to clean up timer
43+ uc.Cancel(ctx)
44+}
45+
46+func TestInsertAccentUseCase_OnKeyReleased_CancelsTimer(t *testing.T) {
47+ ctx := context.Background()
48+ focusProvider := mocks.NewMockFocusedInputProvider(t)
49+ accentPicker := mocks.NewMockAccentPickerUI(t)
50+ textInput := mocks.NewMockTextInputTarget(t)
51+
52+ // GetFocusedInput is called when starting long-press to capture target
53+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
54+
55+ timerFired := false
56+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) {
57+ timerFired = true
58+ fn()
59+ })
60+
61+ // Start long-press detection
62+ uc.OnKeyPressed(ctx, 'e', false)
63+
64+ // Release before timer fires
65+ uc.OnKeyReleased(ctx, 'e')
66+
67+ // Wait a bit longer than LongPressDelay
68+ time.Sleep(LongPressDelay + 50*time.Millisecond)
69+
70+ assert.False(t, timerFired, "timer should have been canceled by key release")
71+}
72+
73+func TestInsertAccentUseCase_LongPress_ShowsPicker(t *testing.T) {
74+ ctx := context.Background()
75+ focusProvider := mocks.NewMockFocusedInputProvider(t)
76+ accentPicker := mocks.NewMockAccentPickerUI(t)
77+ textInput := mocks.NewMockTextInputTarget(t)
78+
79+ var wg sync.WaitGroup
80+ wg.Add(1)
81+
82+ // GetFocusedInput is called when showing picker to delete repeated characters
83+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
84+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
85+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
86+
87+ // Expect Show to be called with accents for 'e'
88+ accentPicker.EXPECT().
89+ Show(mock.MatchedBy(func(accents []rune) bool {
90+ return len(accents) > 0 && accents[0] == 'è'
91+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
92+ Run(func(_ []rune, _ func(rune), _ func()) {
93+ wg.Done()
94+ }).
95+ Once()
96+
97+ // Hide will be called by Cancel
98+ accentPicker.EXPECT().Hide().Maybe()
99+
100+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
101+
102+ // Start long-press detection
103+ uc.OnKeyPressed(ctx, 'e', false)
104+
105+ // Wait for timer to fire
106+ wg.Wait()
107+ time.Sleep(50 * time.Millisecond) // Give time for state update
108+
109+ assert.True(t, uc.IsPickerVisible(), "picker should be visible after long-press")
110+
111+ // Clean up
112+ uc.Cancel(ctx)
113+}
114+
115+func TestInsertAccentUseCase_Cancel(t *testing.T) {
116+ ctx := context.Background()
117+ focusProvider := mocks.NewMockFocusedInputProvider(t)
118+ accentPicker := mocks.NewMockAccentPickerUI(t)
119+ textInput := mocks.NewMockTextInputTarget(t)
120+
121+ // GetFocusedInput is called when starting long-press to capture target
122+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
123+
124+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
125+
126+ // Start timer
127+ uc.OnKeyPressed(ctx, 'e', false)
128+
129+ // Cancel should stop timer
130+ uc.Cancel(ctx)
131+
132+ // Wait past the timer duration
133+ time.Sleep(LongPressDelay + 50*time.Millisecond)
134+
135+ // Picker should never have been shown
136+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible after cancel")
137+}
138+
139+func TestInsertAccentUseCase_UppercaseAccents(t *testing.T) {
140+ ctx := context.Background()
141+ focusProvider := mocks.NewMockFocusedInputProvider(t)
142+ accentPicker := mocks.NewMockAccentPickerUI(t)
143+ textInput := mocks.NewMockTextInputTarget(t)
144+
145+ var wg sync.WaitGroup
146+ wg.Add(1)
147+
148+ // GetFocusedInput is called when showing picker to delete repeated characters
149+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
150+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
151+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
152+
153+ // Expect Show to be called with uppercase accents when shift is held
154+ accentPicker.EXPECT().
155+ Show(mock.MatchedBy(func(accents []rune) bool {
156+ return len(accents) > 0 && accents[0] == 'È' // Uppercase È
157+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
158+ Run(func(_ []rune, _ func(rune), _ func()) {
159+ wg.Done()
160+ }).
161+ Once()
162+
163+ // Hide will be called by Cancel
164+ accentPicker.EXPECT().Hide().Maybe()
165+
166+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
167+
168+ // Start long-press with shift held
169+ uc.OnKeyPressed(ctx, 'e', true)
170+
171+ // Wait for timer
172+ wg.Wait()
173+
174+ // Clean up
175+ uc.Cancel(ctx)
176+}
177+
178+func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
179+ ctx := context.Background()
180+ focusProvider := mocks.NewMockFocusedInputProvider(t)
181+ accentPicker := mocks.NewMockAccentPickerUI(t)
182+ textInput := mocks.NewMockTextInputTarget(t)
183+
184+ var showCalled int
185+ var mu sync.Mutex
186+
187+ // GetFocusedInput is called when showing picker to delete repeated characters
188+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
189+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
190+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
191+
192+ accentPicker.EXPECT().
193+ Show(mock.Anything, mock.Anything, mock.Anything).
194+ Run(func(_ []rune, _ func(rune), _ func()) {
195+ mu.Lock()
196+ showCalled++
197+ mu.Unlock()
198+ }).
199+ Maybe()
200+
201+ accentPicker.EXPECT().Hide().Maybe()
202+
203+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
204+
205+ // Trigger first long-press
206+ uc.OnKeyPressed(ctx, 'e', false)
207+ time.Sleep(LongPressDelay + 50*time.Millisecond)
208+
209+ // Try to start another long-press while picker is visible
210+ handled := uc.OnKeyPressed(ctx, 'a', false)
211+ assert.True(t, handled, "should return true when picker is visible")
212+
213+ mu.Lock()
214+ count := showCalled
215+ mu.Unlock()
216+ assert.LessOrEqual(t, count, 1, "Show should only be called once")
217+
218+ uc.Cancel(ctx)
219+}
M internal/application/usecase/search_history.go +50 -0
@@ -75,6 +75,56 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
7575 return entries, nil
7676 }
7777
78+// GetRecentSince retrieves history entries visited within the last N days.
79+// If days is 0, returns all history entries.
80+// If days is negative, defaults to 30 days.
81+func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
82+ if days < 0 {
83+ days = 30 // Default to 30 days for negative values
84+ }
85+
86+ var entries []*entity.HistoryEntry
87+ var err error
88+
89+ switch days {
90+ case 0:
91+ entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
92+ default:
93+ entries, err = uc.historyRepo.GetRecentSince(ctx, days)
94+ }
95+
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get recent history: %w", err)
98+ }
99+
100+ return entries, nil
101+}
102+
103+// GetMostVisited retrieves history entries sorted by visit count within the last N days.
104+// If days is 0, returns all history entries sorted by visit count.
105+// If days is negative, defaults to 30 days.
106+func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
107+ if days < 0 {
108+ days = 30 // Default to 30 days for negative values
109+ }
110+
111+ var entries []*entity.HistoryEntry
112+ var err error
113+
114+ switch days {
115+ case 0:
116+ entries, err = uc.historyRepo.GetAllMostVisited(ctx)
117+ default:
118+ entries, err = uc.historyRepo.GetMostVisited(ctx, days)
119+ }
120+
121+ if err != nil {
122+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
123+ }
124+
125+ return entries, nil
126+}
127+
78128 // FindByURL retrieves a history entry by its URL.
79129 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80130 log := logging.FromContext(ctx)
A internal/application/usecase/search_history_test.go +153 -0
@@ -0,0 +1,153 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17+ ctx := testContext()
18+
19+ historyRepo := repomocks.NewMockHistoryRepository(t)
20+
21+ entries := []*entity.HistoryEntry{
22+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
23+ {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
24+ }
25+
26+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
27+
28+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
29+
30+ result, err := uc.GetRecentSince(ctx, 30)
31+ require.NoError(t, err)
32+ require.Len(t, result, 2)
33+ assert.Equal(t, "https://example.com", result[0].URL)
34+ assert.Equal(t, "https://github.com", result[1].URL)
35+}
36+
37+func TestSearchHistoryUseCase_GetRecentSince_ZeroMeansAll(t *testing.T) {
38+ ctx := testContext()
39+
40+ historyRepo := repomocks.NewMockHistoryRepository(t)
41+
42+ entries := []*entity.HistoryEntry{
43+ {ID: 1, URL: "https://example.com", Title: "Example"},
44+ }
45+
46+ // When days == 0, should call GetAllRecentHistory
47+ historyRepo.EXPECT().GetAllRecentHistory(mock.Anything).Return(entries, nil)
48+
49+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
50+
51+ result, err := uc.GetRecentSince(ctx, 0)
52+ require.NoError(t, err)
53+ require.Len(t, result, 1)
54+}
55+
56+func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
57+ ctx := testContext()
58+
59+ historyRepo := repomocks.NewMockHistoryRepository(t)
60+
61+ entries := []*entity.HistoryEntry{}
62+
63+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
64+
65+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
66+
67+ result, err := uc.GetRecentSince(ctx, -5)
68+ require.NoError(t, err)
69+ require.Empty(t, result)
70+}
71+
72+func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
73+ ctx := testContext()
74+
75+ historyRepo := repomocks.NewMockHistoryRepository(t)
76+
77+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
78+
79+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
80+
81+ result, err := uc.GetRecentSince(ctx, 7)
82+ require.Error(t, err)
83+ assert.Nil(t, result)
84+ assert.Contains(t, err.Error(), "failed to get recent history")
85+}
86+
87+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
88+ ctx := testContext()
89+
90+ historyRepo := repomocks.NewMockHistoryRepository(t)
91+
92+ entries := []*entity.HistoryEntry{
93+ {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
94+ {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
95+ }
96+
97+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
98+
99+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
100+
101+ result, err := uc.GetMostVisited(ctx, 30)
102+ require.NoError(t, err)
103+ require.Len(t, result, 2)
104+ assert.Equal(t, "https://github.com", result[0].URL)
105+ assert.Equal(t, int64(100), result[0].VisitCount)
106+}
107+
108+func TestSearchHistoryUseCase_GetMostVisited_ZeroMeansAll(t *testing.T) {
109+ ctx := testContext()
110+
111+ historyRepo := repomocks.NewMockHistoryRepository(t)
112+
113+ entries := []*entity.HistoryEntry{}
114+
115+ // When days == 0, should call GetAllMostVisited
116+ historyRepo.EXPECT().GetAllMostVisited(mock.Anything).Return(entries, nil)
117+
118+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
119+
120+ result, err := uc.GetMostVisited(ctx, 0)
121+ require.NoError(t, err)
122+ require.Empty(t, result)
123+}
124+
125+func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
126+ ctx := testContext()
127+
128+ historyRepo := repomocks.NewMockHistoryRepository(t)
129+
130+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
131+
132+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
133+
134+ result, err := uc.GetMostVisited(ctx, 14)
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "failed to get most visited history")
138+}
139+
140+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
141+ ctx := testContext()
142+
143+ historyRepo := repomocks.NewMockHistoryRepository(t)
144+
145+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
146+
147+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
148+
149+ result, err := uc.GetMostVisited(ctx, 30)
150+ require.NoError(t, err)
151+ require.NotNil(t, result)
152+ assert.Empty(t, result)
153+}
M internal/bootstrap/gui.go +46 -17
@@ -10,7 +10,9 @@ import (
1010 "sync"
1111 "time"
1212
13+ "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1416 "github.com/bnema/dumber/internal/infrastructure/config"
1517 "github.com/bnema/dumber/internal/infrastructure/deps"
1618 "github.com/bnema/dumber/internal/infrastructure/media"
@@ -28,10 +30,12 @@ type DatabaseResult struct {
2830
2931 // ParallelInitResult holds the results of parallel initialization phase.
3032 type ParallelInitResult struct {
31- DataDir string
32- CacheDir string
33- ThemeManager *theme.Manager
34- Duration time.Duration
33+ DataDir string
34+ CacheDir string
35+ ThemeManager *theme.Manager
36+ ColorResolver port.ColorSchemeResolver
37+ AdwaitaDetector *colorscheme.AdwaitaDetector
38+ Duration time.Duration
3539 }
3640
3741 // DeferredInitResult holds results from deferred initialization checks.
@@ -84,17 +88,30 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
8488 }
8589
8690 // RunParallelInit runs the essential parallel initialization phase.
87-// This includes directory resolution and theme creation.
91+// This includes directory resolution, color scheme resolver creation, and theme creation.
8892 // Returns the first fatal error encountered, or nil with the results.
8993 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
9094 var (
9195 dataDir, cacheDir string
92- themeManager *theme.Manager
9396 dirsErr error
9497 wg sync.WaitGroup
9598 )
9699
97100 start := time.Now()
101+
102+ // Create color scheme resolver with initial detectors.
103+ // AdwaitaDetector is created but NOT marked available yet (requires adw.Init()).
104+ configAdapter := colorscheme.NewConfigAdapter(input.Config)
105+ resolver := colorscheme.NewResolver(configAdapter)
106+
107+ // Register detectors that are available before GTK init
108+ resolver.RegisterDetector(colorscheme.NewEnvDetector())
109+ resolver.RegisterDetector(colorscheme.NewGsettingsDetector())
110+
111+ // Create adwaita detector (will be marked available after adw.Init())
112+ adwaitaDetector := colorscheme.NewAdwaitaDetector()
113+ resolver.RegisterDetector(adwaitaDetector)
114+
98115 wg.Add(2)
99116
100117 // Resolve directories
@@ -104,9 +121,10 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
104121 }()
105122
106123 // Theme manager (CPU-bound, no I/O)
124+ var themeManager *theme.Manager
107125 go func() {
108126 defer wg.Done()
109- themeManager = theme.NewManager(input.Ctx, input.Config)
127+ themeManager = theme.NewManager(input.Ctx, input.Config, resolver)
110128 }()
111129
112130 wg.Wait()
@@ -118,10 +136,12 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
118136 }
119137
120138 return &ParallelInitResult{
121- DataDir: dataDir,
122- CacheDir: cacheDir,
123- ThemeManager: themeManager,
124- Duration: duration,
139+ DataDir: dataDir,
140+ CacheDir: cacheDir,
141+ ThemeManager: themeManager,
142+ ColorResolver: resolver,
143+ AdwaitaDetector: adwaitaDetector,
144+ Duration: duration,
125145 }, nil
126146 }
127147
@@ -253,11 +273,12 @@ type ParallelDBWebKitResult struct {
253273
254274 // ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
255275 type ParallelDBWebKitInput struct {
256- Ctx context.Context
257- Config *config.Config
258- DataDir string // For WebKit context
259- CacheDir string // For WebKit cache
260- ThemeManager *theme.Manager
276+ Ctx context.Context
277+ Config *config.Config
278+ DataDir string // For WebKit context
279+ CacheDir string // For WebKit cache
280+ ThemeManager *theme.Manager
281+ ColorResolver port.ColorSchemeResolver
261282 }
262283
263284 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -291,7 +312,15 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult,
291312 }()
292313
293314 // WebKit stack on main thread (GTK requirement)
294- stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
315+ stack := BuildWebKitStack(WebKitStackInput{
316+ Ctx: input.Ctx,
317+ Config: input.Config,
318+ DataDir: input.DataDir,
319+ CacheDir: input.CacheDir,
320+ ThemeManager: input.ThemeManager,
321+ ColorResolver: input.ColorResolver,
322+ Logger: *log,
323+ })
295324
296325 // Wait for database
297326 dbRes := <-dbCh
M internal/bootstrap/webkit_stack.go +132 -47
@@ -15,6 +15,17 @@ import (
1515 "github.com/rs/zerolog"
1616 )
1717
18+// WebKitStackInput holds the input for BuildWebKitStack.
19+type WebKitStackInput struct {
20+ Ctx context.Context
21+ Config *config.Config
22+ DataDir string
23+ CacheDir string
24+ ThemeManager *theme.Manager
25+ ColorResolver port.ColorSchemeResolver
26+ Logger zerolog.Logger
27+}
28+
1829 type WebKitStack struct {
1930 Context *webkit.WebKitContext
2031 Settings *webkit.SettingsManager
@@ -24,50 +35,64 @@ type WebKitStack struct {
2435 FilterManager *filtering.Manager
2536 }
2637
27-func BuildWebKitStack(
28- ctx context.Context,
29- cfg *config.Config,
30- dataDir string,
31- cacheDir string,
32- themeManager *theme.Manager,
33- logger zerolog.Logger,
34-) WebKitStack {
35- // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization.
36- // Environment variables must be set before GTK/WebKit/GStreamer initializes.
37- renderEnv := env.NewManager()
38- gpuVendor := renderEnv.DetectGPUVendor(ctx)
39- renderSettings := port.RenderingEnvSettings{
40- // GStreamer settings
41- ForceVSync: cfg.Media.ForceVSync,
42- GLRenderingMode: string(cfg.Media.GLRenderingMode),
43- GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
44- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
45- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
46-
47- // WebKit compositor settings
48- DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
49- ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
50- DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
51-
52- // GTK/GSK settings
53- GSKRenderer: string(cfg.Rendering.GSKRenderer),
54- DisableMipmaps: cfg.Rendering.DisableMipmaps,
55- PreferGL: cfg.Rendering.PreferGL,
38+func BuildWebKitStack(input WebKitStackInput) WebKitStack {
39+ ctx := input.Ctx
40+ cfg := input.Config
41+ dataDir := input.DataDir
42+ cacheDir := input.CacheDir
43+ themeManager := input.ThemeManager
44+ colorResolver := input.ColorResolver
45+ logger := input.Logger
46+ // Detect hardware for performance profile scaling
47+ hwSurveyor := env.NewHardwareSurveyor()
48+ hwInfo := hwSurveyor.Survey(ctx)
49+ logger.Info().
50+ Int("cpu_cores", hwInfo.CPUCores).
51+ Int("cpu_threads", hwInfo.CPUThreads).
52+ Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)).
53+ Str("gpu_vendor", string(hwInfo.GPUVendor)).
54+ Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
55+ Msg("hardware survey completed")
56+
57+ // Resolve performance profile to get actual settings
58+ perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
59+ logger.Info().
60+ Str("profile", string(cfg.Performance.Profile)).
61+ Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
62+ Int("skia_gpu_threads", perfSettings.SkiaGPUPaintingThreads).
63+ Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
64+ Msg("resolved performance profile")
65+
66+ // Configure rendering environment (must happen before WebKit/GTK init)
67+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
68+
69+ // Build WebKitContext options with memory pressure settings
70+ wkOpts := port.WebKitContextOptions{
71+ DataDir: dataDir,
72+ CacheDir: cacheDir,
73+ }
5674
57- // Debug settings
58- ShowFPS: cfg.Rendering.ShowFPS,
59- SampleMemory: cfg.Rendering.SampleMemory,
60- DebugFrames: cfg.Rendering.DebugFrames,
75+ // Configure web process memory pressure if any setting is configured
76+ if hasWebProcessMemoryConfig(&perfSettings) {
77+ wkOpts.WebProcessMemory = &port.MemoryPressureConfig{
78+ MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB,
79+ PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec,
80+ ConservativeThreshold: perfSettings.WebProcessMemoryConservativeThreshold,
81+ StrictThreshold: perfSettings.WebProcessMemoryStrictThreshold,
82+ }
6183 }
62- if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
63- logger.Warn().Err(err).Msg("failed to apply rendering environment")
84+
85+ // Configure network process memory pressure if any setting is configured
86+ if hasNetworkProcessMemoryConfig(&perfSettings) {
87+ wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{
88+ MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB,
89+ PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec,
90+ ConservativeThreshold: perfSettings.NetworkProcessMemoryConservativeThreshold,
91+ StrictThreshold: perfSettings.NetworkProcessMemoryStrictThreshold,
92+ }
6493 }
65- logger.Info().
66- Str("gpu", string(gpuVendor)).
67- Interface("vars", renderEnv.GetAppliedVars()).
68- Msg("rendering environment configured")
6994
70- wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir)
95+ wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
7196 if err != nil {
7297 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
7398 }
@@ -93,10 +118,7 @@ func BuildWebKitStack(
93118 schemeHandler.RegisterWithContext(wkCtx)
94119
95120 settings := webkit.NewSettingsManager(ctx, cfg)
96- injector := webkit.NewContentInjector(themeManager.PrefersDark())
97-
98- // Set background color on injector for early CSS injection (prevents white flash)
99- injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background)
121+ injector := webkit.NewContentInjector(colorResolver)
100122
101123 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
102124 themeCSSText := themeManager.GetWebUIThemeCSS()
@@ -106,9 +128,9 @@ func BuildWebKitStack(
106128
107129 messageRouter := webkit.NewMessageRouter(ctx)
108130 poolCfg := webkit.DefaultPoolConfig()
109- // Override prewarm count from config if set
110- if cfg.Performance.WebViewPoolPrewarmCount > 0 {
111- poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
131+ // Override prewarm count from resolved profile settings
132+ if perfSettings.WebViewPoolPrewarmCount > 0 {
133+ poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
112134 }
113135 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
114136 // Ensure prewarmed WebViews pick up the theme background color.
@@ -135,3 +157,66 @@ func BuildWebKitStack(
135157 FilterManager: filterManager,
136158 }
137159 }
160+
161+// hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
162+func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
163+ return p.WebProcessMemoryLimitMB > 0 ||
164+ p.WebProcessMemoryPollIntervalSec > 0 ||
165+ p.WebProcessMemoryConservativeThreshold > 0 ||
166+ p.WebProcessMemoryStrictThreshold > 0
167+}
168+
169+// hasNetworkProcessMemoryConfig returns true if any network process memory setting is configured.
170+func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
171+ return p.NetworkProcessMemoryLimitMB > 0 ||
172+ p.NetworkProcessMemoryPollIntervalSec > 0 ||
173+ p.NetworkProcessMemoryConservativeThreshold > 0 ||
174+ p.NetworkProcessMemoryStrictThreshold > 0
175+}
176+
177+// configureRenderingEnvironment sets up the rendering environment before WebKit/GTK initialization.
178+// Environment variables must be set before GTK/WebKit/GStreamer initializes.
179+func configureRenderingEnvironment(
180+ ctx context.Context,
181+ cfg *config.Config,
182+ perfSettings *config.ResolvedPerformanceSettings,
183+ logger zerolog.Logger,
184+) {
185+ renderEnv := env.NewManager()
186+ gpuVendor := renderEnv.DetectGPUVendor(ctx)
187+ renderSettings := port.RenderingEnvSettings{
188+ // GStreamer settings
189+ ForceVSync: cfg.Media.ForceVSync,
190+ GLRenderingMode: string(cfg.Media.GLRenderingMode),
191+ GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
192+ VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
193+ QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
194+
195+ // WebKit compositor settings
196+ DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
197+ ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
198+ DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
199+
200+ // GTK/GSK settings
201+ GSKRenderer: string(cfg.Rendering.GSKRenderer),
202+ DisableMipmaps: cfg.Rendering.DisableMipmaps,
203+ PreferGL: cfg.Rendering.PreferGL,
204+
205+ // Debug settings
206+ ShowFPS: cfg.Rendering.ShowFPS,
207+ SampleMemory: cfg.Rendering.SampleMemory,
208+ DebugFrames: cfg.Rendering.DebugFrames,
209+
210+ // Skia rendering thread settings (from resolved profile)
211+ SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
212+ SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
213+ SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
214+ }
215+ if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
216+ logger.Warn().Err(err).Msg("failed to apply rendering environment")
217+ }
218+ logger.Info().
219+ Str("gpu", string(gpuVendor)).
220+ Interface("vars", renderEnv.GetAppliedVars()).
221+ Msg("rendering environment configured")
222+}
M internal/cli/cmd/dmenu.go +21 -6
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/dumber/assets"
1515 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
1617 domainurl "github.com/bnema/dumber/internal/domain/url"
1718 "github.com/bnema/dumber/internal/infrastructure/config"
1819 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -22,7 +23,8 @@ import (
2223 var (
2324 dmenuInteractive bool
2425 dmenuSelect bool
25- dmenuMax int
26+ dmenuDays int
27+ dmenuMostVisited bool
2628 )
2729
2830 var dmenuCmd = &cobra.Command{
@@ -43,7 +45,8 @@ func init() {
4345
4446 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
4547 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
46- dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")
48+ dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)")
49+ dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency")
4750 }
4851
4952 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
7174 app := GetApp()
7275 cfg := app.Config.Dmenu
7376
74- // Override max if explicitly set via CLI flag
75- if dmenuMax > 0 {
76- cfg.MaxHistoryItems = dmenuMax
77+ // Override days if explicitly set via CLI flag
78+ if dmenuDays > 0 {
79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
7785 }
7886
7987 return cfg
@@ -131,7 +139,14 @@ func runDmenuPipe() error {
131139 ctx := context.Background()
132140 cfg := getDmenuConfig()
133141
134- entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)
142+ var entries []*entity.HistoryEntry
143+ var err error
144+
145+ if cfg.SortByVisitCount {
146+ entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays)
147+ } else {
148+ entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays)
149+ }
135150 if err != nil {
136151 return fmt.Errorf("get history: %w", err)
137152 }
A internal/domain/entity/accent.go +46 -0
@@ -0,0 +1,46 @@
1+package entity
2+
3+import "unicode"
4+
5+// AccentMap maps base characters to their accented variants.
6+// The order determines display order in the accent picker.
7+var AccentMap = map[rune][]rune{
8+ 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'},
9+ 'c': {'ç', 'ć', 'č'},
10+ 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'},
11+ 'i': {'ì', 'í', 'î', 'ï', 'į'},
12+ 'n': {'ñ', 'ń'},
13+ 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'},
14+ 's': {'ß', 'ś', 'š'},
15+ 'u': {'ù', 'ú', 'û', 'ü', 'ū'},
16+ 'y': {'ÿ', 'ý'},
17+}
18+
19+// HasAccents returns true if the given character has accent variants.
20+func HasAccents(char rune) bool {
21+ lower := unicode.ToLower(char)
22+ _, ok := AccentMap[lower]
23+ return ok
24+}
25+
26+// GetAccents returns the accent variants for a character.
27+// If uppercase is true, returns uppercase variants.
28+// Returns nil if the character has no accent variants.
29+func GetAccents(char rune, uppercase bool) []rune {
30+ lower := unicode.ToLower(char)
31+ accents, ok := AccentMap[lower]
32+ if !ok {
33+ return nil
34+ }
35+
36+ if !uppercase {
37+ return accents
38+ }
39+
40+ // Return uppercase versions
41+ result := make([]rune, len(accents))
42+ for i, a := range accents {
43+ result[i] = unicode.ToUpper(a)
44+ }
45+ return result
46+}
A internal/domain/entity/accent_test.go +108 -0
@@ -0,0 +1,108 @@
1+package entity
2+
3+import (
4+ "testing"
5+)
6+
7+func TestHasAccents(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ char rune
11+ want bool
12+ }{
13+ {"lowercase e", 'e', true},
14+ {"uppercase E", 'E', true},
15+ {"lowercase a", 'a', true},
16+ {"uppercase A", 'A', true},
17+ {"lowercase c", 'c', true},
18+ {"lowercase n", 'n', true},
19+ {"lowercase x (no accents)", 'x', false},
20+ {"uppercase X (no accents)", 'X', false},
21+ {"digit 1 (no accents)", '1', false},
22+ {"space (no accents)", ' ', false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := HasAccents(tt.char)
28+ if got != tt.want {
29+ t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestGetAccents(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ char rune
39+ uppercase bool
40+ wantLen int
41+ wantFirst rune
42+ }{
43+ {"lowercase e", 'e', false, 6, 'è'},
44+ {"uppercase E", 'E', true, 6, 'È'},
45+ {"lowercase a", 'a', false, 7, 'à'},
46+ {"uppercase A", 'A', true, 7, 'À'},
47+ {"lowercase c", 'c', false, 3, 'ç'},
48+ {"uppercase C with shift", 'c', true, 3, 'Ç'},
49+ {"no accents x", 'x', false, 0, 0},
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ got := GetAccents(tt.char, tt.uppercase)
55+
56+ if len(got) != tt.wantLen {
57+ t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen)
58+ }
59+
60+ if tt.wantLen > 0 && got[0] != tt.wantFirst {
61+ t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst)
62+ }
63+ })
64+ }
65+}
66+
67+func TestGetAccents_UppercaseFromLowercase(t *testing.T) {
68+ // When passing lowercase char with uppercase=true, should return uppercase accents
69+ uppercase := GetAccents('e', true)
70+ lowercase := GetAccents('e', false)
71+
72+ if len(uppercase) == 0 {
73+ t.Fatal("expected accents for 'e' with uppercase=true")
74+ }
75+
76+ if len(uppercase) != len(lowercase) {
77+ t.Fatalf("uppercase and lowercase accent counts differ: %d vs %d", len(uppercase), len(lowercase))
78+ }
79+
80+ // Verify each uppercase accent is different from the corresponding lowercase accent
81+ for i, upper := range uppercase {
82+ lower := lowercase[i]
83+ if upper == lower {
84+ t.Errorf("accent at index %d is same for uppercase and lowercase: %q", i, upper)
85+ }
86+ }
87+
88+ // Verify first accent is È (uppercase of è)
89+ if uppercase[0] != 'È' {
90+ t.Errorf("expected first uppercase accent to be È, got %q", uppercase[0])
91+ }
92+}
93+
94+func TestAccentMapCoverage(t *testing.T) {
95+ // Verify all expected characters have accents
96+ expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'}
97+
98+ for _, char := range expectedChars {
99+ if !HasAccents(char) {
100+ t.Errorf("expected %q to have accents", char)
101+ }
102+
103+ accents := GetAccents(char, false)
104+ if len(accents) == 0 {
105+ t.Errorf("expected %q to have at least one accent variant", char)
106+ }
107+ }
108+}
M internal/domain/repository/history.go +14 -0
@@ -21,6 +21,20 @@ type HistoryRepository interface {
2121 // GetRecent retrieves recent history entries with pagination.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
24+ // GetRecentSince retrieves history entries visited within the last N days.
25+ // days must be > 0.
26+ GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
27+
28+ // GetMostVisited retrieves history entries sorted by visit count within the last N days.
29+ // days must be > 0.
30+ GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
31+
32+ // GetAllRecentHistory retrieves all history entries sorted by recency.
33+ GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)
34+
35+ // GetAllMostVisited retrieves all history entries sorted by visit count.
36+ GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error)
37+
2438 // IncrementVisitCount increments the visit count for a URL.
2539 IncrementVisitCount(ctx context.Context, url string) error
2640
M internal/domain/repository/mocks/mocks.go +260 -0
@@ -2197,6 +2197,130 @@ func (_c *MockHistoryRepository_FindByURL_Call) RunAndReturn(run func(ctx contex
21972197 return _c
21982198 }
21992199
2200+// GetAllMostVisited provides a mock function for the type MockHistoryRepository
2201+func (_mock *MockHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
2202+ ret := _mock.Called(ctx)
2203+
2204+ if len(ret) == 0 {
2205+ panic("no return value specified for GetAllMostVisited")
2206+ }
2207+
2208+ var r0 []*entity.HistoryEntry
2209+ var r1 error
2210+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2211+ return returnFunc(ctx)
2212+ }
2213+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2214+ r0 = returnFunc(ctx)
2215+ } else {
2216+ if ret.Get(0) != nil {
2217+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2218+ }
2219+ }
2220+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2221+ r1 = returnFunc(ctx)
2222+ } else {
2223+ r1 = ret.Error(1)
2224+ }
2225+ return r0, r1
2226+}
2227+
2228+// MockHistoryRepository_GetAllMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllMostVisited'
2229+type MockHistoryRepository_GetAllMostVisited_Call struct {
2230+ *mock.Call
2231+}
2232+
2233+// GetAllMostVisited is a helper method to define mock.On call
2234+// - ctx context.Context
2235+func (_e *MockHistoryRepository_Expecter) GetAllMostVisited(ctx interface{}) *MockHistoryRepository_GetAllMostVisited_Call {
2236+ return &MockHistoryRepository_GetAllMostVisited_Call{Call: _e.mock.On("GetAllMostVisited", ctx)}
2237+}
2238+
2239+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllMostVisited_Call {
2240+ _c.Call.Run(func(args mock.Arguments) {
2241+ var arg0 context.Context
2242+ if args[0] != nil {
2243+ arg0 = args[0].(context.Context)
2244+ }
2245+ run(
2246+ arg0,
2247+ )
2248+ })
2249+ return _c
2250+}
2251+
2252+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllMostVisited_Call {
2253+ _c.Call.Return(historyEntrys, err)
2254+ return _c
2255+}
2256+
2257+func (_c *MockHistoryRepository_GetAllMostVisited_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllMostVisited_Call {
2258+ _c.Call.Return(run)
2259+ return _c
2260+}
2261+
2262+// GetAllRecentHistory provides a mock function for the type MockHistoryRepository
2263+func (_mock *MockHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
2264+ ret := _mock.Called(ctx)
2265+
2266+ if len(ret) == 0 {
2267+ panic("no return value specified for GetAllRecentHistory")
2268+ }
2269+
2270+ var r0 []*entity.HistoryEntry
2271+ var r1 error
2272+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2273+ return returnFunc(ctx)
2274+ }
2275+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2276+ r0 = returnFunc(ctx)
2277+ } else {
2278+ if ret.Get(0) != nil {
2279+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2280+ }
2281+ }
2282+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2283+ r1 = returnFunc(ctx)
2284+ } else {
2285+ r1 = ret.Error(1)
2286+ }
2287+ return r0, r1
2288+}
2289+
2290+// MockHistoryRepository_GetAllRecentHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllRecentHistory'
2291+type MockHistoryRepository_GetAllRecentHistory_Call struct {
2292+ *mock.Call
2293+}
2294+
2295+// GetAllRecentHistory is a helper method to define mock.On call
2296+// - ctx context.Context
2297+func (_e *MockHistoryRepository_Expecter) GetAllRecentHistory(ctx interface{}) *MockHistoryRepository_GetAllRecentHistory_Call {
2298+ return &MockHistoryRepository_GetAllRecentHistory_Call{Call: _e.mock.On("GetAllRecentHistory", ctx)}
2299+}
2300+
2301+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllRecentHistory_Call {
2302+ _c.Call.Run(func(args mock.Arguments) {
2303+ var arg0 context.Context
2304+ if args[0] != nil {
2305+ arg0 = args[0].(context.Context)
2306+ }
2307+ run(
2308+ arg0,
2309+ )
2310+ })
2311+ return _c
2312+}
2313+
2314+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllRecentHistory_Call {
2315+ _c.Call.Return(historyEntrys, err)
2316+ return _c
2317+}
2318+
2319+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllRecentHistory_Call {
2320+ _c.Call.Return(run)
2321+ return _c
2322+}
2323+
22002324 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository
22012325 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
22022326 ret := _mock.Called(ctx, daysAgo)
@@ -2395,6 +2519,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
23952519 return _c
23962520 }
23972521
2522+// GetMostVisited provides a mock function for the type MockHistoryRepository
2523+func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2524+ ret := _mock.Called(ctx, days)
2525+
2526+ if len(ret) == 0 {
2527+ panic("no return value specified for GetMostVisited")
2528+ }
2529+
2530+ var r0 []*entity.HistoryEntry
2531+ var r1 error
2532+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2533+ return returnFunc(ctx, days)
2534+ }
2535+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2536+ r0 = returnFunc(ctx, days)
2537+ } else {
2538+ if ret.Get(0) != nil {
2539+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2540+ }
2541+ }
2542+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2543+ r1 = returnFunc(ctx, days)
2544+ } else {
2545+ r1 = ret.Error(1)
2546+ }
2547+ return r0, r1
2548+}
2549+
2550+// MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited'
2551+type MockHistoryRepository_GetMostVisited_Call struct {
2552+ *mock.Call
2553+}
2554+
2555+// GetMostVisited is a helper method to define mock.On call
2556+// - ctx context.Context
2557+// - days int
2558+func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call {
2559+ return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)}
2560+}
2561+
2562+func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call {
2563+ _c.Call.Run(func(args mock.Arguments) {
2564+ var arg0 context.Context
2565+ if args[0] != nil {
2566+ arg0 = args[0].(context.Context)
2567+ }
2568+ var arg1 int
2569+ if args[1] != nil {
2570+ arg1 = args[1].(int)
2571+ }
2572+ run(
2573+ arg0,
2574+ arg1,
2575+ )
2576+ })
2577+ return _c
2578+}
2579+
2580+func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call {
2581+ _c.Call.Return(historyEntrys, err)
2582+ return _c
2583+}
2584+
2585+func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call {
2586+ _c.Call.Return(run)
2587+ return _c
2588+}
2589+
23982590 // GetRecent provides a mock function for the type MockHistoryRepository
23992591 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
24002592 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2661,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
24692661 return _c
24702662 }
24712663
2664+// GetRecentSince provides a mock function for the type MockHistoryRepository
2665+func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2666+ ret := _mock.Called(ctx, days)
2667+
2668+ if len(ret) == 0 {
2669+ panic("no return value specified for GetRecentSince")
2670+ }
2671+
2672+ var r0 []*entity.HistoryEntry
2673+ var r1 error
2674+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2675+ return returnFunc(ctx, days)
2676+ }
2677+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2678+ r0 = returnFunc(ctx, days)
2679+ } else {
2680+ if ret.Get(0) != nil {
2681+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2682+ }
2683+ }
2684+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2685+ r1 = returnFunc(ctx, days)
2686+ } else {
2687+ r1 = ret.Error(1)
2688+ }
2689+ return r0, r1
2690+}
2691+
2692+// MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince'
2693+type MockHistoryRepository_GetRecentSince_Call struct {
2694+ *mock.Call
2695+}
2696+
2697+// GetRecentSince is a helper method to define mock.On call
2698+// - ctx context.Context
2699+// - days int
2700+func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call {
2701+ return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)}
2702+}
2703+
2704+func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call {
2705+ _c.Call.Run(func(args mock.Arguments) {
2706+ var arg0 context.Context
2707+ if args[0] != nil {
2708+ arg0 = args[0].(context.Context)
2709+ }
2710+ var arg1 int
2711+ if args[1] != nil {
2712+ arg1 = args[1].(int)
2713+ }
2714+ run(
2715+ arg0,
2716+ arg1,
2717+ )
2718+ })
2719+ return _c
2720+}
2721+
2722+func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call {
2723+ _c.Call.Return(historyEntrys, err)
2724+ return _c
2725+}
2726+
2727+func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call {
2728+ _c.Call.Return(run)
2729+ return _c
2730+}
2731+
24722732 // GetStats provides a mock function for the type MockHistoryRepository
24732733 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
24742734 ret := _mock.Called(ctx)
A internal/infrastructure/colorscheme/config_adapter.go +23 -0
@@ -0,0 +1,23 @@
1+package colorscheme
2+
3+import (
4+ "github.com/bnema/dumber/internal/infrastructure/config"
5+)
6+
7+// ConfigAdapter adapts config.Config to the ConfigProvider interface.
8+type ConfigAdapter struct {
9+ cfg *config.Config
10+}
11+
12+// NewConfigAdapter creates a new config adapter.
13+func NewConfigAdapter(cfg *config.Config) *ConfigAdapter {
14+ return &ConfigAdapter{cfg: cfg}
15+}
16+
17+// GetColorScheme implements ConfigProvider.
18+func (a *ConfigAdapter) GetColorScheme() string {
19+ if a.cfg == nil {
20+ return ""
21+ }
22+ return a.cfg.Appearance.ColorScheme
23+}
A internal/infrastructure/colorscheme/detector_adwaita.go +62 -0
@@ -0,0 +1,62 @@
1+package colorscheme
2+
3+import (
4+ "sync/atomic"
5+
6+ "github.com/jwijenbergh/puregotk/v4/adw"
7+)
8+
9+const (
10+ detectorNameAdwaita = "libadwaita"
11+ priorityAdwaita = 100
12+)
13+
14+// AdwaitaDetector detects color scheme from libadwaita's StyleManager.
15+// This is the most accurate detector after adw.Init() has been called,
16+// as it reflects the same state that WebKit uses for prefers-color-scheme.
17+type AdwaitaDetector struct {
18+ available atomic.Bool
19+}
20+
21+// NewAdwaitaDetector creates a new libadwaita-based detector.
22+// Initially unavailable until MarkAvailable() is called after adw.Init().
23+func NewAdwaitaDetector() *AdwaitaDetector {
24+ return &AdwaitaDetector{}
25+}
26+
27+// Name implements port.ColorSchemeDetector.
28+func (*AdwaitaDetector) Name() string {
29+ return detectorNameAdwaita
30+}
31+
32+// Priority implements port.ColorSchemeDetector.
33+func (*AdwaitaDetector) Priority() int {
34+ return priorityAdwaita
35+}
36+
37+// Available implements port.ColorSchemeDetector.
38+// Returns true only after MarkAvailable() has been called.
39+func (d *AdwaitaDetector) Available() bool {
40+ return d.available.Load()
41+}
42+
43+// MarkAvailable should be called after adw.Init() completes.
44+// This enables the detector to query StyleManager.
45+func (d *AdwaitaDetector) MarkAvailable() {
46+ d.available.Store(true)
47+}
48+
49+// Detect implements port.ColorSchemeDetector.
50+// Queries libadwaita's StyleManager for the dark mode preference.
51+func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) {
52+ if !d.Available() {
53+ return false, false
54+ }
55+
56+ styleMgr := adw.StyleManagerGetDefault()
57+ if styleMgr == nil {
58+ return false, false
59+ }
60+
61+ return styleMgr.GetDark(), true
62+}
A internal/infrastructure/colorscheme/detector_env.go +50 -0
@@ -0,0 +1,50 @@
1+package colorscheme
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameEnv = "GTK_THEME"
10+ priorityEnv = 20
11+)
12+
13+// EnvDetector detects color scheme from GTK_THEME environment variable.
14+// This is useful when users explicitly set their theme via environment.
15+type EnvDetector struct{}
16+
17+// NewEnvDetector creates a new environment variable-based detector.
18+func NewEnvDetector() *EnvDetector {
19+ return &EnvDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*EnvDetector) Name() string {
24+ return detectorNameEnv
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*EnvDetector) Priority() int {
29+ return priorityEnv
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if GTK_THEME environment variable is set.
34+func (*EnvDetector) Available() bool {
35+ return os.Getenv("GTK_THEME") != ""
36+}
37+
38+// Detect implements port.ColorSchemeDetector.
39+// Checks if GTK_THEME contains "dark" (case-insensitive).
40+func (*EnvDetector) Detect() (prefersDark, ok bool) {
41+ gtkTheme := os.Getenv("GTK_THEME")
42+ if gtkTheme == "" {
43+ return false, false
44+ }
45+
46+ // If GTK_THEME contains "dark", prefer dark mode
47+ // Otherwise, assume light mode
48+ prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
49+ return prefersDark, true
50+}
A internal/infrastructure/colorscheme/detector_gsettings.go +63 -0
@@ -0,0 +1,63 @@
1+package colorscheme
2+
3+import (
4+ "os/exec"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameGsettings = "gsettings"
10+ priorityGsettings = 10
11+)
12+
13+// GsettingsDetector detects color scheme from GNOME gsettings.
14+// This is the most reliable method for GNOME-based desktops.
15+type GsettingsDetector struct{}
16+
17+// NewGsettingsDetector creates a new gsettings-based detector.
18+func NewGsettingsDetector() *GsettingsDetector {
19+ return &GsettingsDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*GsettingsDetector) Name() string {
24+ return detectorNameGsettings
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*GsettingsDetector) Priority() int {
29+ return priorityGsettings
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if gsettings command is available.
34+func (*GsettingsDetector) Available() bool {
35+ _, err := exec.LookPath("gsettings")
36+ return err == nil
37+}
38+
39+// Detect implements port.ColorSchemeDetector.
40+// Queries org.gnome.desktop.interface color-scheme.
41+func (*GsettingsDetector) Detect() (prefersDark, ok bool) {
42+ cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
43+ output, err := cmd.Output()
44+ if err != nil {
45+ return false, false
46+ }
47+
48+ // Output is like "'prefer-dark'\n", strip quotes and whitespace
49+ result := strings.TrimSpace(string(output))
50+ result = strings.Trim(result, "'\"")
51+
52+ switch result {
53+ case "prefer-dark":
54+ return true, true
55+ case "prefer-light":
56+ return false, true
57+ case "default":
58+ // "default" means follow system, which we can't determine here
59+ return false, false
60+ default:
61+ return false, false
62+ }
63+}
A internal/infrastructure/colorscheme/resolver.go +170 -0
@@ -0,0 +1,170 @@
1+package colorscheme
2+
3+import (
4+ "cmp"
5+ "slices"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ // sourceFallback indicates no detector provided the preference.
14+ sourceFallback = "fallback"
15+ // sourceConfig indicates the preference came from user config.
16+ sourceConfig = "config"
17+)
18+
19+// ConfigProvider provides access to the color scheme configuration.
20+type ConfigProvider interface {
21+ // GetColorScheme returns the configured color scheme preference.
22+ // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light"
23+ GetColorScheme() string
24+}
25+
26+// callbackWrapper wraps a callback function to enable pointer comparison for removal.
27+type callbackWrapper struct {
28+ fn func(port.ColorSchemePreference)
29+}
30+
31+// Resolver implements port.ColorSchemeResolver.
32+// It manages multiple detectors and respects config overrides.
33+type Resolver struct {
34+ mu sync.RWMutex
35+ config ConfigProvider
36+ detectors []port.ColorSchemeDetector
37+ sortedDetectors []port.ColorSchemeDetector // cached sorted detectors
38+ current port.ColorSchemePreference
39+ callbacks []*callbackWrapper
40+}
41+
42+// NewResolver creates a new color scheme resolver.
43+// The config provider is used to check for explicit user preferences.
44+func NewResolver(config ConfigProvider) *Resolver {
45+ return &Resolver{
46+ config: config,
47+ detectors: make([]port.ColorSchemeDetector, 0),
48+ current: port.ColorSchemePreference{
49+ PrefersDark: true, // Default to dark until first Resolve()
50+ Source: sourceFallback,
51+ },
52+ }
53+}
54+
55+// Resolve implements port.ColorSchemeResolver.
56+func (r *Resolver) Resolve() port.ColorSchemePreference {
57+ r.mu.RLock()
58+ defer r.mu.RUnlock()
59+ return r.resolveInternal()
60+}
61+
62+// resolveInternal performs the actual resolution without locking.
63+// Caller must hold at least a read lock.
64+func (r *Resolver) resolveInternal() port.ColorSchemePreference {
65+ // Check config for explicit override first
66+ if r.config != nil {
67+ scheme := r.config.GetColorScheme()
68+ switch strings.ToLower(scheme) {
69+ case "prefer-dark", "dark":
70+ return port.ColorSchemePreference{
71+ PrefersDark: true,
72+ Source: sourceConfig,
73+ }
74+ case "prefer-light", "light":
75+ return port.ColorSchemePreference{
76+ PrefersDark: false,
77+ Source: sourceConfig,
78+ }
79+ // "default" or empty falls through to detector chain
80+ }
81+ }
82+
83+ // Try each detector in priority order (already sorted)
84+ for _, detector := range r.sortedDetectors {
85+ if !detector.Available() {
86+ continue
87+ }
88+ if prefersDark, ok := detector.Detect(); ok {
89+ return port.ColorSchemePreference{
90+ PrefersDark: prefersDark,
91+ Source: detector.Name(),
92+ }
93+ }
94+ }
95+
96+ // Fallback to dark mode if all detectors fail
97+ return port.ColorSchemePreference{
98+ PrefersDark: true,
99+ Source: sourceFallback,
100+ }
101+}
102+
103+// RegisterDetector implements port.ColorSchemeResolver.
104+func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
105+ r.mu.Lock()
106+ defer r.mu.Unlock()
107+ r.detectors = append(r.detectors, detector)
108+ r.rebuildSortedDetectors()
109+}
110+
111+// rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first).
112+// Caller must hold the write lock.
113+func (r *Resolver) rebuildSortedDetectors() {
114+ r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors))
115+ copy(r.sortedDetectors, r.detectors)
116+ slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int {
117+ return cmp.Compare(b.Priority(), a.Priority()) // descending order
118+ })
119+}
120+
121+// Refresh implements port.ColorSchemeResolver.
122+func (r *Resolver) Refresh() port.ColorSchemePreference {
123+ r.mu.Lock()
124+
125+ newPref := r.resolveInternal()
126+
127+ // Prepare callbacks to invoke if preference changed
128+ var callbacks []*callbackWrapper
129+ if newPref.PrefersDark != r.current.PrefersDark {
130+ r.current = newPref
131+ callbacks = make([]*callbackWrapper, len(r.callbacks))
132+ copy(callbacks, r.callbacks)
133+ } else {
134+ r.current = newPref
135+ }
136+
137+ // Release lock before invoking callbacks to avoid deadlocks
138+ r.mu.Unlock()
139+
140+ // Invoke callbacks outside of lock
141+ for _, cb := range callbacks {
142+ cb.fn(newPref)
143+ }
144+
145+ return newPref
146+}
147+
148+// OnChange implements port.ColorSchemeResolver.
149+func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+
153+ // Wrap callback to enable pointer comparison for removal
154+ wrapper := &callbackWrapper{fn: callback}
155+ r.callbacks = append(r.callbacks, wrapper)
156+
157+ // Return unregister function
158+ return func() {
159+ r.mu.Lock()
160+ defer r.mu.Unlock()
161+
162+ // Find and remove callback by pointer equality
163+ for i, cb := range r.callbacks {
164+ if cb == wrapper {
165+ r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...)
166+ return
167+ }
168+ }
169+ }
170+}
A internal/infrastructure/colorscheme/resolver_test.go +425 -0
@@ -0,0 +1,425 @@
1+package colorscheme
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// mockConfigProvider implements ConfigProvider for testing.
13+type mockConfigProvider struct {
14+ scheme string
15+}
16+
17+func (m *mockConfigProvider) GetColorScheme() string {
18+ return m.scheme
19+}
20+
21+// mockDetector implements port.ColorSchemeDetector for testing.
22+type mockDetector struct {
23+ name string
24+ priority int
25+ available bool
26+ prefersDark bool
27+ detectOk bool
28+}
29+
30+func (m *mockDetector) Name() string { return m.name }
31+func (m *mockDetector) Priority() int { return m.priority }
32+func (m *mockDetector) Available() bool { return m.available }
33+func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk }
34+
35+func TestResolver_ConfigOverride(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ configValue string
39+ wantDark bool
40+ wantSource string
41+ }{
42+ {
43+ name: "prefer-dark from config",
44+ configValue: "prefer-dark",
45+ wantDark: true,
46+ wantSource: "config",
47+ },
48+ {
49+ name: "dark from config",
50+ configValue: "dark",
51+ wantDark: true,
52+ wantSource: "config",
53+ },
54+ {
55+ name: "prefer-light from config",
56+ configValue: "prefer-light",
57+ wantDark: false,
58+ wantSource: "config",
59+ },
60+ {
61+ name: "light from config",
62+ configValue: "light",
63+ wantDark: false,
64+ wantSource: "config",
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ config := &mockConfigProvider{scheme: tt.configValue}
71+ resolver := NewResolver(config)
72+
73+ pref := resolver.Resolve()
74+
75+ assert.Equal(t, tt.wantDark, pref.PrefersDark)
76+ assert.Equal(t, tt.wantSource, pref.Source)
77+ })
78+ }
79+}
80+
81+func TestResolver_DefaultFallsThrough(t *testing.T) {
82+ config := &mockConfigProvider{scheme: "default"}
83+ resolver := NewResolver(config)
84+
85+ // Add a detector that returns light
86+ detector := &mockDetector{
87+ name: "test",
88+ priority: 50,
89+ available: true,
90+ prefersDark: false,
91+ detectOk: true,
92+ }
93+ resolver.RegisterDetector(detector)
94+
95+ pref := resolver.Resolve()
96+
97+ assert.False(t, pref.PrefersDark)
98+ assert.Equal(t, "test", pref.Source)
99+}
100+
101+func TestResolver_DetectorPriority(t *testing.T) {
102+ config := &mockConfigProvider{scheme: "default"}
103+ resolver := NewResolver(config)
104+
105+ // Low priority detector returns dark
106+ lowPriority := &mockDetector{
107+ name: "low",
108+ priority: 10,
109+ available: true,
110+ prefersDark: true,
111+ detectOk: true,
112+ }
113+ // High priority detector returns light
114+ highPriority := &mockDetector{
115+ name: "high",
116+ priority: 100,
117+ available: true,
118+ prefersDark: false,
119+ detectOk: true,
120+ }
121+
122+ // Register low first, high second (order shouldn't matter)
123+ resolver.RegisterDetector(lowPriority)
124+ resolver.RegisterDetector(highPriority)
125+
126+ pref := resolver.Resolve()
127+
128+ // Should use high priority detector
129+ assert.False(t, pref.PrefersDark)
130+ assert.Equal(t, "high", pref.Source)
131+}
132+
133+func TestResolver_SkipsUnavailableDetector(t *testing.T) {
134+ config := &mockConfigProvider{scheme: "default"}
135+ resolver := NewResolver(config)
136+
137+ // High priority but unavailable
138+ unavailable := &mockDetector{
139+ name: "unavailable",
140+ priority: 100,
141+ available: false,
142+ prefersDark: false,
143+ detectOk: true,
144+ }
145+ // Low priority but available
146+ available := &mockDetector{
147+ name: "available",
148+ priority: 10,
149+ available: true,
150+ prefersDark: true,
151+ detectOk: true,
152+ }
153+
154+ resolver.RegisterDetector(unavailable)
155+ resolver.RegisterDetector(available)
156+
157+ pref := resolver.Resolve()
158+
159+ // Should use available detector
160+ assert.True(t, pref.PrefersDark)
161+ assert.Equal(t, "available", pref.Source)
162+}
163+
164+func TestResolver_SkipsFailedDetection(t *testing.T) {
165+ config := &mockConfigProvider{scheme: "default"}
166+ resolver := NewResolver(config)
167+
168+ // High priority but detection fails
169+ failing := &mockDetector{
170+ name: "failing",
171+ priority: 100,
172+ available: true,
173+ prefersDark: false,
174+ detectOk: false, // Detection fails
175+ }
176+ // Low priority but succeeds
177+ succeeding := &mockDetector{
178+ name: "succeeding",
179+ priority: 10,
180+ available: true,
181+ prefersDark: true,
182+ detectOk: true,
183+ }
184+
185+ resolver.RegisterDetector(failing)
186+ resolver.RegisterDetector(succeeding)
187+
188+ pref := resolver.Resolve()
189+
190+ // Should use succeeding detector
191+ assert.True(t, pref.PrefersDark)
192+ assert.Equal(t, "succeeding", pref.Source)
193+}
194+
195+func TestResolver_FallbackWhenNoDetectors(t *testing.T) {
196+ config := &mockConfigProvider{scheme: "default"}
197+ resolver := NewResolver(config)
198+
199+ // No detectors registered
200+ pref := resolver.Resolve()
201+
202+ // Should fallback to dark
203+ assert.True(t, pref.PrefersDark)
204+ assert.Equal(t, "fallback", pref.Source)
205+}
206+
207+func TestResolver_FallbackWhenAllFail(t *testing.T) {
208+ config := &mockConfigProvider{scheme: "default"}
209+ resolver := NewResolver(config)
210+
211+ // All detectors fail
212+ resolver.RegisterDetector(&mockDetector{
213+ name: "fail1",
214+ priority: 100,
215+ available: true,
216+ detectOk: false,
217+ })
218+ resolver.RegisterDetector(&mockDetector{
219+ name: "fail2",
220+ priority: 50,
221+ available: false,
222+ })
223+
224+ pref := resolver.Resolve()
225+
226+ // Should fallback to dark
227+ assert.True(t, pref.PrefersDark)
228+ assert.Equal(t, "fallback", pref.Source)
229+}
230+
231+func TestResolver_NilConfig(t *testing.T) {
232+ resolver := NewResolver(nil)
233+
234+ detector := &mockDetector{
235+ name: "test",
236+ priority: 50,
237+ available: true,
238+ prefersDark: false,
239+ detectOk: true,
240+ }
241+ resolver.RegisterDetector(detector)
242+
243+ pref := resolver.Resolve()
244+
245+ // Should use detector since config is nil
246+ assert.False(t, pref.PrefersDark)
247+ assert.Equal(t, "test", pref.Source)
248+}
249+
250+func TestResolver_Refresh(t *testing.T) {
251+ config := &mockConfigProvider{scheme: "default"}
252+ resolver := NewResolver(config)
253+
254+ // Start with light-preferring detector
255+ detector := &mockDetector{
256+ name: "test",
257+ priority: 50,
258+ available: true,
259+ prefersDark: false,
260+ detectOk: true,
261+ }
262+ resolver.RegisterDetector(detector)
263+
264+ pref1 := resolver.Refresh()
265+ assert.False(t, pref1.PrefersDark)
266+
267+ // Change detector preference
268+ detector.prefersDark = true
269+
270+ pref2 := resolver.Refresh()
271+ assert.True(t, pref2.PrefersDark)
272+}
273+
274+func TestResolver_OnChange(t *testing.T) {
275+ config := &mockConfigProvider{scheme: "default"}
276+ resolver := NewResolver(config)
277+
278+ detector := &mockDetector{
279+ name: "test",
280+ priority: 50,
281+ available: true,
282+ prefersDark: false,
283+ detectOk: true,
284+ }
285+ resolver.RegisterDetector(detector)
286+
287+ var callbackPref port.ColorSchemePreference
288+ var callbackCount int
289+ resolver.OnChange(func(pref port.ColorSchemePreference) {
290+ callbackPref = pref
291+ callbackCount++
292+ })
293+
294+ // Initial refresh - callback should be called (default was dark, now light)
295+ resolver.Refresh()
296+ assert.Equal(t, 1, callbackCount)
297+ assert.False(t, callbackPref.PrefersDark)
298+
299+ // Same preference - callback should NOT be called
300+ resolver.Refresh()
301+ assert.Equal(t, 1, callbackCount)
302+
303+ // Change preference - callback should be called
304+ detector.prefersDark = true
305+ resolver.Refresh()
306+ assert.Equal(t, 2, callbackCount)
307+ assert.True(t, callbackPref.PrefersDark)
308+}
309+
310+func TestResolver_OnChangeUnregister(t *testing.T) {
311+ config := &mockConfigProvider{scheme: "default"}
312+ resolver := NewResolver(config)
313+
314+ detector := &mockDetector{
315+ name: "test",
316+ priority: 50,
317+ available: true,
318+ prefersDark: false,
319+ detectOk: true,
320+ }
321+ resolver.RegisterDetector(detector)
322+
323+ var callbackCount int
324+ unregister := resolver.OnChange(func(_ port.ColorSchemePreference) {
325+ callbackCount++
326+ })
327+
328+ // Initial refresh triggers callback
329+ resolver.Refresh()
330+ assert.Equal(t, 1, callbackCount)
331+
332+ // Unregister
333+ unregister()
334+
335+ // Change preference - callback should NOT be called
336+ detector.prefersDark = true
337+ resolver.Refresh()
338+ assert.Equal(t, 1, callbackCount) // Still 1, not 2
339+}
340+
341+func TestResolver_ConcurrentAccess(_ *testing.T) {
342+ config := &mockConfigProvider{scheme: "default"}
343+ resolver := NewResolver(config)
344+
345+ detector := &mockDetector{
346+ name: "test",
347+ priority: 50,
348+ available: true,
349+ prefersDark: false,
350+ detectOk: true,
351+ }
352+ resolver.RegisterDetector(detector)
353+
354+ var wg sync.WaitGroup
355+ const goroutines = 10
356+
357+ // Concurrent Resolve calls
358+ for i := 0; i < goroutines; i++ {
359+ wg.Add(1)
360+ go func() {
361+ defer wg.Done()
362+ for j := 0; j < 100; j++ {
363+ resolver.Resolve()
364+ }
365+ }()
366+ }
367+
368+ // Concurrent Refresh calls
369+ for i := 0; i < goroutines; i++ {
370+ wg.Add(1)
371+ go func() {
372+ defer wg.Done()
373+ for j := 0; j < 100; j++ {
374+ resolver.Refresh()
375+ }
376+ }()
377+ }
378+
379+ // Concurrent RegisterDetector calls
380+ for i := 0; i < goroutines; i++ {
381+ wg.Add(1)
382+ go func(id int) {
383+ defer wg.Done()
384+ resolver.RegisterDetector(&mockDetector{
385+ name: "concurrent",
386+ priority: id,
387+ available: true,
388+ prefersDark: id%2 == 0,
389+ detectOk: true,
390+ })
391+ }(i)
392+ }
393+
394+ wg.Wait()
395+ // Test passes if no race conditions detected
396+}
397+
398+func TestResolver_EmptyConfigScheme(t *testing.T) {
399+ config := &mockConfigProvider{scheme: ""}
400+ resolver := NewResolver(config)
401+
402+ detector := &mockDetector{
403+ name: "test",
404+ priority: 50,
405+ available: true,
406+ prefersDark: false,
407+ detectOk: true,
408+ }
409+ resolver.RegisterDetector(detector)
410+
411+ pref := resolver.Resolve()
412+
413+ // Empty config should fall through to detector
414+ assert.False(t, pref.PrefersDark)
415+ assert.Equal(t, "test", pref.Source)
416+}
417+
418+func TestResolver_ImplementsInterface(t *testing.T) {
419+ config := &mockConfigProvider{scheme: "default"}
420+ resolver := NewResolver(config)
421+
422+ // Verify Resolver implements port.ColorSchemeResolver
423+ var _ port.ColorSchemeResolver = resolver
424+ require.NotNil(t, resolver)
425+}
M internal/infrastructure/config/defaults.go +22 -2
@@ -7,7 +7,7 @@ const (
77 defaultRetentionDays = 365 // 1 year
88
99 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items
10+ defaultMaxHistoryDays = 30 // days
1111
1212 // Logging defaults
1313 defaultMaxLogAgeDays = 7 // days
@@ -65,6 +65,10 @@ const (
6565 // Performance defaults
6666 defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
6767 defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
68+
69+ // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
70+ defaultSkiaCPUPaintingThreads = 0
71+ defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting
6872 )
6973
7074 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -90,7 +94,7 @@ func DefaultConfig() *Config {
9094 SearchShortcuts: GetDefaultSearchShortcuts(),
9195 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
9296 Dmenu: DmenuConfig{
93- MaxHistoryItems: defaultMaxHistoryItems,
97+ MaxHistoryDays: defaultMaxHistoryDays,
9498 ShowVisitCount: true,
9599 ShowLastVisited: true,
96100 HistoryPrefix: "🕒",
@@ -282,8 +286,24 @@ func DefaultConfig() *Config {
282286 Prefix: "",
283287 },
284288 Performance: PerformanceConfig{
289+ Profile: ProfileDefault, // Use WebKit defaults, no tuning
285290 ZoomCacheSize: defaultZoomCacheSize,
286291 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
292+ // Skia threading - balanced defaults (unset = use WebKit defaults)
293+ // These only apply when Profile is "custom"
294+ SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
295+ SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads,
296+ SkiaEnableCPURendering: false,
297+ // Web process memory pressure - all unset by default
298+ WebProcessMemoryLimitMB: 0,
299+ WebProcessMemoryPollIntervalSec: 0,
300+ WebProcessMemoryConservativeThreshold: 0,
301+ WebProcessMemoryStrictThreshold: 0,
302+ // Network process memory pressure - all unset by default
303+ NetworkProcessMemoryLimitMB: 0,
304+ NetworkProcessMemoryPollIntervalSec: 0,
305+ NetworkProcessMemoryConservativeThreshold: 0,
306+ NetworkProcessMemoryStrictThreshold: 0,
287307 },
288308 Update: UpdateConfig{
289309 EnableOnStartup: true, // Check for updates on startup by default
M internal/infrastructure/config/loader.go +40 -1
@@ -212,6 +212,20 @@ func normalizeConfig(config *Config) {
212212 }
213213
214214 config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
215+ normalizePerformanceProfile(config)
216+}
217+
218+func normalizePerformanceProfile(config *Config) {
219+ switch strings.ToLower(string(config.Performance.Profile)) {
220+ case "", string(ProfileDefault):
221+ config.Performance.Profile = ProfileDefault
222+ case string(ProfileLite):
223+ config.Performance.Profile = ProfileLite
224+ case string(ProfileMax):
225+ config.Performance.Profile = ProfileMax
226+ case string(ProfileCustom):
227+ config.Performance.Profile = ProfileCustom
228+ }
215229 }
216230
217231 // Get returns the current configuration (thread-safe).
@@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error {
252266 m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
253267 m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
254268
269+ // Performance profile (requires browser restart to take effect)
270+ m.viper.Set("performance.profile", string(cfg.Performance.Profile))
271+
255272 if err := m.viper.WriteConfig(); err != nil {
256273 return fmt.Errorf("failed to write config: %w", err)
257274 }
@@ -316,6 +333,7 @@ func (m *Manager) setDefaults() {
316333 m.setRuntimeDefaults(defaults)
317334 m.setSessionDefaults(defaults)
318335 m.setUpdateDefaults(defaults)
336+ m.setPerformanceDefaults(defaults)
319337 }
320338
321339 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -330,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
330348 }
331349
332350 func (m *Manager) setDmenuDefaults(defaults *Config) {
333- m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
351+ m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
334352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
335353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
336354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
@@ -461,6 +479,27 @@ func (m *Manager) setUpdateDefaults(defaults *Config) {
461479 m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
462480 }
463481
482+func (m *Manager) setPerformanceDefaults(defaults *Config) {
483+ m.viper.SetDefault("performance.profile", string(defaults.Performance.Profile))
484+ m.viper.SetDefault("performance.zoom_cache_size", defaults.Performance.ZoomCacheSize)
485+ m.viper.SetDefault("performance.webview_pool_prewarm_count", defaults.Performance.WebViewPoolPrewarmCount)
486+ // Skia threading (only used when profile = "custom")
487+ m.viper.SetDefault("performance.skia_cpu_painting_threads", defaults.Performance.SkiaCPUPaintingThreads)
488+ m.viper.SetDefault("performance.skia_gpu_painting_threads", defaults.Performance.SkiaGPUPaintingThreads)
489+ m.viper.SetDefault("performance.skia_enable_cpu_rendering", defaults.Performance.SkiaEnableCPURendering)
490+ // Web process memory pressure
491+ m.viper.SetDefault("performance.web_process_memory_limit_mb", defaults.Performance.WebProcessMemoryLimitMB)
492+ m.viper.SetDefault("performance.web_process_memory_poll_interval_sec", defaults.Performance.WebProcessMemoryPollIntervalSec)
493+ m.viper.SetDefault("performance.web_process_memory_conservative_threshold", defaults.Performance.WebProcessMemoryConservativeThreshold)
494+ m.viper.SetDefault("performance.web_process_memory_strict_threshold", defaults.Performance.WebProcessMemoryStrictThreshold)
495+ // Network process memory pressure
496+ m.viper.SetDefault("performance.network_process_memory_limit_mb", defaults.Performance.NetworkProcessMemoryLimitMB)
497+ m.viper.SetDefault("performance.network_process_memory_poll_interval_sec", defaults.Performance.NetworkProcessMemoryPollIntervalSec)
498+ netConservativeThreshold := defaults.Performance.NetworkProcessMemoryConservativeThreshold
499+ m.viper.SetDefault("performance.network_process_memory_conservative_threshold", netConservativeThreshold)
500+ m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
501+}
502+
464503 // New returns a new default configuration instance.
465504 // This is a convenience function for getting default config without the full manager.
466505 func New() *Config {
A internal/infrastructure/config/profile.go +307 -0
@@ -0,0 +1,307 @@
1+package config
2+
3+import (
4+ "runtime"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+const (
10+ // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads
11+ // supported by WebKitGTK (0-8 range).
12+ maxSkiaCPUThreads = 8
13+
14+ // maxSkiaGPUThreads is the maximum number of Skia GPU painting threads
15+ // supported by WebKitGTK (-1 to 8 range, where -1 means "let WebKit decide").
16+ maxSkiaGPUThreads = 8
17+
18+ // Memory tier thresholds (in bytes)
19+ // Explicitly typed as uint64 to prevent overflow on 32-bit systems.
20+ ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB
21+ ramTierMedium uint64 = 16 * 1024 * 1024 * 1024 // 16 GB
22+ ramTierHigh uint64 = 32 * 1024 * 1024 * 1024 // 32 GB
23+
24+ // VRAM tier thresholds (in bytes)
25+ vramTierLow uint64 = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated)
26+ vramTierMid uint64 = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete)
27+ vramTierHigh uint64 = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete)
28+
29+ // GPU thread scaling values
30+ gpuThreadsHigh = 8 // For 16GB+ VRAM
31+ gpuThreadsMid = 6 // For 8-16GB VRAM
32+ gpuThreadsLow = 4 // For 4-8GB VRAM
33+ gpuThreadsMin = 2 // For <4GB VRAM or unknown
34+
35+ // Memory limit values (in MB)
36+ webMemHigh = 4096 // For 32GB+ RAM
37+ webMemMedium = 3072 // For 16-32GB RAM
38+ webMemLow = 2048 // For 8-16GB RAM
39+ webMemMin = 1024 // For <8GB RAM
40+ netMemHigh = 1024 // For 32GB+ RAM
41+ netMemMedium = 768 // For 16-32GB RAM
42+ netMemLow = 512 // For 8-16GB RAM
43+ netMemMin = 256 // For <8GB RAM
44+ webMemFallback = 2048 // When RAM unknown
45+ netMemFallback = 512 // When RAM unknown
46+
47+ // WebView pool prewarm counts
48+ poolPrewarmHigh = 12 // For 32GB+ RAM
49+ poolPrewarmMedium = 8 // For 16-32GB RAM
50+ poolPrewarmLow = 6 // For 8-16GB RAM
51+ poolPrewarmMin = 4 // For <8GB RAM
52+ poolPrewarmFallback = 8 // When RAM unknown
53+)
54+
55+// ResolvedPerformanceSettings contains the computed performance settings
56+// after profile resolution. These are the actual values to apply.
57+type ResolvedPerformanceSettings struct {
58+ // Skia threading
59+ SkiaCPUPaintingThreads int
60+ SkiaGPUPaintingThreads int
61+ SkiaEnableCPURendering bool
62+
63+ // Web process memory pressure
64+ WebProcessMemoryLimitMB int
65+ WebProcessMemoryPollIntervalSec float64
66+ WebProcessMemoryConservativeThreshold float64
67+ WebProcessMemoryStrictThreshold float64
68+
69+ // Network process memory pressure
70+ NetworkProcessMemoryLimitMB int
71+ NetworkProcessMemoryPollIntervalSec float64
72+ NetworkProcessMemoryConservativeThreshold float64
73+ NetworkProcessMemoryStrictThreshold float64
74+
75+ // WebView pool
76+ WebViewPoolPrewarmCount int
77+}
78+
79+// ResolvePerformanceProfile computes the actual performance settings based on
80+// the selected profile. For ProfileCustom, it returns the user's configured values.
81+// For other profiles, it computes appropriate values based on detected hardware.
82+//
83+// The hw parameter is optional - if nil, fallback values are used.
84+func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings {
85+ switch cfg.Profile {
86+ case ProfileLite:
87+ return resolveLiteProfile(hw)
88+ case ProfileMax:
89+ return resolveMaxProfile(hw)
90+ case ProfileCustom:
91+ return resolveCustomProfile(cfg)
92+ default: // ProfileDefault or empty
93+ return resolveDefaultProfile()
94+ }
95+}
96+
97+// resolveDefaultProfile returns WebKit defaults (no tuning).
98+func resolveDefaultProfile() ResolvedPerformanceSettings {
99+ return ResolvedPerformanceSettings{
100+ // All zeros/negatives mean "unset, use WebKit defaults"
101+ SkiaCPUPaintingThreads: 0,
102+ SkiaGPUPaintingThreads: -1,
103+ SkiaEnableCPURendering: false,
104+
105+ WebProcessMemoryLimitMB: 0,
106+ WebProcessMemoryPollIntervalSec: 0,
107+ WebProcessMemoryConservativeThreshold: 0,
108+ WebProcessMemoryStrictThreshold: 0,
109+
110+ NetworkProcessMemoryLimitMB: 0,
111+ NetworkProcessMemoryPollIntervalSec: 0,
112+ NetworkProcessMemoryConservativeThreshold: 0,
113+ NetworkProcessMemoryStrictThreshold: 0,
114+
115+ WebViewPoolPrewarmCount: 4,
116+ }
117+}
118+
119+// resolveLiteProfile returns settings optimized for low-RAM systems.
120+// Uses hardware info to scale CPU threads if available.
121+func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
122+ // Use 2 CPU threads, or scale down on very low-core systems
123+ cpuThreads := 2
124+ if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 {
125+ cpuThreads = 1
126+ }
127+
128+ return ResolvedPerformanceSettings{
129+ SkiaCPUPaintingThreads: cpuThreads,
130+ SkiaGPUPaintingThreads: -1, // unset
131+ SkiaEnableCPURendering: false,
132+
133+ WebProcessMemoryLimitMB: 768,
134+ WebProcessMemoryPollIntervalSec: 0, // use WebKit default (30s)
135+ WebProcessMemoryConservativeThreshold: 0.25,
136+ WebProcessMemoryStrictThreshold: 0.4,
137+
138+ NetworkProcessMemoryLimitMB: 384,
139+ NetworkProcessMemoryPollIntervalSec: 0,
140+ NetworkProcessMemoryConservativeThreshold: 0.25,
141+ NetworkProcessMemoryStrictThreshold: 0.4,
142+
143+ WebViewPoolPrewarmCount: 2,
144+ }
145+}
146+
147+// resolveMaxProfile returns settings optimized for maximum responsiveness.
148+// Scales values based on detected hardware capabilities.
149+func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
150+ // CPU threads: use physical cores (not threads), capped at WebKitGTK limit
151+ cpuThreads := computeMaxCPUThreads(hw)
152+
153+ // GPU threads: scale based on VRAM
154+ gpuThreads := computeMaxGPUThreads(hw)
155+
156+ // WebView pool: scale based on RAM
157+ poolPrewarm := computeMaxPoolPrewarm(hw)
158+
159+ return ResolvedPerformanceSettings{
160+ SkiaCPUPaintingThreads: cpuThreads,
161+ SkiaGPUPaintingThreads: gpuThreads,
162+ SkiaEnableCPURendering: false,
163+
164+ // Max profile: no memory limits, let WebKit use defaults
165+ WebProcessMemoryLimitMB: 0,
166+ WebProcessMemoryPollIntervalSec: 0,
167+ WebProcessMemoryConservativeThreshold: 0,
168+ WebProcessMemoryStrictThreshold: 0,
169+
170+ NetworkProcessMemoryLimitMB: 0,
171+ NetworkProcessMemoryPollIntervalSec: 0,
172+ NetworkProcessMemoryConservativeThreshold: 0,
173+ NetworkProcessMemoryStrictThreshold: 0,
174+
175+ WebViewPoolPrewarmCount: poolPrewarm,
176+ }
177+}
178+
179+// computeMaxCPUThreads calculates Skia CPU threads for max profile.
180+// Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit.
181+func computeMaxCPUThreads(hw *port.HardwareInfo) int {
182+ var cpuThreads int
183+
184+ if hw != nil && hw.CPUCores > 0 {
185+ // Use physical cores directly - they're better for parallel painting
186+ cpuThreads = hw.CPUCores
187+ } else {
188+ // Fallback: assume hyperthreading, use half of logical CPUs
189+ cpuThreads = runtime.NumCPU() / 2
190+ }
191+
192+ // Minimum 4 for meaningful parallelism
193+ if cpuThreads < 4 {
194+ cpuThreads = 4
195+ }
196+ // Cap at WebKitGTK Skia limit
197+ if cpuThreads > maxSkiaCPUThreads {
198+ cpuThreads = maxSkiaCPUThreads
199+ }
200+
201+ return cpuThreads
202+}
203+
204+// computeMaxGPUThreads calculates Skia GPU threads based on VRAM.
205+func computeMaxGPUThreads(hw *port.HardwareInfo) int {
206+ if hw == nil || hw.VRAM == 0 {
207+ // No VRAM info - use conservative default
208+ return gpuThreadsMin
209+ }
210+
211+ // Scale GPU threads based on VRAM tiers
212+ switch {
213+ case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+)
214+ return gpuThreadsHigh
215+ case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070)
216+ return gpuThreadsMid
217+ case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete)
218+ return gpuThreadsLow
219+ default: // <4GB VRAM (integrated / old discrete)
220+ return gpuThreadsMin
221+ }
222+}
223+
224+// computeMaxMemoryLimits returns web process and network process memory limits.
225+func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) {
226+ if hw == nil || hw.TotalRAM == 0 {
227+ // Fallback: assume 16GB system
228+ return webMemFallback, netMemFallback
229+ }
230+
231+ // Scale memory limits based on system RAM
232+ switch {
233+ case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM
234+ return webMemHigh, netMemHigh
235+ case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM
236+ return webMemMedium, netMemMedium
237+ case hw.TotalRAM >= ramTierLow: // 8-16GB RAM
238+ return webMemLow, netMemLow
239+ default: // <8GB RAM
240+ return webMemMin, netMemMin
241+ }
242+}
243+
244+// computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM.
245+func computeMaxPoolPrewarm(hw *port.HardwareInfo) int {
246+ if hw == nil || hw.TotalRAM == 0 {
247+ return poolPrewarmFallback
248+ }
249+
250+ switch {
251+ case hw.TotalRAM >= ramTierHigh: // 32GB+
252+ return poolPrewarmHigh
253+ case hw.TotalRAM >= ramTierMedium: // 16-32GB
254+ return poolPrewarmMedium
255+ case hw.TotalRAM >= ramTierLow: // 8-16GB
256+ return poolPrewarmLow
257+ default: // <8GB
258+ return poolPrewarmMin
259+ }
260+}
261+
262+// resolveCustomProfile returns the user's configured values directly.
263+func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings {
264+ return ResolvedPerformanceSettings{
265+ SkiaCPUPaintingThreads: cfg.SkiaCPUPaintingThreads,
266+ SkiaGPUPaintingThreads: cfg.SkiaGPUPaintingThreads,
267+ SkiaEnableCPURendering: cfg.SkiaEnableCPURendering,
268+
269+ WebProcessMemoryLimitMB: cfg.WebProcessMemoryLimitMB,
270+ WebProcessMemoryPollIntervalSec: cfg.WebProcessMemoryPollIntervalSec,
271+ WebProcessMemoryConservativeThreshold: cfg.WebProcessMemoryConservativeThreshold,
272+ WebProcessMemoryStrictThreshold: cfg.WebProcessMemoryStrictThreshold,
273+
274+ NetworkProcessMemoryLimitMB: cfg.NetworkProcessMemoryLimitMB,
275+ NetworkProcessMemoryPollIntervalSec: cfg.NetworkProcessMemoryPollIntervalSec,
276+ NetworkProcessMemoryConservativeThreshold: cfg.NetworkProcessMemoryConservativeThreshold,
277+ NetworkProcessMemoryStrictThreshold: cfg.NetworkProcessMemoryStrictThreshold,
278+
279+ WebViewPoolPrewarmCount: cfg.WebViewPoolPrewarmCount,
280+ }
281+}
282+
283+// HasCustomPerformanceFields returns true if any individual performance tuning
284+// fields are set to non-default values. Used for validation.
285+func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
286+ return cfg.SkiaCPUPaintingThreads != 0 ||
287+ cfg.SkiaGPUPaintingThreads != -1 ||
288+ cfg.SkiaEnableCPURendering ||
289+ cfg.WebProcessMemoryLimitMB != 0 ||
290+ cfg.WebProcessMemoryPollIntervalSec != 0 ||
291+ cfg.WebProcessMemoryConservativeThreshold != 0 ||
292+ cfg.WebProcessMemoryStrictThreshold != 0 ||
293+ cfg.NetworkProcessMemoryLimitMB != 0 ||
294+ cfg.NetworkProcessMemoryPollIntervalSec != 0 ||
295+ cfg.NetworkProcessMemoryConservativeThreshold != 0 ||
296+ cfg.NetworkProcessMemoryStrictThreshold != 0
297+}
298+
299+// IsValidPerformanceProfile returns true if the profile name is recognized.
300+func IsValidPerformanceProfile(profile PerformanceProfile) bool {
301+ switch profile {
302+ case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "":
303+ return true
304+ default:
305+ return false
306+ }
307+}
A internal/infrastructure/config/profile_test.go +401 -0
@@ -0,0 +1,401 @@
1+package config
2+
3+import (
4+ "runtime"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+func TestResolvePerformanceProfile_Default(t *testing.T) {
11+ cfg := &PerformanceConfig{
12+ Profile: ProfileDefault,
13+ }
14+
15+ result := ResolvePerformanceProfile(cfg, nil)
16+
17+ // Default should have no tuning (unset values)
18+ if result.SkiaCPUPaintingThreads != 0 {
19+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
20+ }
21+ if result.SkiaGPUPaintingThreads != -1 {
22+ t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads)
23+ }
24+ if result.WebProcessMemoryLimitMB != 0 {
25+ t.Errorf("expected WebProcessMemoryLimitMB=0, got %d", result.WebProcessMemoryLimitMB)
26+ }
27+ if result.WebViewPoolPrewarmCount != 4 {
28+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
29+ }
30+}
31+
32+func TestResolvePerformanceProfile_Empty(t *testing.T) {
33+ cfg := &PerformanceConfig{
34+ Profile: "", // Empty should behave like default
35+ }
36+
37+ result := ResolvePerformanceProfile(cfg, nil)
38+
39+ if result.SkiaCPUPaintingThreads != 0 {
40+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
41+ }
42+ if result.WebViewPoolPrewarmCount != 4 {
43+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
44+ }
45+}
46+
47+func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
48+ cfg := &PerformanceConfig{
49+ Profile: ProfileLite,
50+ }
51+
52+ result := ResolvePerformanceProfile(cfg, nil)
53+
54+ // Lite without hardware info should use default lite values
55+ if result.SkiaCPUPaintingThreads != 2 {
56+ t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads)
57+ }
58+ if result.WebProcessMemoryLimitMB != 768 {
59+ t.Errorf("expected WebProcessMemoryLimitMB=768, got %d", result.WebProcessMemoryLimitMB)
60+ }
61+ if result.NetworkProcessMemoryLimitMB != 384 {
62+ t.Errorf("expected NetworkProcessMemoryLimitMB=384, got %d", result.NetworkProcessMemoryLimitMB)
63+ }
64+ if result.WebViewPoolPrewarmCount != 2 {
65+ t.Errorf("expected WebViewPoolPrewarmCount=2, got %d", result.WebViewPoolPrewarmCount)
66+ }
67+}
68+
69+func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) {
70+ cfg := &PerformanceConfig{
71+ Profile: ProfileLite,
72+ }
73+ hw := &port.HardwareInfo{
74+ CPUCores: 2, // Very low core count
75+ CPUThreads: 4,
76+ }
77+
78+ result := ResolvePerformanceProfile(cfg, hw)
79+
80+ // Lite on low-core system should scale down
81+ if result.SkiaCPUPaintingThreads != 1 {
82+ t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads)
83+ }
84+}
85+
86+func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) {
87+ cfg := &PerformanceConfig{
88+ Profile: ProfileMax,
89+ }
90+
91+ result := ResolvePerformanceProfile(cfg, nil)
92+
93+ // Max without hardware info should use fallback values
94+ // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8]
95+ expectedCPUThreads := runtime.NumCPU() / 2
96+ if expectedCPUThreads < 4 {
97+ expectedCPUThreads = 4
98+ }
99+ if expectedCPUThreads > 8 {
100+ expectedCPUThreads = 8
101+ }
102+ if result.SkiaCPUPaintingThreads != expectedCPUThreads {
103+ t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads)
104+ }
105+ // GPU threads: conservative default when no VRAM info
106+ if result.SkiaGPUPaintingThreads != 2 {
107+ t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads)
108+ }
109+ // Max profile: no memory limits (unset)
110+ if result.WebProcessMemoryLimitMB != 0 {
111+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset), got %d", result.WebProcessMemoryLimitMB)
112+ }
113+ if result.WebViewPoolPrewarmCount != 8 {
114+ t.Errorf("expected WebViewPoolPrewarmCount=8, got %d", result.WebViewPoolPrewarmCount)
115+ }
116+}
117+
118+func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) {
119+ cfg := &PerformanceConfig{
120+ Profile: ProfileMax,
121+ }
122+ hw := &port.HardwareInfo{
123+ CPUCores: 16,
124+ CPUThreads: 32,
125+ TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB
126+ VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU)
127+ GPUVendor: port.GPUVendorAMD,
128+ }
129+
130+ result := ResolvePerformanceProfile(cfg, hw)
131+
132+ // CPU threads: use physical cores capped at 8 (WebKitGTK limit)
133+ if result.SkiaCPUPaintingThreads != 8 {
134+ t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads)
135+ }
136+ // GPU threads: high-end tier (16GB+ VRAM)
137+ if result.SkiaGPUPaintingThreads != 8 {
138+ t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads)
139+ }
140+ // Max profile: no memory limits (unset)
141+ if result.WebProcessMemoryLimitMB != 0 {
142+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB)
143+ }
144+ if result.NetworkProcessMemoryLimitMB != 0 {
145+ t.Errorf("expected NetworkProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.NetworkProcessMemoryLimitMB)
146+ }
147+ // Pool prewarm: high-end tier
148+ if result.WebViewPoolPrewarmCount != 12 {
149+ t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount)
150+ }
151+}
152+
153+func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) {
154+ cfg := &PerformanceConfig{
155+ Profile: ProfileMax,
156+ }
157+ hw := &port.HardwareInfo{
158+ CPUCores: 8,
159+ CPUThreads: 16,
160+ TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB
161+ VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU)
162+ GPUVendor: port.GPUVendorNVIDIA,
163+ }
164+
165+ result := ResolvePerformanceProfile(cfg, hw)
166+
167+ // CPU threads: use physical cores (8)
168+ if result.SkiaCPUPaintingThreads != 8 {
169+ t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads)
170+ }
171+ // GPU threads: mid-range tier (8-16GB VRAM)
172+ if result.SkiaGPUPaintingThreads != 6 {
173+ t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads)
174+ }
175+ // Max profile: no memory limits (unset)
176+ if result.WebProcessMemoryLimitMB != 0 {
177+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB)
178+ }
179+ // Pool prewarm: mid-range tier
180+ if result.WebViewPoolPrewarmCount != 8 {
181+ t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount)
182+ }
183+}
184+
185+func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) {
186+ cfg := &PerformanceConfig{
187+ Profile: ProfileMax,
188+ }
189+ hw := &port.HardwareInfo{
190+ CPUCores: 4,
191+ CPUThreads: 4,
192+ TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB
193+ VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete)
194+ GPUVendor: port.GPUVendorIntel,
195+ }
196+
197+ result := ResolvePerformanceProfile(cfg, hw)
198+
199+ // CPU threads: use physical cores (4)
200+ if result.SkiaCPUPaintingThreads != 4 {
201+ t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads)
202+ }
203+ // GPU threads: low tier (<4GB VRAM)
204+ if result.SkiaGPUPaintingThreads != 2 {
205+ t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads)
206+ }
207+ // Max profile: no memory limits (unset)
208+ if result.WebProcessMemoryLimitMB != 0 {
209+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB)
210+ }
211+ // Pool prewarm: low tier
212+ if result.WebViewPoolPrewarmCount != 4 {
213+ t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount)
214+ }
215+}
216+
217+func TestResolvePerformanceProfile_Custom(t *testing.T) {
218+ cfg := &PerformanceConfig{
219+ Profile: ProfileCustom,
220+ SkiaCPUPaintingThreads: 6,
221+ SkiaGPUPaintingThreads: 3,
222+ SkiaEnableCPURendering: true,
223+ WebProcessMemoryLimitMB: 1024,
224+ WebProcessMemoryConservativeThreshold: 0.4,
225+ WebViewPoolPrewarmCount: 5,
226+ }
227+ // Hardware info should be ignored for custom profile
228+ hw := &port.HardwareInfo{
229+ CPUCores: 16,
230+ CPUThreads: 32,
231+ TotalRAM: 64 * 1024 * 1024 * 1024,
232+ VRAM: 16 * 1024 * 1024 * 1024,
233+ }
234+
235+ result := ResolvePerformanceProfile(cfg, hw)
236+
237+ // Custom should use user-specified values, ignoring hardware
238+ if result.SkiaCPUPaintingThreads != 6 {
239+ t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads)
240+ }
241+ if result.SkiaGPUPaintingThreads != 3 {
242+ t.Errorf("expected SkiaGPUPaintingThreads=3, got %d", result.SkiaGPUPaintingThreads)
243+ }
244+ if !result.SkiaEnableCPURendering {
245+ t.Error("expected SkiaEnableCPURendering=true")
246+ }
247+ if result.WebProcessMemoryLimitMB != 1024 {
248+ t.Errorf("expected WebProcessMemoryLimitMB=1024, got %d", result.WebProcessMemoryLimitMB)
249+ }
250+ if result.WebViewPoolPrewarmCount != 5 {
251+ t.Errorf("expected WebViewPoolPrewarmCount=5, got %d", result.WebViewPoolPrewarmCount)
252+ }
253+}
254+
255+func TestHasCustomPerformanceFields(t *testing.T) {
256+ tests := []struct {
257+ name string
258+ cfg PerformanceConfig
259+ expected bool
260+ }{
261+ {
262+ name: "all defaults - no custom fields",
263+ cfg: PerformanceConfig{
264+ SkiaCPUPaintingThreads: 0,
265+ SkiaGPUPaintingThreads: -1,
266+ SkiaEnableCPURendering: false,
267+ },
268+ expected: false,
269+ },
270+ {
271+ name: "skia cpu threads set",
272+ cfg: PerformanceConfig{
273+ SkiaCPUPaintingThreads: 4,
274+ SkiaGPUPaintingThreads: -1,
275+ },
276+ expected: true,
277+ },
278+ {
279+ name: "memory limit set",
280+ cfg: PerformanceConfig{
281+ SkiaGPUPaintingThreads: -1,
282+ WebProcessMemoryLimitMB: 1024,
283+ },
284+ expected: true,
285+ },
286+ {
287+ name: "conservative threshold set",
288+ cfg: PerformanceConfig{
289+ SkiaGPUPaintingThreads: -1,
290+ WebProcessMemoryConservativeThreshold: 0.4,
291+ },
292+ expected: true,
293+ },
294+ }
295+
296+ for _, tt := range tests {
297+ t.Run(tt.name, func(t *testing.T) {
298+ result := HasCustomPerformanceFields(&tt.cfg)
299+ if result != tt.expected {
300+ t.Errorf("expected %v, got %v", tt.expected, result)
301+ }
302+ })
303+ }
304+}
305+
306+func TestIsValidPerformanceProfile(t *testing.T) {
307+ tests := []struct {
308+ profile PerformanceProfile
309+ expected bool
310+ }{
311+ {ProfileDefault, true},
312+ {ProfileLite, true},
313+ {ProfileMax, true},
314+ {ProfileCustom, true},
315+ {"", true}, // Empty is valid (treated as default)
316+ {"invalid", false},
317+ {"LITE", false}, // Case sensitive
318+ {"Maximum", false},
319+ }
320+
321+ for _, tt := range tests {
322+ t.Run(string(tt.profile), func(t *testing.T) {
323+ result := IsValidPerformanceProfile(tt.profile)
324+ if result != tt.expected {
325+ t.Errorf("IsValidPerformanceProfile(%q): expected %v, got %v", tt.profile, tt.expected, result)
326+ }
327+ })
328+ }
329+}
330+
331+func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) {
332+ tests := []struct {
333+ name string
334+ vramGB int
335+ expected int
336+ }{
337+ {"no VRAM info", 0, 2},
338+ {"2GB VRAM (integrated)", 2, 2},
339+ {"4GB VRAM (entry discrete)", 4, 4},
340+ {"6GB VRAM (low-mid discrete)", 6, 4},
341+ {"8GB VRAM (mid discrete)", 8, 6},
342+ {"12GB VRAM (mid-high discrete)", 12, 6},
343+ {"16GB VRAM (high-end)", 16, 8},
344+ {"24GB VRAM (workstation)", 24, 8},
345+ }
346+
347+ for _, tt := range tests {
348+ t.Run(tt.name, func(t *testing.T) {
349+ hw := &port.HardwareInfo{
350+ VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024,
351+ }
352+ if tt.vramGB == 0 {
353+ hw = nil
354+ }
355+ result := computeMaxGPUThreads(hw)
356+ if result != tt.expected {
357+ t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result)
358+ }
359+ })
360+ }
361+}
362+
363+func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) {
364+ tests := []struct {
365+ name string
366+ ramGB int
367+ expectedWeb int
368+ expectedNet int
369+ expectedPool int
370+ }{
371+ {"no RAM info", 0, 2048, 512, 8},
372+ {"4GB RAM", 4, 1024, 256, 4},
373+ {"8GB RAM", 8, 2048, 512, 6},
374+ {"16GB RAM", 16, 3072, 768, 8},
375+ {"32GB RAM", 32, 4096, 1024, 12},
376+ {"64GB RAM", 64, 4096, 1024, 12},
377+ }
378+
379+ for _, tt := range tests {
380+ t.Run(tt.name, func(t *testing.T) {
381+ hw := &port.HardwareInfo{
382+ TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024,
383+ }
384+ if tt.ramGB == 0 {
385+ hw = nil
386+ }
387+ webMB, netMB := computeMaxMemoryLimits(hw)
388+ poolPrewarm := computeMaxPoolPrewarm(hw)
389+
390+ if webMB != tt.expectedWeb {
391+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB)
392+ }
393+ if netMB != tt.expectedNet {
394+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB)
395+ }
396+ if poolPrewarm != tt.expectedPool {
397+ t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm)
398+ }
399+ })
400+ }
401+}
M internal/infrastructure/config/schema.go +70 -2
@@ -125,6 +125,20 @@ const (
125125 GLRenderingModeNone GLRenderingMode = "none"
126126 )
127127
128+// PerformanceProfile selects preset performance tuning settings.
129+type PerformanceProfile string
130+
131+const (
132+ // ProfileDefault uses WebKit defaults (no tuning applied).
133+ ProfileDefault PerformanceProfile = "default"
134+ // ProfileLite reduces resource usage for low-RAM systems.
135+ ProfileLite PerformanceProfile = "lite"
136+ // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).
137+ ProfileMax PerformanceProfile = "max"
138+ // ProfileCustom allows full control over individual performance settings.
139+ ProfileCustom PerformanceProfile = "custom"
140+)
141+
128142 // ThemeDefault is the default theme setting (follows system).
129143 const ThemeDefault = "default"
130144
@@ -181,6 +195,12 @@ type HistoryConfig struct {
181195 // PerformanceConfig holds internal performance tuning options.
182196 // These are not exposed in dumb://config UI but can be set in config file.
183197 type PerformanceConfig struct {
198+ // Profile selects a preset performance configuration.
199+ // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control)
200+ // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.
201+ // Default: "default"
202+ Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`
203+
184204 // ZoomCacheSize is the number of domain zoom levels to cache in memory.
185205 // Higher values reduce database queries but use more memory.
186206 // Default: 256 domains (~20KB memory)
@@ -188,8 +208,56 @@ type PerformanceConfig struct {
188208
189209 // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
190210 // Higher values speed up initial tab creation but use more memory.
191- // Default: 4
211+ // Default: 4 (custom mode), computed from profile otherwise
192212 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
213+
214+ // --- Skia rendering threads (env vars) ---
215+ // NOTE: Fields below only apply when Profile is "custom".
216+ // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS.
217+ // Default: 0 (unset, uses WebKit default)
218+ SkiaCPUPaintingThreads int `mapstructure:"skia_cpu_painting_threads" yaml:"skia_cpu_painting_threads" toml:"skia_cpu_painting_threads"` //nolint:lll // struct tags must stay on one line
219+
220+ // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS.
221+ // Default: -1 (unset). Value 0 disables GPU tile painting.
222+ SkiaGPUPaintingThreads int `mapstructure:"skia_gpu_painting_threads" yaml:"skia_gpu_painting_threads" toml:"skia_gpu_painting_threads"` //nolint:lll // struct tags must stay on one line
223+
224+ // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1.
225+ // Default: false
226+ SkiaEnableCPURendering bool `mapstructure:"skia_enable_cpu_rendering" yaml:"skia_enable_cpu_rendering" toml:"skia_enable_cpu_rendering"` //nolint:lll // struct tags must stay on one line
227+
228+ // --- Web process memory pressure ---
229+ // WebProcessMemoryLimitMB sets memory limit in MB for web processes.
230+ // Default: 0 (unset, uses WebKit default: system RAM capped at 3GB)
231+ WebProcessMemoryLimitMB int `mapstructure:"web_process_memory_limit_mb" yaml:"web_process_memory_limit_mb" toml:"web_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line
232+
233+ // WebProcessMemoryPollIntervalSec sets poll interval for memory checks.
234+ // Default: 0 (unset, uses WebKit default: 30 seconds)
235+ WebProcessMemoryPollIntervalSec float64 `mapstructure:"web_process_memory_poll_interval_sec" yaml:"web_process_memory_poll_interval_sec" toml:"web_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line
236+
237+ // WebProcessMemoryConservativeThreshold sets threshold for conservative memory release.
238+ // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.33)
239+ WebProcessMemoryConservativeThreshold float64 `mapstructure:"web_process_memory_conservative_threshold" yaml:"web_process_memory_conservative_threshold" toml:"web_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line
240+
241+ // WebProcessMemoryStrictThreshold sets threshold for strict memory release.
242+ // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.5)
243+ WebProcessMemoryStrictThreshold float64 `mapstructure:"web_process_memory_strict_threshold" yaml:"web_process_memory_strict_threshold" toml:"web_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line
244+
245+ // --- Network process memory pressure ---
246+ // NetworkProcessMemoryLimitMB sets memory limit in MB for network process.
247+ // Default: 0 (unset)
248+ NetworkProcessMemoryLimitMB int `mapstructure:"network_process_memory_limit_mb" yaml:"network_process_memory_limit_mb" toml:"network_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line
249+
250+ // NetworkProcessMemoryPollIntervalSec sets poll interval for memory checks.
251+ // Default: 0 (unset)
252+ NetworkProcessMemoryPollIntervalSec float64 `mapstructure:"network_process_memory_poll_interval_sec" yaml:"network_process_memory_poll_interval_sec" toml:"network_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line
253+
254+ // NetworkProcessMemoryConservativeThreshold sets threshold for conservative memory release.
255+ // Valid: (0, 1). Default: 0 (unset)
256+ NetworkProcessMemoryConservativeThreshold float64 `mapstructure:"network_process_memory_conservative_threshold" yaml:"network_process_memory_conservative_threshold" toml:"network_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line
257+
258+ // NetworkProcessMemoryStrictThreshold sets threshold for strict memory release.
259+ // Valid: (0, 1). Default: 0 (unset)
260+ NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line
193261 }
194262
195263 // SearchShortcut represents a search shortcut configuration.
@@ -210,7 +278,7 @@ func (c *Config) ShortcutURLs() map[string]string {
210278
211279 // DmenuConfig holds dmenu/rofi integration configuration.
212280 type DmenuConfig struct {
213- MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
281+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
214282 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
215283 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
216284 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
M internal/infrastructure/config/schema_provider.go +3 -3
@@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo {
264264 func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {
265265 return []entity.ConfigKeyInfo{
266266 {
267- Key: "dmenu.max_history_items",
267+ Key: "dmenu.max_history_days",
268268 Type: "int",
269- Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems),
270- Description: "Maximum history items shown in dmenu",
269+ Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays),
270+ Description: "Number of days of history to show in dmenu (0 = all)",
271271 Range: ">=0",
272272 Section: SectionDmenu,
273273 },
M internal/infrastructure/config/validation.go +47 -2
@@ -28,6 +28,7 @@ func validateConfig(config *Config) error {
2828 validationErrors = append(validationErrors, validateRendering(config)...)
2929 validationErrors = append(validationErrors, validateColorScheme(config)...)
3030 validationErrors = append(validationErrors, validateSession(config)...)
31+ validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
3132
3233 // If there are validation errors, return them
3334 if len(validationErrors) > 0 {
@@ -52,8 +53,8 @@ func validateHistory(config *Config) []string {
5253 }
5354
5455 func validateDmenu(config *Config) []string {
55- if config.Dmenu.MaxHistoryItems < 0 {
56- return []string{"dmenu.max_history_items must be non-negative"}
56+ if config.Dmenu.MaxHistoryDays < 0 {
57+ return []string{"dmenu.max_history_days must be non-negative"}
5758 }
5859 return nil
5960 }
@@ -347,3 +348,47 @@ func validateSession(config *Config) []string {
347348 }
348349 return validationErrors
349350 }
351+
352+func validatePerformanceProfile(config *Config) []string {
353+ var validationErrors []string
354+
355+ // Validate profile name
356+ if !IsValidPerformanceProfile(config.Performance.Profile) {
357+ validationErrors = append(validationErrors, fmt.Sprintf(
358+ "performance.profile must be one of: default, lite, max, custom (got: %s)",
359+ config.Performance.Profile,
360+ ))
361+ }
362+
363+ // When profile is not "custom", warn if individual fields are set
364+ // (they will be ignored in favor of profile-computed values)
365+ if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" {
366+ if HasCustomPerformanceFields(&config.Performance) {
367+ validationErrors = append(validationErrors,
368+ "performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
369+ "set profile = \"custom\" to use individual field values",
370+ )
371+ }
372+ }
373+
374+ // Validate memory pressure threshold ordering: WebKit requires conservative < strict
375+ webCons := config.Performance.WebProcessMemoryConservativeThreshold
376+ webStrict := config.Performance.WebProcessMemoryStrictThreshold
377+ if webCons > 0 && webStrict > 0 && webCons >= webStrict {
378+ validationErrors = append(validationErrors,
379+ "performance.web_process_memory_conservative_threshold must be less than "+
380+ "web_process_memory_strict_threshold when both are set",
381+ )
382+ }
383+
384+ netCons := config.Performance.NetworkProcessMemoryConservativeThreshold
385+ netStrict := config.Performance.NetworkProcessMemoryStrictThreshold
386+ if netCons > 0 && netStrict > 0 && netCons >= netStrict {
387+ validationErrors = append(validationErrors,
388+ "performance.network_process_memory_conservative_threshold must be less than "+
389+ "network_process_memory_strict_threshold when both are set",
390+ )
391+ }
392+
393+ return validationErrors
394+}
M internal/infrastructure/config/webui_gateway.go +32 -0
@@ -15,6 +15,24 @@ func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway {
1515 return &WebUIConfigGateway{mgr: mgr}
1616 }
1717
18+// Validation limits for custom performance fields.
19+const (
20+ maxWebProcessMemoryMB = 16384
21+ maxNetworkProcessMemoryMB = 4096
22+ maxWebViewPoolPrewarm = 20
23+)
24+
25+// clampInt returns v clamped to [lo, hi].
26+func clampInt(v, lo, hi int) int {
27+ if v < lo {
28+ return lo
29+ }
30+ if v > hi {
31+ return hi
32+ }
33+ return v
34+}
35+
1836 func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
1937 _ = ctx
2038 if g == nil || g.mgr == nil {
@@ -60,5 +78,19 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUI
6078 current.SearchShortcuts = shortcuts
6179 }
6280
81+ // Performance profile (requires restart to take effect)
82+ if cfg.Performance.Profile != "" {
83+ current.Performance.Profile = PerformanceProfile(cfg.Performance.Profile)
84+ }
85+
86+ // Custom performance fields (only used when profile is "custom")
87+ if cfg.Performance.Profile == string(ProfileCustom) {
88+ current.Performance.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads)
89+ current.Performance.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads)
90+ current.Performance.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB)
91+ current.Performance.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB)
92+ current.Performance.WebViewPoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm)
93+ }
94+
6395 return g.mgr.Save(current)
6496 }
A internal/infrastructure/env/hardware_survey.go +349 -0
@@ -0,0 +1,349 @@
1+package env
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strconv"
10+ "strings"
11+ "sync"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/logging"
15+)
16+
17+const (
18+ // kibibyte is 1024 bytes (/proc/meminfo reports in kB which is actually KiB)
19+ kibibyte = 1024
20+)
21+
22+// HardwareSurveyor implements port.HardwareSurveyor for Linux systems.
23+// It is safe for concurrent use after creation.
24+type HardwareSurveyor struct {
25+ once sync.Once
26+ cached port.HardwareInfo
27+}
28+
29+// NewHardwareSurveyor creates a new hardware surveyor.
30+func NewHardwareSurveyor() *HardwareSurveyor {
31+ return &HardwareSurveyor{}
32+}
33+
34+// Survey detects and returns hardware information.
35+// Results are cached after the first call. Safe for concurrent use.
36+func (s *HardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
37+ s.once.Do(func() {
38+ s.cached = s.doSurvey(ctx)
39+ })
40+ return s.cached
41+}
42+
43+// doSurvey performs the actual hardware detection.
44+func (s *HardwareSurveyor) doSurvey(ctx context.Context) port.HardwareInfo {
45+ log := logging.FromContext(ctx)
46+
47+ info := port.HardwareInfo{
48+ CPUThreads: runtime.NumCPU(), // Go's NumCPU returns logical threads
49+ }
50+
51+ // Detect physical CPU cores
52+ info.CPUCores = s.detectCPUCores(ctx)
53+ if info.CPUCores == 0 {
54+ info.CPUCores = info.CPUThreads // Fallback to threads if cores unknown
55+ }
56+
57+ // Detect RAM
58+ info.TotalRAM, info.AvailableRAM = s.detectMemory(ctx)
59+
60+ // Detect GPU
61+ info.GPUVendor, info.GPUName, info.VRAM = s.detectGPU(ctx)
62+
63+ log.Debug().
64+ Int("cpu_cores", info.CPUCores).
65+ Int("cpu_threads", info.CPUThreads).
66+ Uint64("total_ram_mb", info.TotalRAM/(1024*1024)).
67+ Uint64("available_ram_mb", info.AvailableRAM/(1024*1024)).
68+ Str("gpu_vendor", string(info.GPUVendor)).
69+ Str("gpu_name", info.GPUName).
70+ Uint64("vram_mb", info.VRAM/(1024*1024)).
71+ Msg("hardware survey completed")
72+
73+ return info
74+}
75+
76+// detectCPUCores returns the number of physical CPU cores.
77+// Reads from /sys/devices/system/cpu/cpu*/topology/core_id to count unique cores.
78+func (s *HardwareSurveyor) detectCPUCores(ctx context.Context) int {
79+ log := logging.FromContext(ctx)
80+
81+ // Method 1: Count unique core_ids across all CPUs
82+ coreIDs := make(map[string]struct{})
83+ cpuPath := "/sys/devices/system/cpu"
84+
85+ entries, err := os.ReadDir(cpuPath)
86+ if err != nil {
87+ log.Debug().Err(err).Msg("cannot read /sys/devices/system/cpu")
88+ return 0
89+ }
90+
91+ for _, entry := range entries {
92+ if !strings.HasPrefix(entry.Name(), "cpu") {
93+ continue
94+ }
95+ // Skip non-numeric cpu entries (like cpufreq, cpuidle)
96+ cpuNum := strings.TrimPrefix(entry.Name(), "cpu")
97+ if _, err := strconv.Atoi(cpuNum); err != nil {
98+ continue
99+ }
100+
101+ coreIDPath := filepath.Join(cpuPath, entry.Name(), "topology", "core_id")
102+ data, err := os.ReadFile(coreIDPath)
103+ if err != nil {
104+ continue
105+ }
106+ coreID := strings.TrimSpace(string(data))
107+ coreIDs[coreID] = struct{}{}
108+ }
109+
110+ if len(coreIDs) > 0 {
111+ return len(coreIDs)
112+ }
113+
114+ // Method 2: Parse /proc/cpuinfo for "cpu cores" field
115+ return s.detectCPUCoresFromProcCPUInfo(ctx)
116+}
117+
118+// detectCPUCoresFromProcCPUInfo parses /proc/cpuinfo to get core count.
119+func (*HardwareSurveyor) detectCPUCoresFromProcCPUInfo(ctx context.Context) int {
120+ log := logging.FromContext(ctx)
121+
122+ file, err := os.Open("/proc/cpuinfo")
123+ if err != nil {
124+ log.Debug().Err(err).Msg("cannot read /proc/cpuinfo")
125+ return 0
126+ }
127+ defer func() { _ = file.Close() }()
128+
129+ scanner := bufio.NewScanner(file)
130+ for scanner.Scan() {
131+ line := scanner.Text()
132+ if strings.HasPrefix(line, "cpu cores") {
133+ parts := strings.Split(line, ":")
134+ if len(parts) == 2 {
135+ cores, err := strconv.Atoi(strings.TrimSpace(parts[1]))
136+ if err == nil {
137+ return cores
138+ }
139+ }
140+ }
141+ }
142+ return 0
143+}
144+
145+// detectMemory reads memory info from /proc/meminfo.
146+func (s *HardwareSurveyor) detectMemory(ctx context.Context) (total, available uint64) {
147+ log := logging.FromContext(ctx)
148+
149+ file, err := os.Open("/proc/meminfo")
150+ if err != nil {
151+ log.Debug().Err(err).Msg("cannot read /proc/meminfo")
152+ return 0, 0
153+ }
154+ defer func() { _ = file.Close() }()
155+
156+ scanner := bufio.NewScanner(file)
157+ for scanner.Scan() {
158+ line := scanner.Text()
159+ if strings.HasPrefix(line, "MemTotal:") {
160+ total = s.parseMemInfoLine(line)
161+ } else if strings.HasPrefix(line, "MemAvailable:") {
162+ available = s.parseMemInfoLine(line)
163+ }
164+ if total > 0 && available > 0 {
165+ break
166+ }
167+ }
168+ return total, available
169+}
170+
171+// parseMemInfoLine extracts bytes from a /proc/meminfo line like "MemTotal: 12345 kB".
172+func (*HardwareSurveyor) parseMemInfoLine(line string) uint64 {
173+ parts := strings.Fields(line)
174+ if len(parts) < 2 {
175+ return 0
176+ }
177+ value, err := strconv.ParseUint(parts[1], 10, 64)
178+ if err != nil {
179+ return 0
180+ }
181+ // /proc/meminfo reports in kB (actually KiB)
182+ return value * kibibyte
183+}
184+
185+// detectGPU detects GPU vendor, name, and VRAM from DRM subsystem.
186+// When multiple GPUs are present (e.g., iGPU + discrete), prefers the one with more VRAM.
187+func (s *HardwareSurveyor) detectGPU(ctx context.Context) (port.GPUVendor, string, uint64) {
188+ log := logging.FromContext(ctx)
189+
190+ const drmBase = "/sys/class/drm"
191+ cards := []string{"card0", "card1", "card2", "card3"}
192+
193+ var bestVendor port.GPUVendor
194+ var bestName string
195+ var bestVRAM uint64
196+ var bestCard string
197+
198+ for _, card := range cards {
199+ cardPath := filepath.Join(drmBase, card)
200+ if _, err := os.Stat(cardPath); os.IsNotExist(err) {
201+ continue
202+ }
203+
204+ vendor := s.detectGPUVendor(cardPath)
205+ if vendor == port.GPUVendorUnknown {
206+ continue
207+ }
208+
209+ name := s.detectGPUName(ctx, cardPath)
210+ vram := s.detectVRAM(ctx, cardPath)
211+
212+ log.Debug().
213+ Str("card", card).
214+ Str("vendor", string(vendor)).
215+ Str("name", name).
216+ Uint64("vram_mb", vram/(1024*1024)).
217+ Msg("found GPU")
218+
219+ // Prefer GPU with more VRAM (discrete > integrated)
220+ if vram > bestVRAM {
221+ bestVendor = vendor
222+ bestName = name
223+ bestVRAM = vram
224+ bestCard = card
225+ }
226+ }
227+
228+ if bestVendor != port.GPUVendorUnknown {
229+ log.Debug().
230+ Str("card", bestCard).
231+ Str("vendor", string(bestVendor)).
232+ Uint64("vram_mb", bestVRAM/(1024*1024)).
233+ Msg("selected GPU (highest VRAM)")
234+ }
235+
236+ return bestVendor, bestName, bestVRAM
237+}
238+
239+// detectGPUVendor reads the PCI vendor ID.
240+func (*HardwareSurveyor) detectGPUVendor(cardPath string) port.GPUVendor {
241+ vendorPath := filepath.Join(cardPath, "device", "vendor")
242+ data, err := os.ReadFile(vendorPath)
243+ if err != nil {
244+ return port.GPUVendorUnknown
245+ }
246+
247+ vendorID := strings.TrimSpace(string(data))
248+ switch vendorID {
249+ case "0x1002":
250+ return port.GPUVendorAMD
251+ case "0x8086":
252+ return port.GPUVendorIntel
253+ case "0x10de":
254+ return port.GPUVendorNVIDIA
255+ default:
256+ return port.GPUVendorUnknown
257+ }
258+}
259+
260+// detectGPUName reads the GPU product name from various sources.
261+func (*HardwareSurveyor) detectGPUName(ctx context.Context, cardPath string) string {
262+ log := logging.FromContext(ctx)
263+
264+ // Try reading from device/label first (some drivers provide this)
265+ labelPath := filepath.Join(cardPath, "device", "label")
266+ if data, err := os.ReadFile(labelPath); err == nil {
267+ name := strings.TrimSpace(string(data))
268+ if name != "" {
269+ return name
270+ }
271+ }
272+
273+ // Try reading product name from device subsystem (uevent)
274+ ueventPath := filepath.Join(cardPath, "device", "uevent")
275+ if file, err := os.Open(ueventPath); err == nil {
276+ scanner := bufio.NewScanner(file)
277+ for scanner.Scan() {
278+ line := scanner.Text()
279+ if strings.HasPrefix(line, "PCI_SLOT_NAME=") {
280+ // This gives us the PCI address, useful for debugging
281+ log.Debug().Str("pci_slot", strings.TrimPrefix(line, "PCI_SLOT_NAME=")).Msg("GPU PCI slot")
282+ }
283+ }
284+ _ = file.Close()
285+ }
286+
287+ // For AMD, try reading from /sys/kernel/debug/dri/*/name (requires root usually)
288+ // For now, return empty and rely on vendor detection
289+ return ""
290+}
291+
292+// detectVRAM reads VRAM size from DRM memory info.
293+func (s *HardwareSurveyor) detectVRAM(ctx context.Context, cardPath string) uint64 {
294+ log := logging.FromContext(ctx)
295+
296+ // Method 1: AMD uses mem_info_vram_total in device directory
297+ amdVRAMPath := filepath.Join(cardPath, "device", "mem_info_vram_total")
298+ if data, err := os.ReadFile(amdVRAMPath); err == nil {
299+ vram, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
300+ if err == nil && vram > 0 {
301+ log.Debug().Uint64("vram_bytes", vram).Msg("detected VRAM from AMD mem_info_vram_total")
302+ return vram
303+ }
304+ }
305+
306+ // Method 2: Try reading from resource file (PCI BAR sizes)
307+ // The first non-zero memory region is usually VRAM
308+ if vram := s.detectVRAMFromPCIResource(ctx, cardPath); vram > 0 {
309+ return vram
310+ }
311+
312+ // Method 3: For Intel integrated, check i915 specific paths
313+ // Intel iGPU uses system RAM, so VRAM is usually 0 or shared
314+ return 0
315+}
316+
317+// detectVRAMFromPCIResource tries to detect VRAM from PCI BAR sizes.
318+func (*HardwareSurveyor) detectVRAMFromPCIResource(ctx context.Context, cardPath string) uint64 {
319+ log := logging.FromContext(ctx)
320+
321+ resourcePath := filepath.Join(cardPath, "device", "resource")
322+ file, err := os.Open(resourcePath)
323+ if err != nil {
324+ return 0
325+ }
326+ defer func() { _ = file.Close() }()
327+
328+ const minVRAMSize = 256 * 1024 * 1024 // 256MB - skip smaller I/O regions
329+ scanner := bufio.NewScanner(file)
330+ for scanner.Scan() {
331+ line := scanner.Text()
332+ parts := strings.Fields(line)
333+ if len(parts) >= 2 {
334+ start, err1 := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 64)
335+ end, err2 := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 64)
336+ if err1 == nil && err2 == nil && start > 0 && end > start {
337+ size := end - start + 1
338+ // VRAM is typically > 256MB, skip smaller regions (I/O, config)
339+ if size > minVRAMSize {
340+ log.Debug().Uint64("vram_bytes", size).Msg("detected VRAM from PCI resource")
341+ return size
342+ }
343+ }
344+ }
345+ }
346+ return 0
347+}
348+
349+var _ port.HardwareSurveyor = (*HardwareSurveyor)(nil)
M internal/infrastructure/env/manager.go +18 -0
@@ -54,6 +54,7 @@ func (m *Manager) ApplyEnvironment(ctx context.Context, settings port.RenderingE
5454 m.applyWebKitEnv(settings)
5555 m.applyGTKEnv(settings)
5656 m.applyDebugEnv(settings)
57+ m.applySkiaEnv(settings)
5758
5859 log.Debug().
5960 Interface("vars", m.appliedVars).
@@ -162,6 +163,23 @@ func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) {
162163 }
163164 }
164165
166+func (m *Manager) applySkiaEnv(settings port.RenderingEnvSettings) {
167+ // CPU painting threads: only set if > 0 and env var not already set
168+ if settings.SkiaCPUPaintingThreads > 0 && os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") == "" {
169+ m.setEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaCPUPaintingThreads))
170+ }
171+
172+ // GPU painting threads: only set if >= 0 (0 is valid: disables GPU tile painting)
173+ if settings.SkiaGPUPaintingThreads >= 0 && os.Getenv("WEBKIT_SKIA_GPU_PAINTING_THREADS") == "" {
174+ m.setEnv("WEBKIT_SKIA_GPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaGPUPaintingThreads))
175+ }
176+
177+ // CPU rendering: only set if true and env var not already set
178+ if settings.SkiaEnableCPURendering && os.Getenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING") == "" {
179+ m.setEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", "1")
180+ }
181+}
182+
165183 func (m *Manager) setEnv(key, value string) {
166184 _ = os.Setenv(key, value)
167185 m.appliedVars[key] = value
A internal/infrastructure/env/manager_test.go +172 -0
@@ -0,0 +1,172 @@
1+package env_test
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/infrastructure/env"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
15+ // Save original env vars and restore after test
16+ origCPU := os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS")
17+ origGPU := os.Getenv("WEBKIT_SKIA_GPU_PAINTING_THREADS")
18+ origCPURender := os.Getenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING")
19+ t.Cleanup(func() {
20+ restoreEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", origCPU)
21+ restoreEnv("WEBKIT_SKIA_GPU_PAINTING_THREADS", origGPU)
22+ restoreEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", origCPURender)
23+ })
24+
25+ tests := []struct {
26+ name string
27+ settings port.RenderingEnvSettings
28+ presetEnv map[string]string
29+ expectedVars map[string]string
30+ unexpectedVars []string
31+ }{
32+ {
33+ name: "CPU threads set when > 0",
34+ settings: port.RenderingEnvSettings{
35+ SkiaCPUPaintingThreads: 4,
36+ SkiaGPUPaintingThreads: -1, // unset
37+ },
38+ expectedVars: map[string]string{
39+ "WEBKIT_SKIA_CPU_PAINTING_THREADS": "4",
40+ },
41+ unexpectedVars: []string{"WEBKIT_SKIA_GPU_PAINTING_THREADS"},
42+ },
43+ {
44+ name: "GPU threads set when >= 0",
45+ settings: port.RenderingEnvSettings{
46+ SkiaCPUPaintingThreads: 0, // unset
47+ SkiaGPUPaintingThreads: 2,
48+ },
49+ expectedVars: map[string]string{
50+ "WEBKIT_SKIA_GPU_PAINTING_THREADS": "2",
51+ },
52+ unexpectedVars: []string{"WEBKIT_SKIA_CPU_PAINTING_THREADS"},
53+ },
54+ {
55+ name: "GPU threads 0 disables GPU tile painting",
56+ settings: port.RenderingEnvSettings{
57+ SkiaGPUPaintingThreads: 0,
58+ },
59+ expectedVars: map[string]string{
60+ "WEBKIT_SKIA_GPU_PAINTING_THREADS": "0",
61+ },
62+ },
63+ {
64+ name: "CPU rendering enabled",
65+ settings: port.RenderingEnvSettings{
66+ SkiaEnableCPURendering: true,
67+ },
68+ expectedVars: map[string]string{
69+ "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "1",
70+ },
71+ },
72+ {
73+ name: "CPU rendering disabled (default)",
74+ settings: port.RenderingEnvSettings{
75+ SkiaEnableCPURendering: false,
76+ },
77+ unexpectedVars: []string{"WEBKIT_SKIA_ENABLE_CPU_RENDERING"},
78+ },
79+ {
80+ name: "does not override existing CPU threads env var",
81+ settings: port.RenderingEnvSettings{
82+ SkiaCPUPaintingThreads: 8,
83+ },
84+ presetEnv: map[string]string{
85+ "WEBKIT_SKIA_CPU_PAINTING_THREADS": "2",
86+ },
87+ expectedVars: map[string]string{
88+ "WEBKIT_SKIA_CPU_PAINTING_THREADS": "2", // keeps original
89+ },
90+ },
91+ {
92+ name: "does not override existing GPU threads env var",
93+ settings: port.RenderingEnvSettings{
94+ SkiaGPUPaintingThreads: 4,
95+ },
96+ presetEnv: map[string]string{
97+ "WEBKIT_SKIA_GPU_PAINTING_THREADS": "1",
98+ },
99+ expectedVars: map[string]string{
100+ "WEBKIT_SKIA_GPU_PAINTING_THREADS": "1", // keeps original
101+ },
102+ },
103+ {
104+ name: "does not override existing CPU rendering env var",
105+ settings: port.RenderingEnvSettings{
106+ SkiaEnableCPURendering: true,
107+ },
108+ presetEnv: map[string]string{
109+ "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "0",
110+ },
111+ expectedVars: map[string]string{
112+ "WEBKIT_SKIA_ENABLE_CPU_RENDERING": "0", // keeps original
113+ },
114+ },
115+ {
116+ name: "all unset by default",
117+ settings: port.RenderingEnvSettings{
118+ SkiaCPUPaintingThreads: 0, // unset
119+ SkiaGPUPaintingThreads: -1, // unset
120+ SkiaEnableCPURendering: false,
121+ },
122+ unexpectedVars: []string{
123+ "WEBKIT_SKIA_CPU_PAINTING_THREADS",
124+ "WEBKIT_SKIA_GPU_PAINTING_THREADS",
125+ "WEBKIT_SKIA_ENABLE_CPU_RENDERING",
126+ },
127+ },
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ // Clear env vars before each test
133+ os.Unsetenv("WEBKIT_SKIA_CPU_PAINTING_THREADS")
134+ os.Unsetenv("WEBKIT_SKIA_GPU_PAINTING_THREADS")
135+ os.Unsetenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING")
136+
137+ // Set any preset env vars
138+ for k, v := range tt.presetEnv {
139+ os.Setenv(k, v)
140+ }
141+
142+ manager := env.NewManager()
143+ ctx := context.Background()
144+
145+ err := manager.ApplyEnvironment(ctx, tt.settings)
146+ require.NoError(t, err)
147+
148+ // Check expected vars are set correctly
149+ for k, v := range tt.expectedVars {
150+ actual := os.Getenv(k)
151+ assert.Equal(t, v, actual, "env var %s", k)
152+ }
153+
154+ // Check unexpected vars are not set (or were not set by us)
155+ appliedVars := manager.GetAppliedVars()
156+ for _, k := range tt.unexpectedVars {
157+ _, wasApplied := appliedVars[k]
158+ if tt.presetEnv[k] == "" {
159+ assert.False(t, wasApplied, "env var %s should not be applied", k)
160+ }
161+ }
162+ })
163+ }
164+}
165+
166+func restoreEnv(key, value string) {
167+ if value == "" {
168+ os.Unsetenv(key)
169+ } else {
170+ os.Setenv(key, value)
171+ }
172+}
M internal/infrastructure/persistence/sqlite/history_repo.go +63 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "database/sql"
66 "errors"
7+ "fmt"
78 "time"
89
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -87,6 +88,68 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
8788 return entries, nil
8889 }
8990
91+func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
92+ if days <= 0 {
93+ return nil, fmt.Errorf("days must be positive, got %d", days)
94+ }
95+ // Format: "-N days" for SQLite datetime modifier
96+ daysModifier := fmt.Sprintf("-%d days", days)
97+ rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier)
98+ if err != nil {
99+ return nil, err
100+ }
101+
102+ entries := make([]*entity.HistoryEntry, len(rows))
103+ for i := range rows {
104+ entries[i] = historyFromRow(rows[i])
105+ }
106+ return entries, nil
107+}
108+
109+func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
110+ if days <= 0 {
111+ return nil, fmt.Errorf("days must be positive, got %d", days)
112+ }
113+ // Format: "-N days" for SQLite datetime modifier
114+ daysModifier := fmt.Sprintf("-%d days", days)
115+ rows, err := r.queries.GetMostVisited(ctx, daysModifier)
116+ if err != nil {
117+ return nil, err
118+ }
119+
120+ entries := make([]*entity.HistoryEntry, len(rows))
121+ for i := range rows {
122+ entries[i] = historyFromRow(rows[i])
123+ }
124+ return entries, nil
125+}
126+
127+func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
128+ rows, err := r.queries.GetAllRecentHistory(ctx)
129+ if err != nil {
130+ return nil, err
131+ }
132+
133+ entries := make([]*entity.HistoryEntry, len(rows))
134+ for i := range rows {
135+ entries[i] = historyFromRow(rows[i])
136+ }
137+ return entries, nil
138+}
139+
140+func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
141+ rows, err := r.queries.GetAllMostVisited(ctx)
142+ if err != nil {
143+ return nil, err
144+ }
145+
146+ entries := make([]*entity.HistoryEntry, len(rows))
147+ for i := range rows {
148+ entries[i] = historyFromRow(rows[i])
149+ }
150+ return entries, nil
151+}
152+
90153 func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error {
91154 return r.queries.IncrementVisitCount(ctx, url)
92155 }
A internal/infrastructure/persistence/sqlite/history_repo_test.go +325 -0
@@ -0,0 +1,325 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func historyTestCtx() context.Context {
17+ logger := logging.NewFromConfigValues("debug", "console")
18+ return logging.WithContext(context.Background(), logger)
19+}
20+
21+func TestHistoryRepository_GetRecentSince(t *testing.T) {
22+ ctx := historyTestCtx()
23+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
24+
25+ db, err := sqlite.NewConnection(ctx, dbPath)
26+ require.NoError(t, err)
27+ t.Cleanup(func() { _ = db.Close() })
28+
29+ repo := sqlite.NewHistoryRepository(db)
30+
31+ // Save some history entries
32+ entries := []*entity.HistoryEntry{
33+ {URL: "https://example.com", Title: "Example"},
34+ {URL: "https://github.com", Title: "GitHub"},
35+ {URL: "https://google.com", Title: "Google"},
36+ }
37+
38+ for _, e := range entries {
39+ require.NoError(t, repo.Save(ctx, e))
40+ }
41+
42+ // Get recent since 30 days (all should be included)
43+ results, err := repo.GetRecentSince(ctx, 30)
44+ require.NoError(t, err)
45+ require.Len(t, results, 3)
46+
47+ // Results should be sorted by last_visited DESC
48+ // Since they were all just inserted, order may vary
49+ urls := make(map[string]bool)
50+ for _, r := range results {
51+ urls[r.URL] = true
52+ }
53+ assert.True(t, urls["https://example.com"])
54+ assert.True(t, urls["https://github.com"])
55+ assert.True(t, urls["https://google.com"])
56+}
57+
58+func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59+ ctx := historyTestCtx()
60+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
61+
62+ db, err := sqlite.NewConnection(ctx, dbPath)
63+ require.NoError(t, err)
64+ t.Cleanup(func() { _ = db.Close() })
65+
66+ repo := sqlite.NewHistoryRepository(db)
67+
68+ // Get recent with no data
69+ results, err := repo.GetRecentSince(ctx, 30)
70+ require.NoError(t, err)
71+ assert.Empty(t, results)
72+}
73+
74+func TestHistoryRepository_GetMostVisited(t *testing.T) {
75+ ctx := historyTestCtx()
76+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
77+
78+ db, err := sqlite.NewConnection(ctx, dbPath)
79+ require.NoError(t, err)
80+ t.Cleanup(func() { _ = db.Close() })
81+
82+ repo := sqlite.NewHistoryRepository(db)
83+
84+ // Save entries with different visit counts
85+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87+ entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88+
89+ require.NoError(t, repo.Save(ctx, entry1))
90+ require.NoError(t, repo.Save(ctx, entry2))
91+ require.NoError(t, repo.Save(ctx, entry3))
92+
93+ // Increment visit counts to create different popularities
94+ // github: 5 visits, google: 3 visits, example: 1 visit
95+ for i := 0; i < 4; i++ {
96+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97+ }
98+ for i := 0; i < 2; i++ {
99+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100+ }
101+
102+ // Get most visited
103+ results, err := repo.GetMostVisited(ctx, 30)
104+ require.NoError(t, err)
105+ require.Len(t, results, 3)
106+
107+ // First result should be github (5 visits)
108+ assert.Equal(t, "https://github.com", results[0].URL)
109+ assert.Equal(t, int64(5), results[0].VisitCount)
110+
111+ // Second should be google (3 visits)
112+ assert.Equal(t, "https://google.com", results[1].URL)
113+ assert.Equal(t, int64(3), results[1].VisitCount)
114+
115+ // Third should be example (1 visit)
116+ assert.Equal(t, "https://example.com", results[2].URL)
117+ assert.Equal(t, int64(1), results[2].VisitCount)
118+}
119+
120+func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121+ ctx := historyTestCtx()
122+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
123+
124+ db, err := sqlite.NewConnection(ctx, dbPath)
125+ require.NoError(t, err)
126+ t.Cleanup(func() { _ = db.Close() })
127+
128+ repo := sqlite.NewHistoryRepository(db)
129+
130+ // Get most visited with no data
131+ results, err := repo.GetMostVisited(ctx, 30)
132+ require.NoError(t, err)
133+ assert.Empty(t, results)
134+}
135+
136+func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137+ ctx := historyTestCtx()
138+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
139+
140+ db, err := sqlite.NewConnection(ctx, dbPath)
141+ require.NoError(t, err)
142+ t.Cleanup(func() { _ = db.Close() })
143+
144+ repo := sqlite.NewHistoryRepository(db)
145+
146+ // Save an entry
147+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148+ require.NoError(t, repo.Save(ctx, entry))
149+
150+ // Query with 1 day filter - should include recently added entry
151+ results, err := repo.GetRecentSince(ctx, 1)
152+ require.NoError(t, err)
153+ require.Len(t, results, 1)
154+
155+ // Query with very large days filter - should still work
156+ results, err = repo.GetRecentSince(ctx, 365)
157+ require.NoError(t, err)
158+ require.Len(t, results, 1)
159+}
160+
161+func TestHistoryRepository_CRUD(t *testing.T) {
162+ ctx := historyTestCtx()
163+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
164+
165+ db, err := sqlite.NewConnection(ctx, dbPath)
166+ require.NoError(t, err)
167+ t.Cleanup(func() { _ = db.Close() })
168+
169+ repo := sqlite.NewHistoryRepository(db)
170+
171+ // Create
172+ entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173+ require.NoError(t, repo.Save(ctx, entry))
174+
175+ // Read
176+ found, err := repo.FindByURL(ctx, "https://example.com")
177+ require.NoError(t, err)
178+ require.NotNil(t, found)
179+ assert.Equal(t, "https://example.com", found.URL)
180+ assert.Equal(t, "Example", found.Title)
181+ assert.Equal(t, int64(1), found.VisitCount)
182+
183+ // Update (save again increments visit count)
184+ require.NoError(t, repo.Save(ctx, entry))
185+ found2, err := repo.FindByURL(ctx, "https://example.com")
186+ require.NoError(t, err)
187+ assert.Equal(t, int64(2), found2.VisitCount)
188+
189+ // Get recent
190+ recent, err := repo.GetRecent(ctx, 10, 0)
191+ require.NoError(t, err)
192+ require.Len(t, recent, 1)
193+
194+ // Delete
195+ require.NoError(t, repo.Delete(ctx, found.ID))
196+
197+ // Verify deleted
198+ deleted, err := repo.FindByURL(ctx, "https://example.com")
199+ require.NoError(t, err)
200+ assert.Nil(t, deleted)
201+}
202+
203+// Ensure the test uses an isolated timestamp for time-sensitive queries
204+func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205+ ctx := historyTestCtx()
206+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
207+
208+ db, err := sqlite.NewConnection(ctx, dbPath)
209+ require.NoError(t, err)
210+ t.Cleanup(func() { _ = db.Close() })
211+
212+ repo := sqlite.NewHistoryRepository(db)
213+
214+ // Save entry
215+ entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216+ require.NoError(t, repo.Save(ctx, entry))
217+
218+ // Verify entry was saved with recent timestamp
219+ found, err := repo.FindByURL(ctx, "https://test.com")
220+ require.NoError(t, err)
221+ require.NotNil(t, found)
222+
223+ // LastVisited should be within the last minute
224+ timeDiff := time.Since(found.LastVisited)
225+ assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226+}
227+
228+func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
229+ ctx := historyTestCtx()
230+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
231+
232+ db, err := sqlite.NewConnection(ctx, dbPath)
233+ require.NoError(t, err)
234+ t.Cleanup(func() { _ = db.Close() })
235+
236+ repo := sqlite.NewHistoryRepository(db)
237+
238+ // days = 0 should return error
239+ _, err = repo.GetRecentSince(ctx, 0)
240+ require.Error(t, err)
241+ assert.Contains(t, err.Error(), "days must be positive")
242+
243+ // days < 0 should return error
244+ _, err = repo.GetRecentSince(ctx, -5)
245+ require.Error(t, err)
246+ assert.Contains(t, err.Error(), "days must be positive")
247+}
248+
249+func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
250+ ctx := historyTestCtx()
251+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
252+
253+ db, err := sqlite.NewConnection(ctx, dbPath)
254+ require.NoError(t, err)
255+ t.Cleanup(func() { _ = db.Close() })
256+
257+ repo := sqlite.NewHistoryRepository(db)
258+
259+ // days = 0 should return error
260+ _, err = repo.GetMostVisited(ctx, 0)
261+ require.Error(t, err)
262+ assert.Contains(t, err.Error(), "days must be positive")
263+
264+ // days < 0 should return error
265+ _, err = repo.GetMostVisited(ctx, -5)
266+ require.Error(t, err)
267+ assert.Contains(t, err.Error(), "days must be positive")
268+}
269+
270+func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
271+ ctx := historyTestCtx()
272+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
273+
274+ db, err := sqlite.NewConnection(ctx, dbPath)
275+ require.NoError(t, err)
276+ t.Cleanup(func() { _ = db.Close() })
277+
278+ repo := sqlite.NewHistoryRepository(db)
279+
280+ // Save some entries
281+ entries := []*entity.HistoryEntry{
282+ {URL: "https://example.com", Title: "Example"},
283+ {URL: "https://github.com", Title: "GitHub"},
284+ }
285+ for _, e := range entries {
286+ require.NoError(t, repo.Save(ctx, e))
287+ }
288+
289+ // Get all recent history
290+ results, err := repo.GetAllRecentHistory(ctx)
291+ require.NoError(t, err)
292+ require.Len(t, results, 2)
293+}
294+
295+func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
296+ ctx := historyTestCtx()
297+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
298+
299+ db, err := sqlite.NewConnection(ctx, dbPath)
300+ require.NoError(t, err)
301+ t.Cleanup(func() { _ = db.Close() })
302+
303+ repo := sqlite.NewHistoryRepository(db)
304+
305+ // Save entries with different visit counts
306+ entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
307+ entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
308+
309+ require.NoError(t, repo.Save(ctx, entry1))
310+ require.NoError(t, repo.Save(ctx, entry2))
311+
312+ // Increment github visits
313+ for i := 0; i < 3; i++ {
314+ require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
315+ }
316+
317+ // Get all most visited
318+ results, err := repo.GetAllMostVisited(ctx)
319+ require.NoError(t, err)
320+ require.Len(t, results, 2)
321+
322+ // First should be github (4 visits)
323+ assert.Equal(t, "https://github.com", results[0].URL)
324+ assert.Equal(t, int64(4), results[0].VisitCount)
325+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +28 -0
@@ -79,6 +79,34 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
7979 return r.repo.GetRecent(ctx, limit, offset)
8080 }
8181
82+func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
83+ if err := r.init(ctx); err != nil {
84+ return nil, err
85+ }
86+ return r.repo.GetRecentSince(ctx, days)
87+}
88+
89+func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
90+ if err := r.init(ctx); err != nil {
91+ return nil, err
92+ }
93+ return r.repo.GetMostVisited(ctx, days)
94+}
95+
96+func (r *LazyHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
97+ if err := r.init(ctx); err != nil {
98+ return nil, err
99+ }
100+ return r.repo.GetAllRecentHistory(ctx)
101+}
102+
103+func (r *LazyHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
104+ if err := r.init(ctx); err != nil {
105+ return nil, err
106+ }
107+ return r.repo.GetAllMostVisited(ctx)
108+}
109+
82110 func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83111 if err := r.init(ctx); err != nil {
84112 return err
M internal/infrastructure/persistence/sqlite/queries/history.sql +18 -0
@@ -56,3 +56,21 @@ JOIN history h ON fts.rowid = h.id
5656 WHERE fts.url MATCH ?
5757 ORDER BY bm25(history_fts)
5858 LIMIT ?;
59+
60+-- name: GetRecentHistorySince :many
61+SELECT * FROM history
62+WHERE last_visited >= datetime('now', ?)
63+ORDER BY last_visited DESC;
64+
65+-- name: GetMostVisited :many
66+SELECT * FROM history
67+WHERE last_visited >= datetime('now', ?)
68+ORDER BY visit_count DESC, last_visited DESC;
69+
70+-- name: GetAllRecentHistory :many
71+SELECT * FROM history
72+ORDER BY last_visited DESC;
73+
74+-- name: GetAllMostVisited :many
75+SELECT * FROM history
76+ORDER BY visit_count DESC, last_visited DESC;
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +146 -0
@@ -58,6 +58,78 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
5858 return err
5959 }
6060
61+const GetAllMostVisited = `-- name: GetAllMostVisited :many
62+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
63+ORDER BY visit_count DESC, last_visited DESC
64+`
65+
66+func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
67+ rows, err := q.db.QueryContext(ctx, GetAllMostVisited)
68+ if err != nil {
69+ return nil, err
70+ }
71+ defer rows.Close()
72+ items := []History{}
73+ for rows.Next() {
74+ var i History
75+ if err := rows.Scan(
76+ &i.ID,
77+ &i.Url,
78+ &i.Title,
79+ &i.FaviconUrl,
80+ &i.VisitCount,
81+ &i.LastVisited,
82+ &i.CreatedAt,
83+ ); err != nil {
84+ return nil, err
85+ }
86+ items = append(items, i)
87+ }
88+ if err := rows.Close(); err != nil {
89+ return nil, err
90+ }
91+ if err := rows.Err(); err != nil {
92+ return nil, err
93+ }
94+ return items, nil
95+}
96+
97+const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
98+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
99+ORDER BY last_visited DESC
100+`
101+
102+func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
103+ rows, err := q.db.QueryContext(ctx, GetAllRecentHistory)
104+ if err != nil {
105+ return nil, err
106+ }
107+ defer rows.Close()
108+ items := []History{}
109+ for rows.Next() {
110+ var i History
111+ if err := rows.Scan(
112+ &i.ID,
113+ &i.Url,
114+ &i.Title,
115+ &i.FaviconUrl,
116+ &i.VisitCount,
117+ &i.LastVisited,
118+ &i.CreatedAt,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
61133 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
62134 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC
63135 `
@@ -198,6 +270,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
198270 return items, nil
199271 }
200272
273+const GetMostVisited = `-- name: GetMostVisited :many
274+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
275+WHERE last_visited >= datetime('now', ?)
276+ORDER BY visit_count DESC, last_visited DESC
277+`
278+
279+func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) {
280+ rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime)
281+ if err != nil {
282+ return nil, err
283+ }
284+ defer rows.Close()
285+ items := []History{}
286+ for rows.Next() {
287+ var i History
288+ if err := rows.Scan(
289+ &i.ID,
290+ &i.Url,
291+ &i.Title,
292+ &i.FaviconUrl,
293+ &i.VisitCount,
294+ &i.LastVisited,
295+ &i.CreatedAt,
296+ ); err != nil {
297+ return nil, err
298+ }
299+ items = append(items, i)
300+ }
301+ if err := rows.Close(); err != nil {
302+ return nil, err
303+ }
304+ if err := rows.Err(); err != nil {
305+ return nil, err
306+ }
307+ return items, nil
308+}
309+
201310 const GetRecentHistory = `-- name: GetRecentHistory :many
202311 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
203312 `
@@ -238,6 +347,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
238347 return items, nil
239348 }
240349
350+const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
351+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
352+WHERE last_visited >= datetime('now', ?)
353+ORDER BY last_visited DESC
354+`
355+
356+func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) {
357+ rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime)
358+ if err != nil {
359+ return nil, err
360+ }
361+ defer rows.Close()
362+ items := []History{}
363+ for rows.Next() {
364+ var i History
365+ if err := rows.Scan(
366+ &i.ID,
367+ &i.Url,
368+ &i.Title,
369+ &i.FaviconUrl,
370+ &i.VisitCount,
371+ &i.LastVisited,
372+ &i.CreatedAt,
373+ ); err != nil {
374+ return nil, err
375+ }
376+ items = append(items, i)
377+ }
378+ if err := rows.Close(); err != nil {
379+ return nil, err
380+ }
381+ if err := rows.Err(); err != nil {
382+ return nil, err
383+ }
384+ return items, nil
385+}
386+
241387 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
242388 UPDATE history
243389 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
@@ -32,6 +32,8 @@ type Querier interface {
3232 GetActiveBrowserSession(ctx context.Context) (Session, error)
3333 GetAllFavorites(ctx context.Context) ([]Favorite, error)
3434 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
35+ GetAllMostVisited(ctx context.Context) ([]History, error)
36+ GetAllRecentHistory(ctx context.Context) ([]History, error)
3537 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
3638 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
3739 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
@@ -47,7 +49,9 @@ type Querier interface {
4749 GetHistoryByURL(ctx context.Context, url string) (History, error)
4850 GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error)
4951 GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error)
52+ GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
5053 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
54+ GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
5155 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
5256 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
5357 GetSessionByID(ctx context.Context, id string) (Session, error)
A internal/infrastructure/textinput/focus_provider.go +39 -0
@@ -0,0 +1,39 @@
1+// Package textinput provides implementations for text input targets and focus tracking.
2+package textinput
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// FocusProvider tracks which text input target currently has focus.
11+// It implements port.FocusedInputProvider.
12+type FocusProvider struct {
13+ target port.TextInputTarget
14+ mu sync.RWMutex
15+}
16+
17+// Compile-time interface check.
18+var _ port.FocusedInputProvider = (*FocusProvider)(nil)
19+
20+// NewFocusProvider creates a new focus provider.
21+func NewFocusProvider() *FocusProvider {
22+ return &FocusProvider{}
23+}
24+
25+// GetFocusedInput returns the currently focused text input target.
26+// Returns nil if no text input has focus.
27+func (fp *FocusProvider) GetFocusedInput() port.TextInputTarget {
28+ fp.mu.RLock()
29+ defer fp.mu.RUnlock()
30+ return fp.target
31+}
32+
33+// SetFocusedInput sets the currently focused text input target.
34+// Pass nil to clear focus.
35+func (fp *FocusProvider) SetFocusedInput(target port.TextInputTarget) {
36+ fp.mu.Lock()
37+ defer fp.mu.Unlock()
38+ fp.target = target
39+}
A internal/infrastructure/textinput/gtk_entry_target.go +96 -0
@@ -0,0 +1,96 @@
1+package textinput
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+)
10+
11+// GTKEntryTarget implements TextInputTarget for GTK SearchEntry widgets.
12+// It uses the Editable interface methods to insert and delete text.
13+type GTKEntryTarget struct {
14+ entry *gtk.SearchEntry
15+}
16+
17+// Compile-time interface check.
18+var _ port.TextInputTarget = (*GTKEntryTarget)(nil)
19+
20+// NewGTKEntryTarget creates a new GTK entry target for a SearchEntry.
21+func NewGTKEntryTarget(entry *gtk.SearchEntry) *GTKEntryTarget {
22+ return &GTKEntryTarget{entry: entry}
23+}
24+
25+// InsertText inserts text at the current cursor position.
26+func (t *GTKEntryTarget) InsertText(ctx context.Context, text string) error {
27+ log := logging.FromContext(ctx)
28+
29+ if t.entry == nil {
30+ log.Warn().Msg("GTKEntryTarget: entry is nil")
31+ return nil
32+ }
33+
34+ // Get current text and cursor position
35+ currentText := t.entry.GetText()
36+ pos := t.entry.GetPosition()
37+
38+ // Convert to runes for proper Unicode handling
39+ runes := []rune(currentText)
40+ textRunes := []rune(text)
41+
42+ // Clamp position to valid range
43+ if pos < 0 {
44+ pos = 0
45+ }
46+ if pos > len(runes) {
47+ pos = len(runes)
48+ }
49+
50+ // Build new text with insertion
51+ newRunes := make([]rune, 0, len(runes)+len(textRunes))
52+ newRunes = append(newRunes, runes[:pos]...)
53+ newRunes = append(newRunes, textRunes...)
54+ newRunes = append(newRunes, runes[pos:]...)
55+
56+ // Set new text and cursor position
57+ t.entry.SetText(string(newRunes))
58+ newPos := pos + len(textRunes)
59+ t.entry.SetPosition(newPos)
60+
61+ log.Debug().
62+ Str("text", text).
63+ Int("pos", pos).
64+ Msg("inserted text into GTK entry")
65+
66+ return nil
67+}
68+
69+// DeleteBeforeCursor deletes n characters before the cursor position.
70+func (t *GTKEntryTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
71+ log := logging.FromContext(ctx)
72+
73+ if t.entry == nil {
74+ log.Warn().Msg("GTKEntryTarget: entry is nil")
75+ return nil
76+ }
77+
78+ pos := t.entry.GetPosition()
79+ if pos < n {
80+ n = pos // Don't delete more than available
81+ }
82+
83+ if n <= 0 {
84+ return nil
85+ }
86+
87+ // DeleteText takes start and end positions
88+ t.entry.DeleteText(pos-n, pos)
89+
90+ log.Debug().
91+ Int("n", n).
92+ Int("pos", pos).
93+ Msg("deleted text before cursor in GTK entry")
94+
95+ return nil
96+}
A internal/infrastructure/textinput/webview_target.go +133 -0
@@ -0,0 +1,133 @@
1+package textinput
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk-webkit/webkit"
11+)
12+
13+// clipboardRestoreDelay is the delay before restoring the clipboard after paste.
14+const clipboardRestoreDelay = 50 * time.Millisecond
15+
16+// deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
17+// It handles both input/textarea elements and contenteditable elements.
18+const deleteBeforeCursorJS = `
19+(function() {
20+ const n = %d;
21+ const el = document.activeElement;
22+ if (!el) return;
23+
24+ // Handle input and textarea elements
25+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
26+ const start = el.selectionStart;
27+ const end = el.selectionEnd;
28+ if (start === end && start >= n) {
29+ el.value = el.value.slice(0, start - n) + el.value.slice(end);
30+ el.selectionStart = el.selectionEnd = start - n;
31+ // Trigger input event for frameworks like React
32+ el.dispatchEvent(new Event('input', { bubbles: true }));
33+ }
34+ return;
35+ }
36+
37+ // Handle contenteditable elements
38+ if (el.isContentEditable) {
39+ const sel = window.getSelection();
40+ if (!sel.rangeCount) return;
41+ const range = sel.getRangeAt(0);
42+ for (let i = 0; i < n; i++) {
43+ sel.modify('extend', 'backward', 'character');
44+ }
45+ sel.deleteFromDocument();
46+ }
47+})();
48+`
49+
50+// WebViewTarget implements TextInputTarget for WebView text fields.
51+// It uses the clipboard-paste pattern to insert text:
52+// 1. Save current clipboard
53+// 2. Write text to clipboard
54+// 3. Execute paste command
55+// 4. Restore original clipboard after a short delay
56+type WebViewTarget struct {
57+ webView *webkit.WebView
58+ clipboard port.Clipboard
59+}
60+
61+// Compile-time interface check.
62+var _ port.TextInputTarget = (*WebViewTarget)(nil)
63+
64+// NewWebViewTarget creates a new WebView target.
65+func NewWebViewTarget(webView *webkit.WebView, clipboard port.Clipboard) *WebViewTarget {
66+ return &WebViewTarget{
67+ webView: webView,
68+ clipboard: clipboard,
69+ }
70+}
71+
72+// InsertText inserts text at the current cursor position in the WebView.
73+// Uses clipboard-paste pattern since WebView doesn't expose direct text insertion.
74+func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
75+ log := logging.FromContext(ctx)
76+
77+ if t.webView == nil {
78+ log.Warn().Msg("WebViewTarget: webView is nil")
79+ return nil
80+ }
81+
82+ // Save current clipboard contents
83+ originalClipboard, _ := t.clipboard.ReadText(ctx)
84+
85+ // Write the accent to clipboard
86+ if err := t.clipboard.WriteText(ctx, text); err != nil {
87+ log.Error().Err(err).Msg("failed to write text to clipboard")
88+ return err
89+ }
90+
91+ // Execute paste command
92+ t.webView.ExecuteEditingCommand(webkit.EDITING_COMMAND_PASTE)
93+
94+ log.Debug().
95+ Str("text", text).
96+ Msg("inserted text into WebView via clipboard paste")
97+
98+ // Restore original clipboard after a short delay
99+ // This allows the paste operation to complete first
100+ // Use context.Background() since this cleanup should complete regardless of ctx cancellation
101+ go func() {
102+ time.Sleep(clipboardRestoreDelay)
103+ if originalClipboard != "" {
104+ if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil {
105+ log.Debug().Err(err).Msg("failed to restore clipboard")
106+ }
107+ }
108+ }()
109+
110+ return nil
111+}
112+
113+// DeleteBeforeCursor deletes n characters before the cursor in the WebView.
114+// Uses JavaScript to manipulate the focused input element.
115+func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
116+ log := logging.FromContext(ctx)
117+
118+ if t.webView == nil {
119+ log.Warn().Msg("WebViewTarget: webView is nil")
120+ return nil
121+ }
122+
123+ if n <= 0 {
124+ return nil
125+ }
126+
127+ // Execute JavaScript to delete characters
128+ script := fmt.Sprintf(deleteBeforeCursorJS, n)
129+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
130+
131+ log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
132+ return nil
133+}
M internal/infrastructure/webkit/context.go +44 -10
@@ -6,6 +6,7 @@ import (
66 "path/filepath"
77 "sync"
88
9+ "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/puregotk-webkit/webkit"
1112 "github.com/rs/zerolog"
@@ -34,34 +35,67 @@ type WebKitContext struct {
3435 // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data.
3536 // This MUST be called before creating any WebViews to ensure they use persistent storage.
3637 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
38+ return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{
39+ DataDir: dataDir,
40+ CacheDir: cacheDir,
41+ })
42+}
43+
44+// NewWebKitContextWithOptions creates and initializes a WebKitContext with the given options.
45+// This allows configuring memory pressure settings for web and network processes.
46+func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOptions) (*WebKitContext, error) {
3747 log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger()
3848
39- if dataDir == "" {
49+ if opts.DataDir == "" {
4050 return nil, fmt.Errorf("data directory cannot be empty")
4151 }
42- if cacheDir == "" {
52+ if opts.CacheDir == "" {
4353 return nil, fmt.Errorf("cache directory cannot be empty")
4454 }
4555
4656 wkCtx := &WebKitContext{
47- dataDir: dataDir,
48- cacheDir: cacheDir,
57+ dataDir: opts.DataDir,
58+ cacheDir: opts.CacheDir,
4959 logger: log,
5060 }
5161
62+ // Apply network process memory pressure settings BEFORE creating session
63+ // Per WebKitGTK docs: must be called before creating any NetworkSession
64+ if opts.IsNetworkProcessMemoryConfigured() {
65+ applier := NewMemoryPressureApplier()
66+ if err := applier.ApplyNetworkProcessSettings(ctx, opts.NetworkProcessMemory); err != nil {
67+ log.Warn().Err(err).Msg("failed to apply network process memory pressure settings")
68+ }
69+ }
70+
5271 // Create persistent network session FIRST
5372 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
5473 if err := wkCtx.initNetworkSession(); err != nil {
5574 return nil, fmt.Errorf("failed to init network session: %w", err)
5675 }
5776
58- // Get or create WebContext
59- wkCtx.webContext = webkit.WebContextGetDefault()
77+ // Create WebContext - use custom constructor if memory pressure settings are configured
78+ if opts.IsWebProcessMemoryConfigured() {
79+ memSettings := buildMemoryPressureSettings(opts.WebProcessMemory)
80+ if memSettings != nil {
81+ wkCtx.webContext = webkit.NewWebContextWithMemoryPressureSettings(memSettings)
82+ if wkCtx.webContext != nil {
83+ log.Info().
84+ Int("limit_mb", opts.WebProcessMemory.MemoryLimitMB).
85+ Float64("poll_sec", opts.WebProcessMemory.PollIntervalSec).
86+ Float64("conservative", opts.WebProcessMemory.ConservativeThreshold).
87+ Float64("strict", opts.WebProcessMemory.StrictThreshold).
88+ Msg("created WebContext with memory pressure settings")
89+ }
90+ }
91+ }
92+
93+ // Fall back to default WebContext if custom creation failed or wasn't needed
6094 if wkCtx.webContext == nil {
61- wkCtx.webContext = webkit.NewWebContext()
95+ wkCtx.webContext = webkit.WebContextGetDefault()
6296 }
6397 if wkCtx.webContext == nil {
64- return nil, fmt.Errorf("failed to get or create WebContext")
98+ return nil, fmt.Errorf("failed to create or get WebContext")
6599 }
66100
67101 // Set cache model for browser-style caching
@@ -69,8 +103,8 @@ func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitCon
69103
70104 wkCtx.initialized = true
71105 log.Info().
72- Str("data_dir", dataDir).
73- Str("cache_dir", cacheDir).
106+ Str("data_dir", opts.DataDir).
107+ Str("cache_dir", opts.CacheDir).
74108 Msg("webkit context initialized")
75109
76110 return wkCtx, nil
M internal/infrastructure/webkit/injector.go +51 -120
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "strings"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/logging"
910 "github.com/bnema/puregotk-webkit/webkit"
1011 )
@@ -25,51 +26,24 @@ const themeCSSScript = `(function() {
2526 (document.head || document.documentElement).appendChild(style);
2627 })();`
2728
28-// earlyBackgroundScript injects a temporary background color to prevent white flash.
29-// It's injected at document start and removed after the page loads.
30-// The %s placeholder is replaced with the hex color (e.g., "#0a0a0b").
31-const earlyBackgroundScript = `(function() {
32- var style = document.createElement('style');
33- style.id = '__dumber_early_bg';
34- style.textContent = 'html,body{background-color:%s!important}';
35- document.documentElement.appendChild(style);
36-
37- // Remove the style after page content loads to avoid overriding page styles
38- function removeEarlyBg() {
39- var s = document.getElementById('__dumber_early_bg');
40- if (s) s.remove();
41- }
42-
43- // Try multiple events to ensure removal
44- if (document.readyState === 'complete') {
45- setTimeout(removeEarlyBg, 100);
46- } else {
47- window.addEventListener('DOMContentLoaded', function() {
48- setTimeout(removeEarlyBg, 100);
49- }, {once: true});
50- window.addEventListener('load', function() {
51- setTimeout(removeEarlyBg, 100);
52- }, {once: true});
53- }
54-})();`
55-
56-// darkModeScript patches window.matchMedia and sets theme class on <html>.
57-// It must be injected at document start, after __dumber_gtk_prefers_dark is set.
58-// This script:
59-// 1. Sets dark/light class on <html> for CSS-based theme detection
60-// 2. Injects color-scheme meta tag and CSS for browser UA style hints
61-// 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries
29+// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
30+// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
31+// for JS-based dark mode detection in WebUI components.
32+//
33+// NOTE: This script is NOT injected on external pages. External pages receive
34+// dark mode preference via libadwaita's StyleManager, which WebKit respects
35+// for the native prefers-color-scheme media query.
6236 //
6337 // The matchMedia patch handles various query formats:
6438 // - (prefers-color-scheme: dark)
6539 // - (prefers-color-scheme:dark) -- no space
6640 // - screen and (prefers-color-scheme: dark)
67-// And provides proper addEventListener/removeEventListener stubs for compatibility.
68-const darkModeScript = `(function() {
69- var prefersDark = window.__dumber_gtk_prefers_dark || false;
41+const internalDarkModeScriptTemplate = `(function() {
42+ var prefersDark = %t;
43+ window.__dumber_gtk_prefers_dark = prefersDark;
7044 var originalMatchMedia = window.matchMedia.bind(window);
7145
72- // Apply dark/light class to document element for CSS theming
46+ // Apply dark/light class to document element for Tailwind CSS theming
7347 if (prefersDark) {
7448 document.documentElement.classList.add('dark');
7549 document.documentElement.classList.remove('light');
@@ -78,20 +52,7 @@ const darkModeScript = `(function() {
7852 document.documentElement.classList.remove('dark');
7953 }
8054
81- // Inject color-scheme meta tag
82- var meta = document.createElement('meta');
83- meta.name = 'color-scheme';
84- meta.content = prefersDark ? 'dark light' : 'light dark';
85- document.documentElement.appendChild(meta);
86-
87- // Inject root color-scheme style
88- var style = document.createElement('style');
89- style.setAttribute('data-dumber-color-scheme', '');
90- style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
91- document.documentElement.appendChild(style);
92-
9355 // Helper: Check if query is a prefers-color-scheme query
94- // Normalizes by removing whitespace and lowercasing
9556 function isColorSchemeQuery(query, scheme) {
9657 if (typeof query !== 'string') return false;
9758 var normalized = query.replace(/\s+/g, '').toLowerCase();
@@ -108,7 +69,6 @@ const darkModeScript = `(function() {
10869 media: query,
10970 get onchange() { return onchangeHandler; },
11071 set onchange(fn) { onchangeHandler = fn; },
111- // Deprecated but still used by some sites
11272 addListener: function(cb) {
11373 if (typeof cb === 'function') listeners.push(cb);
11474 },
@@ -116,7 +76,6 @@ const darkModeScript = `(function() {
11676 var idx = listeners.indexOf(cb);
11777 if (idx !== -1) listeners.splice(idx, 1);
11878 },
119- // Modern API
12079 addEventListener: function(type, cb) {
12180 if (type === 'change' && typeof cb === 'function') {
12281 listeners.push(cb);
@@ -152,22 +111,25 @@ const darkModeScript = `(function() {
152111 };
153112 })();`
154113
114+// internalPageAllowList restricts script injection to internal dumb:// pages only.
115+var internalPageAllowList = []string{"dumb://*"}
116+
155117 // ContentInjector encapsulates script injection into WebViews.
156-// It injects minimal scripts for dark mode detection in web pages
157-// and theme CSS variables for internal pages (dumb://).
118+// It injects dark mode detection scripts for internal pages (dumb://)
119+// and theme CSS variables for WebUI styling.
120+// External pages receive dark mode preference via libadwaita's StyleManager.
158121 // Implements port.ContentInjector interface.
159122 type ContentInjector struct {
160- prefersDark bool
161- themeCSSVars string // CSS custom property declarations for WebUI
162- findCSS string // CSS for find-in-page highlight styling
163- bgColor string // Background color hex for early injection (prevents white flash)
123+ colorResolver port.ColorSchemeResolver
124+ themeCSSVars string // CSS custom property declarations for WebUI
125+ findCSS string // CSS for find-in-page highlight styling
164126 }
165127
166128 // NewContentInjector creates a new injector instance.
167-// The prefersDark parameter should come from ThemeManager.PrefersDark().
168-func NewContentInjector(prefersDark bool) *ContentInjector {
129+// The resolver is used to dynamically determine dark mode preference.
130+func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
169131 return &ContentInjector{
170- prefersDark: prefersDark,
132+ colorResolver: resolver,
171133 }
172134 }
173135
@@ -189,29 +151,19 @@ func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css strin
189151 return nil
190152 }
191153
192-// PrefersDark returns the current dark mode preference.
154+// PrefersDark returns the current dark mode preference from the resolver.
193155 func (ci *ContentInjector) PrefersDark() bool {
194- return ci.prefersDark
156+ return ci.colorResolver.Resolve().PrefersDark
195157 }
196158
197-// SetPrefersDark updates the dark mode preference.
198-// Call this when theme changes at runtime.
199-func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
200- ci.prefersDark = prefersDark
201-}
202-
203-// SetBackgroundColor sets the background color for early CSS injection.
204-// This color is applied to html/body at document start to prevent white flash.
205-// The hex color should include the # prefix (e.g., "#0a0a0b").
206-func (ci *ContentInjector) SetBackgroundColor(hex string) {
207- ci.bgColor = hex
208-}
209-
210-// InjectScripts adds the minimal dark mode detection scripts to the given content manager.
211-// Only injects:
212-// - window.__dumber_gtk_prefers_dark flag
213-// - window.__dumber_webview_id (for debugging)
214-// - darkModeScript (patches matchMedia for prefers-color-scheme)
159+// InjectScripts adds scripts to the given content manager.
160+// For internal pages (dumb://*):
161+// - Injects dark mode detection (class on <html>, matchMedia patch)
162+// - Injects theme CSS variables for WebUI styling
163+//
164+// For external pages:
165+// - Only injects find highlight CSS
166+// - Dark mode is handled natively via libadwaita's StyleManager
215167 func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
216168 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
217169
@@ -229,35 +181,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
229181 log.Debug().Str("script", label).Msg("injected user script")
230182 }
231183
232- // 1. Inject early background color to prevent white flash (must be first!)
233- if ci.bgColor != "" {
234- earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor)
235- addScript(
236- webkit.NewUserScript(
237- earlyBgScript,
238- webkit.UserContentInjectTopFrameValue,
239- webkit.UserScriptInjectAtDocumentStartValue,
240- nil,
241- nil,
242- ),
243- "early-background",
244- )
245- }
246-
247- // 2. Inject GTK dark mode preference (must be before dark mode handler)
248- darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
249- addScript(
250- webkit.NewUserScript(
251- darkModePrefScript,
252- webkit.UserContentInjectTopFrameValue,
253- webkit.UserScriptInjectAtDocumentStartValue,
254- nil,
255- nil,
256- ),
257- "gtk-dark-mode-pref",
258- )
184+ prefersDark := ci.PrefersDark()
259185
260- // 3. Inject WebView ID for debugging
186+ // 1. Inject WebView ID for debugging (internal pages only)
261187 if webviewID != 0 {
262188 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
263189 addScript(
@@ -265,26 +191,31 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
265191 idScript,
266192 webkit.UserContentInjectTopFrameValue,
267193 webkit.UserScriptInjectAtDocumentStartValue,
268- nil,
194+ internalPageAllowList,
269195 nil,
270196 ),
271197 "webview-id",
272198 )
199+ log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200+ } else {
201+ log.Warn().Msg("webview ID is 0, skipping ID injection")
273202 }
274203
275- // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
204+ // 2. Inject dark mode handler for internal pages only
205+ // This sets .dark/.light class on <html> and patches matchMedia for WebUI
206+ internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
276207 addScript(
277208 webkit.NewUserScript(
278- darkModeScript,
209+ internalDarkModeScript,
279210 webkit.UserContentInjectTopFrameValue,
280211 webkit.UserScriptInjectAtDocumentStartValue,
281- nil,
212+ internalPageAllowList,
282213 nil,
283214 ),
284- "dark-mode-handler",
215+ "internal-dark-mode-handler",
285216 )
286217
287- // 5. Inject theme CSS for internal pages (dumb://* only)
218+ // 3. Inject theme CSS for internal pages (dumb://* only)
288219 if ci.themeCSSVars != "" {
289220 // Escape for JS string literal
290221 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -296,15 +227,15 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
296227 themeCSSInjectionScript,
297228 webkit.UserContentInjectTopFrameValue,
298229 webkit.UserScriptInjectAtDocumentEndValue,
299- nil, // Inject on all pages - allowlist patterns don't work for custom schemes
230+ internalPageAllowList,
300231 nil,
301232 ),
302233 "theme-css-vars",
303234 )
304- log.Debug().Msg("theme CSS vars injection configured")
235+ log.Debug().Msg("theme CSS vars injection configured for internal pages")
305236 }
306237
307- // 6. Inject find highlight CSS for all pages
238+ // 4. Inject find highlight CSS for all pages
308239 if ci.findCSS != "" {
309240 stylesheet := webkit.NewUserStyleSheet(
310241 ci.findCSS,
@@ -321,5 +252,5 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
321252 }
322253 }
323254
324- log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
255+ log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
325256 }
A internal/infrastructure/webkit/memory_pressure.go +117 -0
@@ -0,0 +1,117 @@
1+package webkit
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/puregotk-webkit/webkit"
9+)
10+
11+// MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK.
12+type MemoryPressureApplier struct{}
13+
14+// NewMemoryPressureApplier creates a new MemoryPressureApplier.
15+func NewMemoryPressureApplier() *MemoryPressureApplier {
16+ return &MemoryPressureApplier{}
17+}
18+
19+// ApplyNetworkProcessSettings applies memory pressure settings to the network process.
20+// Must be called BEFORE creating any NetworkSession.
21+func (*MemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error {
22+ log := logging.FromContext(ctx)
23+
24+ if cfg == nil || !cfg.IsConfigured() {
25+ log.Debug().Msg("no network process memory pressure settings configured")
26+ return nil
27+ }
28+
29+ if cfg.ConservativeThreshold > 0 && cfg.StrictThreshold <= 0 {
30+ log.Warn().Msg("conservative threshold set without strict threshold; skipping conservative to avoid WebKit assertion")
31+ }
32+
33+ settings := buildMemoryPressureSettings(cfg)
34+ if settings == nil {
35+ return nil
36+ }
37+
38+ webkit.NetworkSessionSetMemoryPressureSettings(settings)
39+ log.Info().
40+ Int("limit_mb", cfg.MemoryLimitMB).
41+ Float64("poll_sec", cfg.PollIntervalSec).
42+ Float64("conservative", cfg.ConservativeThreshold).
43+ Float64("strict", cfg.StrictThreshold).
44+ Msg("applied network process memory pressure settings")
45+
46+ return nil
47+}
48+
49+// ApplyWebProcessSettings applies memory pressure settings to web processes.
50+// Returns an opaque settings object that should be passed to WebContext creation.
51+// Returns nil if no settings are configured.
52+func (*MemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) {
53+ log := logging.FromContext(ctx)
54+
55+ if cfg == nil || !cfg.IsConfigured() {
56+ log.Debug().Msg("no web process memory pressure settings configured")
57+ return nil, nil
58+ }
59+
60+ if cfg.ConservativeThreshold > 0 && cfg.StrictThreshold <= 0 {
61+ log.Warn().Msg("conservative threshold set without strict threshold; skipping conservative to avoid WebKit assertion")
62+ }
63+
64+ settings := buildMemoryPressureSettings(cfg)
65+ if settings == nil {
66+ return nil, nil
67+ }
68+
69+ log.Info().
70+ Int("limit_mb", cfg.MemoryLimitMB).
71+ Float64("poll_sec", cfg.PollIntervalSec).
72+ Float64("conservative", cfg.ConservativeThreshold).
73+ Float64("strict", cfg.StrictThreshold).
74+ Msg("prepared web process memory pressure settings")
75+
76+ return settings, nil
77+}
78+
79+// buildMemoryPressureSettings creates WebKitMemoryPressureSettings from config.
80+// Returns nil if no settings are configured.
81+func buildMemoryPressureSettings(cfg *port.MemoryPressureConfig) *webkit.MemoryPressureSettings {
82+ if cfg == nil || !cfg.IsConfigured() {
83+ return nil
84+ }
85+
86+ settings := webkit.NewMemoryPressureSettings()
87+
88+ if cfg.MemoryLimitMB > 0 {
89+ settings.SetMemoryLimit(uint(cfg.MemoryLimitMB))
90+ }
91+
92+ if cfg.PollIntervalSec > 0 {
93+ settings.SetPollInterval(cfg.PollIntervalSec)
94+ }
95+
96+ // Thresholds must be in (0, 1)
97+ // IMPORTANT: WebKit asserts that conservative < strict, so set strict first.
98+ strict := cfg.StrictThreshold
99+ conservative := cfg.ConservativeThreshold
100+
101+ if strict > 0 && strict < 1 {
102+ settings.SetStrictThreshold(strict)
103+ }
104+
105+ // Only set conservative when we also have a strict threshold; otherwise
106+ // WebKit's internal default strict value may not be initialized yet.
107+ if conservative > 0 && conservative < 1 && strict > 0 {
108+ if conservative < strict {
109+ settings.SetConservativeThreshold(conservative)
110+ }
111+ }
112+
113+ return settings
114+}
115+
116+// Ensure MemoryPressureApplier implements the interface.
117+var _ port.MemoryPressureApplier = (*MemoryPressureApplier)(nil)
M internal/infrastructure/webkit/messaging.go +44 -5
@@ -48,6 +48,9 @@ type MessageRouter struct {
4848 mu sync.RWMutex
4949 callbacks []interface{}
5050 signals []uint32
51+
52+ idMu sync.Mutex
53+ syncedIDs map[WebViewID]bool
5154 }
5255
5356 // NewMessageRouter creates a new message router.
@@ -57,8 +60,9 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
5760 }
5861
5962 return &MessageRouter{
60- handlers: make(map[string]handlerEntry),
61- baseCtx: ctx,
63+ handlers: make(map[string]handlerEntry),
64+ baseCtx: ctx,
65+ syncedIDs: make(map[WebViewID]bool),
6266 }
6367 }
6468
@@ -131,9 +135,9 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
131135
132136 // Connect to signal BEFORE registering handler to avoid race conditions
133137 // (as recommended by WebKit documentation)
134- cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
138+ cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
135139 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136- r.handleScriptMessage(valuePtr)
140+ r.handleScriptMessage(ucm, valuePtr)
137141 }
138142
139143 r.mu.Lock()
@@ -167,7 +171,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
167171 }
168172
169173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
170-func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
174+func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
171175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
172176
173177 if valuePtr == 0 {
@@ -195,6 +199,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
195199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196200 msg.WebViewID = msg.WebViewIDAlt
197201 }
202+ if msg.WebViewID == 0 {
203+ if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204+ msg.WebViewID = uint64(senderWV.ID())
205+ r.syncWebViewID(senderWV)
206+ }
207+ }
198208
199209 if msg.Type == "" {
200210 log.Warn().Msg("script message missing type")
@@ -234,6 +244,35 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
234244 }
235245 }
236246
247+func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248+ ptr := senderUCM.GoPointer()
249+ if ptr == 0 {
250+ return nil
251+ }
252+ return LookupWebViewByUCMPointer(ptr)
253+}
254+
255+func (r *MessageRouter) syncWebViewID(wv *WebView) {
256+ if wv == nil {
257+ return
258+ }
259+ if !r.markWebViewIDSynced(wv.ID()) {
260+ return
261+ }
262+ script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263+ wv.RunJavaScript(r.baseCtx, script, "")
264+}
265+
266+func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267+ r.idMu.Lock()
268+ defer r.idMu.Unlock()
269+ if r.syncedIDs[id] {
270+ return false
271+ }
272+ r.syncedIDs[id] = true
273+ return true
274+}
275+
237276 // getHandler retrieves a handler by message type.
238277 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239278 r.mu.RLock()
M internal/infrastructure/webkit/pool.go +51 -0
@@ -353,6 +353,57 @@ func (p *WebViewPool) Size() int {
353353 return len(p.pool)
354354 }
355355
356+// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357+// This is needed after adw.Init() to update dark mode preference that was
358+// injected with the wrong value during early prewarming.
359+func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360+ if p.closed.Load() || p.injector == nil {
361+ return
362+ }
363+
364+ log := logging.FromContext(ctx)
365+
366+ // Drain and refresh each WebView, then put it back
367+ count := len(p.pool)
368+ if count == 0 {
369+ return
370+ }
371+
372+ refreshed := 0
373+refreshLoop:
374+ for i := 0; i < count; i++ {
375+ select {
376+ case wv := <-p.pool:
377+ if wv == nil || wv.IsDestroyed() {
378+ continue
379+ }
380+
381+ // Clear existing scripts and re-inject with updated values
382+ if wv.ucm != nil {
383+ wv.ucm.RemoveAllScripts()
384+ wv.ucm.RemoveAllStyleSheets()
385+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386+ }
387+
388+ // Put back in pool
389+ select {
390+ case p.pool <- wv:
391+ refreshed++
392+ default:
393+ wv.Destroy()
394+ }
395+ default:
396+ // Pool was modified during iteration, stop
397+ break refreshLoop
398+ }
399+ }
400+
401+ log.Debug().
402+ Int("refreshed", refreshed).
403+ Bool("prefers_dark", p.injector.PrefersDark()).
404+ Msg("refreshed scripts in pooled webviews")
405+}
406+
356407 // Close shuts down the pool and destroys all pooled WebViews.
357408 func (p *WebViewPool) Close(ctx context.Context) {
358409 if p.closed.Swap(true) {
M internal/infrastructure/webkit/scheme_handler.go +127 -19
@@ -13,7 +13,9 @@ import (
1313 "strings"
1414 "sync"
1515
16+ "github.com/bnema/dumber/internal/application/port"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/env"
1719 "github.com/bnema/dumber/internal/logging"
1820 "github.com/bnema/puregotk-webkit/soup"
1921 "github.com/bnema/puregotk-webkit/webkit"
@@ -59,11 +61,13 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
5961
6062 // DumbSchemeHandler handles dumb:// URI scheme requests.
6163 type DumbSchemeHandler struct {
62- handlers map[string]PageHandler
63- assets embed.FS
64- assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
65- logger zerolog.Logger
66- mu sync.RWMutex
64+ handlers map[string]PageHandler
65+ assets embed.FS
66+ assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
67+ logger zerolog.Logger
68+ mu sync.RWMutex
69+ hwSurveyor *env.HardwareSurveyor
70+ ctx context.Context
6771 }
6872
6973 type configAppearancePayload struct {
@@ -76,8 +80,45 @@ type configAppearancePayload struct {
7680 DarkPalette config.ColorPalette `json:"dark_palette"`
7781 }
7882
83+type configHardwarePayload struct {
84+ CPUCores int `json:"cpu_cores"`
85+ CPUThreads int `json:"cpu_threads"`
86+ TotalRAMMB int `json:"total_ram_mb"`
87+ GPUVendor string `json:"gpu_vendor"`
88+ GPUName string `json:"gpu_name"`
89+ VRAMMB int `json:"vram_mb"`
90+}
91+
92+type configPerformancePayload struct {
93+ Profile string `json:"profile"`
94+ Custom configCustomPerformance `json:"custom"`
95+ Resolved configResolvedPerformance `json:"resolved"`
96+ Hardware configHardwarePayload `json:"hardware"`
97+}
98+
99+// configCustomPerformance holds user-editable fields for custom profile.
100+type configCustomPerformance struct {
101+ SkiaCPUThreads int `json:"skia_cpu_threads"`
102+ SkiaGPUThreads int `json:"skia_gpu_threads"`
103+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
104+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
105+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
106+}
107+
108+// configResolvedPerformance shows the actual values that will be applied at startup.
109+type configResolvedPerformance struct {
110+ SkiaCPUThreads int `json:"skia_cpu_threads"`
111+ SkiaGPUThreads int `json:"skia_gpu_threads"`
112+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
113+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
114+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
115+ ConservativeThreshold float64 `json:"conservative_threshold"`
116+ StrictThreshold float64 `json:"strict_threshold"`
117+}
118+
79119 type configPayload struct {
80120 Appearance configAppearancePayload `json:"appearance"`
121+ Performance configPerformancePayload `json:"performance"`
81122 DefaultUIScale float64 `json:"default_ui_scale"`
82123 DefaultSearchEngine string `json:"default_search_engine"`
83124 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
@@ -88,9 +129,11 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
88129 log := logging.FromContext(ctx)
89130
90131 h := &DumbSchemeHandler{
91- handlers: make(map[string]PageHandler),
92- assetDir: "webui",
93- logger: log.With().Str("component", "scheme-handler").Logger(),
132+ handlers: make(map[string]PageHandler),
133+ assetDir: "webui",
134+ logger: log.With().Str("component", "scheme-handler").Logger(),
135+ hwSurveyor: env.NewHardwareSurveyor(),
136+ ctx: ctx,
94137 }
95138
96139 // Register default pages
@@ -124,7 +167,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
124167 return nil
125168 }
126169
127- return buildConfigResponse(config.Get())
170+ return h.buildConfigResponse(config.Get())
128171 }))
129172
130173 // API: Get default config (used by Reset Defaults in dumb://config)
@@ -133,11 +176,23 @@ func (h *DumbSchemeHandler) registerDefaults() {
133176 return nil
134177 }
135178
136- return buildConfigResponse(config.DefaultConfig())
179+ return h.buildConfigResponse(config.DefaultConfig())
137180 }))
138181 }
139182
140-func buildConfigResponse(cfg *config.Config) *SchemeResponse {
183+func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
184+ // Get hardware info for display and profile resolution
185+ // Use background context since survey results are cached and we don't want
186+ // request context cancellation to affect this
187+ var hw *port.HardwareInfo
188+ if h.hwSurveyor != nil {
189+ hwInfo := h.hwSurveyor.Survey(context.Background())
190+ hw = &hwInfo
191+ }
192+
193+ // Resolve performance profile with hardware info
194+ resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw)
195+
141196 resp := configPayload{
142197 DefaultUIScale: cfg.DefaultUIScale,
143198 DefaultSearchEngine: cfg.DefaultSearchEngine,
@@ -151,6 +206,26 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse {
151206 LightPalette: cfg.Appearance.LightPalette,
152207 DarkPalette: cfg.Appearance.DarkPalette,
153208 },
209+ Performance: configPerformancePayload{
210+ Profile: string(cfg.Performance.Profile),
211+ Custom: configCustomPerformance{
212+ SkiaCPUThreads: cfg.Performance.SkiaCPUPaintingThreads,
213+ SkiaGPUThreads: cfg.Performance.SkiaGPUPaintingThreads,
214+ WebProcessMemoryMB: cfg.Performance.WebProcessMemoryLimitMB,
215+ NetworkProcessMemoryMB: cfg.Performance.NetworkProcessMemoryLimitMB,
216+ WebViewPoolPrewarm: cfg.Performance.WebViewPoolPrewarmCount,
217+ },
218+ Resolved: configResolvedPerformance{
219+ SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
220+ SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
221+ WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
222+ NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
223+ WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
224+ ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
225+ StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
226+ },
227+ Hardware: buildHardwarePayload(hw),
228+ },
154229 }
155230
156231 data, err := json.Marshal(resp)
@@ -169,6 +244,21 @@ func buildConfigResponse(cfg *config.Config) *SchemeResponse {
169244 }
170245 }
171246
247+// buildHardwarePayload converts HardwareInfo to JSON payload.
248+func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload {
249+ if hw == nil {
250+ return configHardwarePayload{}
251+ }
252+ return configHardwarePayload{
253+ CPUCores: hw.CPUCores,
254+ CPUThreads: hw.CPUThreads,
255+ TotalRAMMB: hw.TotalRAMMB(),
256+ GPUVendor: string(hw.GPUVendor),
257+ GPUName: hw.GPUName,
258+ VRAMMB: hw.VRAMMB(),
259+ }
260+}
261+
172262 // RegisterPage registers a handler for a specific path.
173263 func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
174264 h.mu.Lock()
@@ -419,6 +509,8 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
419509 }
420510
421511 // RegisterWithContext registers the dumb:// scheme with a WebKitContext.
512+// The scheme is always registered on the default WebContext to ensure WebViews
513+// (which use the default WebContext) can load dumb:// URLs.
422514 func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
423515 if wkCtx == nil || wkCtx.Context() == nil {
424516 h.logger.Error().Msg("cannot register scheme: context is nil")
@@ -429,15 +521,31 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
429521 h.HandleRequest(reqPtr)
430522 })
431523
432- wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
524+ // Always register on the default WebContext since WebViews use it
525+ defaultCtx := webkit.WebContextGetDefault()
526+ if defaultCtx != nil {
527+ defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
433528
434- // Mark scheme as local, secure, and CORS-enabled for proper security policies
435- secMgr := wkCtx.Context().GetSecurityManager()
436- if secMgr != nil {
437- secMgr.RegisterUriSchemeAsLocal("dumb")
438- secMgr.RegisterUriSchemeAsSecure("dumb")
439- secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
440- h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled")
529+ // Mark scheme as local, secure, and CORS-enabled for proper security policies
530+ if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
531+ secMgr.RegisterUriSchemeAsLocal("dumb")
532+ secMgr.RegisterUriSchemeAsSecure("dumb")
533+ secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
534+ }
535+ h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
536+ }
537+
538+ // Also register on custom WebContext if different from default
539+ customCtx := wkCtx.Context()
540+ if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
541+ wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
542+
543+ if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
544+ secMgr.RegisterUriSchemeAsLocal("dumb")
545+ secMgr.RegisterUriSchemeAsSecure("dumb")
546+ secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
547+ }
548+ h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
441549 }
442550
443551 h.logger.Info().Msg("dumb:// scheme registered")
M internal/infrastructure/webkit/webview.go +35 -3
@@ -46,25 +46,34 @@ type PopupRequest struct {
4646 // webViewRegistry tracks all active WebViews.
4747 type webViewRegistry struct {
4848 views map[WebViewID]*WebView
49+ byUCM map[uintptr]WebViewID
4950 counter atomic.Uint64
5051 mu sync.RWMutex
5152 }
5253
5354 var globalRegistry = &webViewRegistry{
5455 views: make(map[WebViewID]*WebView),
56+ byUCM: make(map[uintptr]WebViewID),
5557 }
5658
5759 func (r *webViewRegistry) register(wv *WebView) WebViewID {
5860 id := WebViewID(r.counter.Add(1))
5961 r.mu.Lock()
6062 r.views[id] = wv
63+ if wv != nil && wv.ucm != nil {
64+ r.byUCM[wv.ucm.GoPointer()] = id
65+ }
6166 r.mu.Unlock()
6267 return id
6368 }
6469
6570 func (r *webViewRegistry) unregister(id WebViewID) {
6671 r.mu.Lock()
72+ wv := r.views[id]
6773 delete(r.views, id)
74+ if wv != nil && wv.ucm != nil {
75+ delete(r.byUCM, wv.ucm.GoPointer())
76+ }
6877 r.mu.Unlock()
6978 }
7079
@@ -75,11 +84,29 @@ func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
7584 return r.views[id]
7685 }
7786
87+func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
88+ if ptr == 0 {
89+ return nil
90+ }
91+ r.mu.RLock()
92+ defer r.mu.RUnlock()
93+ id, ok := r.byUCM[ptr]
94+ if !ok {
95+ return nil
96+ }
97+ return r.views[id]
98+}
99+
78100 // LookupWebView returns a WebView by ID from the global registry.
79101 func LookupWebView(id WebViewID) *WebView {
80102 return globalRegistry.Lookup(id)
81103 }
82104
105+// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
106+func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
107+ return globalRegistry.LookupByUCMPointer(ptr)
108+}
109+
83110 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.
84111 type WebView struct {
85112 id WebViewID
@@ -248,10 +275,15 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
248275 return nil, fmt.Errorf("webkit context not initialized")
249276 }
250277
251- // Use NetworkSession-aware constructor for persistent cookie storage
252- inner := webkit.NewWebViewWithNetworkSession(wkCtx.NetworkSession())
278+ // Use options constructor to pass both WebContext and NetworkSession.
279+ // This ensures WebViews use our custom WebContext (with memory pressure settings)
280+ // and the persistent NetworkSession for cookie/storage.
281+ inner := webkit.NewWebViewWithOptions(&webkit.WebViewOptions{
282+ WebContext: wkCtx.Context(),
283+ NetworkSession: wkCtx.NetworkSession(),
284+ })
253285 if inner == nil {
254- return nil, fmt.Errorf("failed to create webkit webview with network session")
286+ return nil, fmt.Errorf("failed to create webkit webview with options")
255287 }
256288
257289 // Set background color IMMEDIATELY after creation, before any other operations.
M internal/ui/app.go +159 -12
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/dumber/internal/infrastructure/desktop"
1515 "github.com/bnema/dumber/internal/infrastructure/filtering"
1616 "github.com/bnema/dumber/internal/infrastructure/snapshot"
17+ "github.com/bnema/dumber/internal/infrastructure/textinput"
1718 "github.com/bnema/dumber/internal/infrastructure/webkit"
1819
1920 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -27,6 +28,7 @@ import (
2728 "github.com/bnema/dumber/internal/ui/layout"
2829 "github.com/bnema/dumber/internal/ui/theme"
2930 "github.com/bnema/dumber/internal/ui/window"
31+ "github.com/jwijenbergh/puregotk/v4/adw"
3032 "github.com/jwijenbergh/puregotk/v4/gdk"
3133 "github.com/jwijenbergh/puregotk/v4/gio"
3234 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -107,6 +109,11 @@ type App struct {
107109
108110 movePaneToTabUC *usecase.MovePaneToTabUseCase
109111
112+ // Accent picker for dead keys support
113+ accentPicker *component.AccentPicker
114+ insertAccentUC *usecase.InsertAccentUseCase
115+ accentFocusProvider *textinput.FocusProvider
116+
110117 // lifecycle
111118 cancel context.CancelCauseFunc
112119 }
@@ -155,6 +162,23 @@ func (a *App) Run(ctx context.Context, args []string) int {
155162 log := logging.FromContext(ctx)
156163 log.Debug().Msg("creating GTK application")
157164
165+ // Initialize libadwaita once (required before using StyleManager).
166+ // This also initializes GTK implicitly.
167+ adw.Init()
168+
169+ // Mark adwaita detector as available now that adw.Init() is complete.
170+ // This enables the highest-priority color scheme detector.
171+ if a.deps != nil && a.deps.AdwaitaDetector != nil {
172+ a.deps.AdwaitaDetector.MarkAvailable()
173+ log.Debug().Msg("adwaita detector marked available")
174+
175+ // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
176+ // They have the wrong dark mode preference injected; re-inject with correct value.
177+ if a.pool != nil {
178+ a.pool.RefreshScripts(ctx)
179+ }
180+ }
181+
158182 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
159183 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
160184 if a.gtkApp == nil {
@@ -199,6 +223,7 @@ func (a *App) onActivate(ctx context.Context) {
199223 a.initLayoutInfrastructure()
200224 a.initAppToasterOverlay()
201225 a.initFocusAndBorderOverlay()
226+ a.initAccentPicker(ctx)
202227
203228 a.initCoordinators(ctx)
204229 a.initKeyboardHandler(ctx)
@@ -217,23 +242,52 @@ func (a *App) onActivate(ctx context.Context) {
217242 func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
218243 log := logging.FromContext(ctx)
219244
220- // Propagate app color scheme preference to GTK (and WebKit)
221- // so web content can correctly evaluate prefers-color-scheme.
222- settings := gtk.SettingsGetDefault()
223- if settings == nil {
224- return
225- }
226-
245+ // Get the config color scheme preference (default follows system theme)
227246 scheme := "default"
228247 if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
229248 scheme = a.deps.Config.Appearance.ColorScheme
230249 }
231- prefersDark := theme.ResolveColorScheme(scheme)
232- settings.SetPropertyGtkApplicationPreferDarkTheme(prefersDark)
250+
251+ // Apply color scheme via libadwaita's StyleManager.
252+ // This properly communicates the preference to GTK and WebKit,
253+ // so web content can correctly evaluate prefers-color-scheme media queries.
254+ styleMgr := adw.StyleManagerGetDefault()
255+ if styleMgr == nil {
256+ log.Warn().Msg("failed to get adw.StyleManager")
257+ return
258+ }
259+
260+ var adwScheme adw.ColorScheme
261+ switch scheme {
262+ case "dark", "prefer-dark":
263+ adwScheme = adw.ColorSchemeForceDarkValue
264+ case "light", "prefer-light":
265+ adwScheme = adw.ColorSchemeForceLightValue
266+ default:
267+ // "default" or empty - follow system preference
268+ adwScheme = adw.ColorSchemeDefaultValue
269+ }
270+
271+ styleMgr.SetColorScheme(adwScheme)
272+
273+ // Refresh the color resolver now that adwaita detector is available.
274+ // This ensures all components get the correct preference.
275+ var pref string
276+ var prefersDark bool
277+ if a.deps != nil && a.deps.ColorResolver != nil {
278+ result := a.deps.ColorResolver.Refresh()
279+ prefersDark = result.PrefersDark
280+ pref = result.Source
281+ } else {
282+ prefersDark = styleMgr.GetDark()
283+ pref = "adw.StyleManager"
284+ }
285+
233286 log.Debug().
234287 Str("scheme", scheme).
235288 Bool("prefers_dark", prefersDark).
236- Msg("set gtk-application-prefer-dark-theme")
289+ Str("source", pref).
290+ Msg("applied color scheme via adw.StyleManager")
237291 }
238292
239293 func (a *App) setupPoolBackgroundColor(ctx context.Context) {
@@ -340,6 +394,49 @@ func (a *App) initFocusAndBorderOverlay() {
340394 a.mainWindow.AddOverlay(gtkWidget)
341395 }
342396
397+func (a *App) initAccentPicker(ctx context.Context) {
398+ log := logging.FromContext(ctx)
399+
400+ if a.widgetFactory == nil || a.mainWindow == nil {
401+ log.Debug().Msg("widget factory or main window not available, skipping accent picker")
402+ return
403+ }
404+
405+ // Create accent picker component
406+ a.accentPicker = component.NewAccentPicker(a.widgetFactory)
407+ if a.accentPicker == nil {
408+ log.Warn().Msg("failed to create accent picker")
409+ return
410+ }
411+
412+ // Add to main window overlay
413+ pickerWidget := a.accentPicker.Widget()
414+ if pickerWidget != nil {
415+ gtkWidget := pickerWidget.GtkWidget()
416+ if gtkWidget != nil {
417+ a.mainWindow.AddOverlay(gtkWidget)
418+ }
419+ }
420+
421+ // Create focus provider (tracks which input has focus)
422+ a.accentFocusProvider = textinput.NewFocusProvider()
423+
424+ // Create the use case with glib.IdleAdd for thread-safe GTK calls
425+ a.insertAccentUC = usecase.NewInsertAccentUseCase(
426+ a.accentFocusProvider,
427+ a.accentPicker,
428+ func(fn func()) {
429+ cb := glib.SourceFunc(func(_ uintptr) bool {
430+ fn()
431+ return false // Don't repeat
432+ })
433+ glib.IdleAdd(&cb, 0)
434+ },
435+ )
436+
437+ log.Debug().Msg("accent picker initialized")
438+}
439+
343440 func (a *App) initKeyboardHandler(ctx context.Context) {
344441 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
345442 return
@@ -367,6 +464,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
367464 }
368465 return wsView.IsOmniboxVisible()
369466 })
467+ // Wire accent handler for dead keys support
468+ if a.insertAccentUC != nil {
469+ a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
470+ }
370471 a.keyboardHandler.AttachTo(a.mainWindow.Window())
371472
372473 // Create global shortcut handler for Alt+1-9 tab switching.
@@ -414,6 +515,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
414515 log.Error().Err(err).Str("url", url).Msg("navigation failed")
415516 }
416517 },
518+ OnFocusIn: func(entry *gtk.SearchEntry) {
519+ // Set omnibox entry as the focused input for accent picker
520+ if a.accentFocusProvider != nil && entry != nil {
521+ a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
522+ }
523+ },
524+ OnFocusOut: func() {
525+ // When omnibox loses focus, set WebView as the focused input
526+ if a.accentFocusProvider != nil {
527+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
528+ }
529+ },
417530 }
418531 a.navCoord.SetOmniboxProvider(a)
419532 log.Debug().Msg("omnibox config stored, provider set")
@@ -432,6 +545,18 @@ func (a *App) initFindBarConfig() {
432545 }
433546 return wv.GetFindController()
434547 },
548+ OnFocusIn: func(entry *gtk.SearchEntry) {
549+ // Set find bar entry as the focused input for accent picker
550+ if a.accentFocusProvider != nil && entry != nil {
551+ a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
552+ }
553+ },
554+ OnFocusOut: func() {
555+ // When find bar loses focus, set WebView as the focused input
556+ if a.accentFocusProvider != nil {
557+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
558+ }
559+ },
435560 }
436561 }
437562
@@ -1172,6 +1297,12 @@ func (a *App) initCoordinators(ctx context.Context) {
11721297 a.deps.OnFirstWebViewShown(ctx)
11731298 })
11741299 }
1300+
1301+ // Set the WebView as the focused input for accent picker (if this is the active pane)
1302+ ws := a.activeWorkspace()
1303+ if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1304+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1305+ }
11751306 })
11761307
11771308 // 5. Keyboard Dispatcher
@@ -1491,6 +1622,23 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
14911622 return a.workspaceViews[activeTab.ID]
14921623 }
14931624
1625+// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1626+// Used by the accent picker to insert accented characters into web content.
1627+func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1628+ ws := a.activeWorkspace()
1629+ if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1630+ return nil
1631+ }
1632+
1633+ wv := a.contentCoord.GetWebView(ws.ActivePaneID)
1634+ if wv == nil {
1635+ return nil
1636+ }
1637+
1638+ // Get the underlying webkit.WebView for the text input target
1639+ return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
1640+}
1641+
14941642 // attachPopupToTab attaches a popup WebView to a newly created tab.
14951643 // This is called when a popup uses tabbed behavior.
14961644 func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
@@ -1711,9 +1859,8 @@ func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
17111859 }
17121860 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
17131861
1714- // Keep injector's dark-mode flag in sync for future navigations
1862+ // Update find highlight CSS for future navigations
17151863 if a.injector != nil {
1716- a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
17171864 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
17181865 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
17191866 log.Warn().Err(err).Msg("failed to update find highlight CSS")
A internal/ui/component/accent_picker.go +319 -0
@@ -0,0 +1,319 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/layout"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Accent picker layout constants.
13+const (
14+ accentPickerSpacing = 8 // Spacing between accent items in pixels.
15+ maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9).
16+)
17+
18+// AccentPicker displays an overlay with accent character options.
19+// The user can select an accent using:
20+// - Left/Right arrow keys to navigate
21+// - Number keys 1-9 to select directly
22+// - Enter to confirm selection
23+// - Escape to cancel
24+type AccentPicker struct {
25+ factory layout.WidgetFactory
26+
27+ // Widgets
28+ container layout.BoxWidget // Outer container for positioning
29+ accentLabels []layout.LabelWidget // Individual accent character labels
30+
31+ // State
32+ accents []rune
33+ selectedIndex int
34+ selectedCb func(rune)
35+ cancelCb func()
36+ visible bool
37+ keyController *gtk.EventControllerKey
38+ retainedClicked []interface{} // Keep click callbacks alive
39+
40+ mu sync.Mutex
41+}
42+
43+// Compile-time interface check.
44+var _ port.AccentPickerUI = (*AccentPicker)(nil)
45+
46+// NewAccentPicker creates a new accent picker component.
47+func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker {
48+ // Create container box
49+ container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing)
50+ container.AddCssClass("accent-picker")
51+
52+ // Position at bottom-center
53+ container.SetHalign(gtk.AlignCenterValue)
54+ container.SetValign(gtk.AlignEndValue)
55+
56+ // Don't expand
57+ container.SetHexpand(false)
58+ container.SetVexpand(false)
59+
60+ // Make focusable so it can receive keyboard events
61+ container.SetFocusable(true)
62+ container.SetCanFocus(true)
63+
64+ // Hidden by default
65+ container.SetVisible(false)
66+
67+ ap := &AccentPicker{
68+ factory: factory,
69+ container: container,
70+ selectedIndex: 0,
71+ }
72+
73+ // Set up key event handling
74+ ap.setupKeyboardHandling()
75+
76+ // Set up hover-to-focus: regain focus when mouse enters
77+ ap.setupHoverFocus()
78+
79+ return ap
80+}
81+
82+// Widget returns the accent picker's container widget.
83+func (ap *AccentPicker) Widget() layout.Widget {
84+ return ap.container
85+}
86+
87+// Show displays the accent picker with the given accent options.
88+func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) {
89+ ap.mu.Lock()
90+ defer ap.mu.Unlock()
91+
92+ ap.accents = accents
93+ ap.selectedCb = selectedCb
94+ ap.cancelCb = cancelCb
95+ ap.selectedIndex = 0
96+ ap.visible = true
97+
98+ // Clear existing labels
99+ ap.clearLabels()
100+
101+ // Create new labels for each accent
102+ ap.accentLabels = make([]layout.LabelWidget, len(accents))
103+ ap.retainedClicked = make([]interface{}, len(accents))
104+
105+ for i, accent := range accents {
106+ label := ap.factory.NewLabel(string(accent))
107+ label.AddCssClass("accent-picker-item")
108+
109+ // Add number hint for first maxNumberedAccentItems items
110+ if i < maxNumberedAccentItems {
111+ label.AddCssClass("accent-picker-numbered")
112+ }
113+
114+ // Set up click handling
115+ clickCtrl := gtk.NewGestureClick()
116+ idx := i // Capture for closure
117+ clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) {
118+ ap.selectAccent(idx)
119+ }
120+ clickCtrl.ConnectPressed(&clickCb)
121+ ap.retainedClicked[i] = clickCb
122+ label.AddController(&clickCtrl.EventController)
123+
124+ ap.accentLabels[i] = label
125+ ap.container.Append(label)
126+ }
127+
128+ // Highlight first item
129+ ap.updateSelection()
130+
131+ ap.container.SetVisible(true)
132+ ap.container.GrabFocus()
133+}
134+
135+// Hide hides the accent picker.
136+func (ap *AccentPicker) Hide() {
137+ ap.mu.Lock()
138+ defer ap.mu.Unlock()
139+
140+ ap.visible = false
141+ ap.container.SetVisible(false)
142+ ap.clearLabels()
143+}
144+
145+// IsVisible returns true if the accent picker is currently visible.
146+func (ap *AccentPicker) IsVisible() bool {
147+ ap.mu.Lock()
148+ defer ap.mu.Unlock()
149+ return ap.visible
150+}
151+
152+// clearLabels removes all accent labels from the container.
153+func (ap *AccentPicker) clearLabels() {
154+ for _, label := range ap.accentLabels {
155+ label.Unparent()
156+ }
157+ ap.accentLabels = nil
158+ ap.retainedClicked = nil
159+}
160+
161+// updateSelection updates the visual selection highlight.
162+func (ap *AccentPicker) updateSelection() {
163+ for i, label := range ap.accentLabels {
164+ if i == ap.selectedIndex {
165+ label.AddCssClass("accent-picker-selected")
166+ } else {
167+ label.RemoveCssClass("accent-picker-selected")
168+ }
169+ }
170+}
171+
172+// selectAccent selects the accent at the given index.
173+func (ap *AccentPicker) selectAccent(index int) {
174+ ap.mu.Lock()
175+ if index < 0 || index >= len(ap.accents) || ap.selectedCb == nil {
176+ ap.mu.Unlock()
177+ return
178+ }
179+ accent := ap.accents[index]
180+ // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
181+ ap.selectedCb(accent)
182+ ap.mu.Unlock()
183+}
184+
185+// cancel cancels the accent picker.
186+func (ap *AccentPicker) cancel() {
187+ ap.mu.Lock()
188+ if ap.cancelCb == nil {
189+ ap.mu.Unlock()
190+ return
191+ }
192+ // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
193+ ap.cancelCb()
194+ ap.mu.Unlock()
195+}
196+
197+// setupKeyboardHandling sets up key event handling for navigation.
198+func (ap *AccentPicker) setupKeyboardHandling() {
199+ keyCtrl := gtk.NewEventControllerKey()
200+
201+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
202+ return ap.handleKeyPress(keyval)
203+ }
204+
205+ keyCtrl.ConnectKeyPressed(&keyPressedCb)
206+ ap.keyController = keyCtrl
207+ ap.container.AddController(&keyCtrl.EventController)
208+}
209+
210+// setupHoverFocus sets up mouse hover to regain focus.
211+// This ensures the picker regains focus when the mouse enters,
212+// so keyboard navigation (including Escape) continues to work.
213+func (ap *AccentPicker) setupHoverFocus() {
214+ motionCtrl := gtk.NewEventControllerMotion()
215+
216+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
217+ ap.mu.Lock()
218+ visible := ap.visible
219+ ap.mu.Unlock()
220+
221+ if visible {
222+ ap.container.GrabFocus()
223+ }
224+ }
225+
226+ motionCtrl.ConnectEnter(&enterCb)
227+ ap.container.AddController(&motionCtrl.EventController)
228+}
229+
230+// handleKeyPress processes a key press event.
231+// Returns true if the key was handled.
232+func (ap *AccentPicker) handleKeyPress(keyval uint) bool {
233+ ap.mu.Lock()
234+ if !ap.visible {
235+ ap.mu.Unlock()
236+ return false
237+ }
238+
239+ // Handle navigation keys
240+ if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn {
241+ return handled
242+ }
243+
244+ // Handle number keys for direct selection
245+ if idx, ok := ap.numberKeyToIndex(keyval); ok {
246+ ap.mu.Unlock()
247+ ap.selectAccent(idx)
248+ return true
249+ }
250+
251+ ap.mu.Unlock()
252+ return false
253+}
254+
255+// handleNavigationKey handles arrow keys, Enter, and Escape.
256+// Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled.
257+func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) {
258+ switch keyval {
259+ case uint(gdk.KEY_Left):
260+ if ap.selectedIndex > 0 {
261+ ap.selectedIndex--
262+ ap.updateSelection()
263+ }
264+ ap.mu.Unlock()
265+ return true, true
266+
267+ case uint(gdk.KEY_Right):
268+ if ap.selectedIndex < len(ap.accents)-1 {
269+ ap.selectedIndex++
270+ ap.updateSelection()
271+ }
272+ ap.mu.Unlock()
273+ return true, true
274+
275+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
276+ idx := ap.selectedIndex
277+ ap.mu.Unlock()
278+ ap.selectAccent(idx)
279+ return true, true
280+
281+ case uint(gdk.KEY_Escape):
282+ ap.mu.Unlock()
283+ ap.cancel()
284+ return true, true
285+
286+ default:
287+ return false, false
288+ }
289+}
290+
291+// numberKeyToIndex converts a number key (1-9) to an accent index.
292+// Returns the index and true if the key is a valid number key.
293+func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) {
294+ keyMap := map[uint]int{
295+ uint(gdk.KEY_1): 0,
296+ uint(gdk.KEY_KP_1): 0,
297+ uint(gdk.KEY_2): 1,
298+ uint(gdk.KEY_KP_2): 1,
299+ uint(gdk.KEY_3): 2,
300+ uint(gdk.KEY_KP_3): 2,
301+ uint(gdk.KEY_4): 3,
302+ uint(gdk.KEY_KP_4): 3,
303+ uint(gdk.KEY_5): 4,
304+ uint(gdk.KEY_KP_5): 4,
305+ uint(gdk.KEY_6): 5,
306+ uint(gdk.KEY_KP_6): 5,
307+ uint(gdk.KEY_7): 6,
308+ uint(gdk.KEY_KP_7): 6,
309+ uint(gdk.KEY_8): 7,
310+ uint(gdk.KEY_KP_8): 7,
311+ uint(gdk.KEY_9): 8,
312+ uint(gdk.KEY_KP_9): 8,
313+ }
314+
315+ if idx, ok := keyMap[keyval]; ok {
316+ return idx, true
317+ }
318+ return 0, false
319+}
M internal/ui/component/find_bar.go +43 -4
@@ -39,16 +39,19 @@ type FindBar struct {
3939
4040 uc *usecase.FindInPageUseCase
4141
42- visible bool
43- mu sync.RWMutex
44- ctx context.Context
45- onClose func()
42+ visible bool
43+ mu sync.RWMutex
44+ ctx context.Context
45+ onClose func()
46+ retainedCallbacks []interface{} // Keep callbacks alive for GC
4647 }
4748
4849 // FindBarConfig holds configuration for creating a FindBar.
4950 type FindBarConfig struct {
5051 OnClose func()
5152 GetFindController func(paneID entity.PaneID) port.FindController
53+ OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
54+ OnFocusOut func() // Callback when entry loses focus
5255 }
5356
5457 // NewFindBar creates a new FindBar component.
@@ -68,6 +71,7 @@ func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar {
6871
6972 fb.setupHandlers()
7073 fb.bindUseCase()
74+ fb.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
7175
7276 log.Debug().Msg("find bar created")
7377 return fb
@@ -405,3 +409,38 @@ func (fb *FindBar) bindUseCase() {
405409 glib.IdleAdd(&cb, 0)
406410 })
407411 }
412+
413+// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
414+func (fb *FindBar) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
415+ if onFocusIn == nil && onFocusOut == nil {
416+ return
417+ }
418+
419+ if fb.entry == nil {
420+ return
421+ }
422+
423+ focusController := gtk.NewEventControllerFocus()
424+ if focusController == nil {
425+ return
426+ }
427+
428+ if onFocusIn != nil {
429+ entry := fb.entry // Capture for closure
430+ focusInCb := func(_ gtk.EventControllerFocus) {
431+ onFocusIn(entry)
432+ }
433+ fb.retainedCallbacks = append(fb.retainedCallbacks, focusInCb)
434+ focusController.ConnectEnter(&focusInCb)
435+ }
436+
437+ if onFocusOut != nil {
438+ focusOutCb := func(_ gtk.EventControllerFocus) {
439+ onFocusOut()
440+ }
441+ fb.retainedCallbacks = append(fb.retainedCallbacks, focusOutCb)
442+ focusController.ConnectLeave(&focusOutCb)
443+ }
444+
445+ fb.entry.AddController(&focusController.EventController)
446+}
M internal/ui/component/omnibox.go +34 -0
@@ -132,6 +132,8 @@ type OmniboxConfig struct {
132132 UIScale float64 // UI scale for favicon sizing
133133 OnNavigate func(url string) // Callback when user navigates via omnibox
134134 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
135+ OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
136+ OnFocusOut func() // Callback when entry loses focus
135137 }
136138
137139 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -166,6 +168,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
166168
167169 o.setupKeyboardHandling()
168170 o.setupEntryChanged()
171+ o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
169172
170173 // Set navigation callback if provided
171174 if cfg.OnNavigate != nil {
@@ -459,6 +462,37 @@ func (o *Omnibox) setupEntryChanged() {
459462 o.entry.ConnectSearchChanged(&changedCb)
460463 }
461464
465+// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
466+func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
467+ if onFocusIn == nil && onFocusOut == nil {
468+ return
469+ }
470+
471+ focusController := gtk.NewEventControllerFocus()
472+ if focusController == nil {
473+ return
474+ }
475+
476+ if onFocusIn != nil {
477+ entry := o.entry // Capture for closure
478+ focusInCb := func(_ gtk.EventControllerFocus) {
479+ onFocusIn(entry)
480+ }
481+ o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
482+ focusController.ConnectEnter(&focusInCb)
483+ }
484+
485+ if onFocusOut != nil {
486+ focusOutCb := func(_ gtk.EventControllerFocus) {
487+ onFocusOut()
488+ }
489+ o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
490+ focusController.ConnectLeave(&focusOutCb)
491+ }
492+
493+ o.entry.AddController(&focusController.EventController)
494+}
495+
462496 // handleKeyPress processes keyboard events.
463497 // Returns true if the event was handled.
464498 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
M internal/ui/coordinator/content.go +267 -22
@@ -21,12 +21,15 @@ import (
2121 "github.com/jwijenbergh/puregotk/v4/gdk"
2222 )
2323
24+const aboutBlankURI = "about:blank"
25+
2426 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
2527 type ContentCoordinator struct {
2628 pool *webkit.WebViewPool
2729 widgetFactory layout.WidgetFactory
2830 faviconAdapter *adapter.FaviconAdapter
2931 zoomUC *usecase.ManageZoomUseCase
32+ injector *webkit.ContentInjector
3033
3134 webViews map[entity.PaneID]*webkit.WebView
3235 paneTitles map[entity.PaneID]string
@@ -58,6 +61,14 @@ type ContentCoordinator struct {
5861 revealMu sync.Mutex
5962 pendingReveal map[entity.PaneID]bool
6063
64+ appearanceMu sync.Mutex
65+ pendingScriptRefresh map[entity.PaneID]bool
66+ pendingThemePanes map[entity.PaneID]bool
67+ pendingThemeUpdate pendingThemeUpdate
68+ hasPendingThemeUpdate bool
69+ currentTheme pendingThemeUpdate
70+ hasCurrentTheme bool
71+
6172 // Gesture action handler for mouse button navigation
6273 gestureActionHandler input.ActionHandler
6374
@@ -81,6 +92,14 @@ type ContentCoordinator struct {
8192
8293 // Callback when fullscreen state changes (for hiding/showing tab bar)
8394 onFullscreenChanged func(entering bool)
95+
96+ // Callback when WebView gains focus (for accent picker text input targeting)
97+ onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
98+}
99+
100+type pendingThemeUpdate struct {
101+ prefersDark bool
102+ cssText string
84103 }
85104
86105 // NewContentCoordinator creates a new ContentCoordinator.
@@ -149,6 +168,11 @@ func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
149168 c.onFullscreenChanged = fn
150169 }
151170
171+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
172+func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
173+ c.onWebViewFocused = fn
174+}
175+
152176 // EnsureWebView acquires or reuses a WebView for the given pane.
153177 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
154178 log := logging.FromContext(ctx)
@@ -186,7 +210,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
186210 case webkit.LoadCommitted:
187211 c.onLoadCommitted(ctx, paneID, wv)
188212 case webkit.LoadFinished:
189- c.onLoadFinished(paneID)
213+ c.onLoadFinished(ctx, paneID, wv)
190214 }
191215 }
192216
@@ -234,6 +258,7 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
234258 return
235259 }
236260 delete(c.webViews, paneID)
261+ c.clearPendingAppearance(paneID)
237262
238263 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
239264 // we must release the inhibition before destroying the webview.
@@ -403,24 +428,26 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.
403428 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
404429 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
405430 // the scripts so future navigations pick up the latest values.
431+// Script refresh is deferred for any WebView that is currently loading to avoid
432+// removing scripts mid-navigation.
406433 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
407434 log := logging.FromContext(ctx)
408435 if injector == nil {
409436 return
410437 }
411438
439+ c.injector = injector
412440 for paneID, wv := range c.webViews {
413441 if wv == nil || wv.IsDestroyed() {
414442 continue
415443 }
416- ucm := wv.UserContentManager()
417- if ucm == nil {
444+ if c.shouldDeferAppearance(wv) {
445+ c.queueScriptRefresh(paneID)
446+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
418447 continue
419448 }
420449
421- ucm.RemoveAllScripts()
422- injector.InjectScripts(ctx, ucm, wv.ID())
423- log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
450+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
424451 }
425452 }
426453
@@ -432,13 +459,33 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
432459 return
433460 }
434461
435- // Safely embed CSS in JS
436- cssJSON, err := json.Marshal(cssText)
462+ c.setCurrentTheme(prefersDark, cssText)
463+
464+ script, err := buildWebUIThemeScript(prefersDark, cssText)
437465 if err != nil {
438- log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
466+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
439467 return
440468 }
441469
470+ for paneID, wv := range c.webViews {
471+ if wv == nil || wv.IsDestroyed() {
472+ continue
473+ }
474+ if c.shouldDeferAppearance(wv) {
475+ c.queueThemeApply(paneID, prefersDark, cssText)
476+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
477+ continue
478+ }
479+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
480+ }
481+}
482+
483+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
484+ cssJSON, err := json.Marshal(cssText)
485+ if err != nil {
486+ return "", err
487+ }
488+
442489 script := fmt.Sprintf(`(function(){
443490 try {
444491 var cssText = %s;
@@ -487,17 +534,185 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
487534 }
488535 })();`, string(cssJSON), prefersDark)
489536
490- for paneID, wv := range c.webViews {
491- if wv == nil || wv.IsDestroyed() {
492- continue
493- }
494- uri := wv.URI()
495- if !strings.HasPrefix(uri, "dumb://") {
496- continue
537+ return script, nil
538+}
539+
540+func (c *ContentCoordinator) applyWebUITheme(
541+ ctx context.Context,
542+ paneID entity.PaneID,
543+ wv *webkit.WebView,
544+ script string,
545+ prefersDark bool,
546+) {
547+ if wv == nil || wv.IsDestroyed() {
548+ return
549+ }
550+ uri := wv.URI()
551+ if !strings.HasPrefix(uri, "dumb://") {
552+ return
553+ }
554+ wv.RunJavaScript(ctx, script, "")
555+ logging.FromContext(ctx).
556+ Debug().
557+ Str("pane_id", string(paneID)).
558+ Str("uri", uri).
559+ Bool("prefers_dark", prefersDark).
560+ Msg("applied WebUI theme")
561+}
562+
563+func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
564+ c.appearanceMu.Lock()
565+ if c.pendingThemePanes == nil {
566+ c.pendingThemePanes = make(map[entity.PaneID]bool)
567+ }
568+ c.pendingThemePanes[paneID] = true
569+ c.pendingThemeUpdate = pendingThemeUpdate{
570+ prefersDark: prefersDark,
571+ cssText: cssText,
572+ }
573+ c.hasPendingThemeUpdate = true
574+ c.appearanceMu.Unlock()
575+}
576+
577+func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
578+ c.appearanceMu.Lock()
579+ c.currentTheme = pendingThemeUpdate{
580+ prefersDark: prefersDark,
581+ cssText: cssText,
582+ }
583+ c.hasCurrentTheme = true
584+ c.appearanceMu.Unlock()
585+}
586+
587+func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
588+ c.appearanceMu.Lock()
589+ defer c.appearanceMu.Unlock()
590+
591+ if !c.hasCurrentTheme {
592+ return pendingThemeUpdate{}, false
593+ }
594+ return c.currentTheme, true
595+}
596+
597+func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
598+ c.appearanceMu.Lock()
599+ defer c.appearanceMu.Unlock()
600+
601+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
602+ return pendingThemeUpdate{}, false
603+ }
604+ delete(c.pendingThemePanes, paneID)
605+ update := c.pendingThemeUpdate
606+ if len(c.pendingThemePanes) == 0 {
607+ c.hasPendingThemeUpdate = false
608+ }
609+ return update, true
610+}
611+
612+func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
613+ update, ok := c.takePendingThemeApply(paneID)
614+ if !ok {
615+ return false
616+ }
617+
618+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
619+ if err != nil {
620+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
621+ return false
622+ }
623+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
624+ return true
625+}
626+
627+func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
628+ update, ok := c.getCurrentTheme()
629+ if !ok || update.cssText == "" {
630+ return false
631+ }
632+
633+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
634+ if err != nil {
635+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
636+ return false
637+ }
638+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
639+ return true
640+}
641+
642+func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
643+ c.appearanceMu.Lock()
644+ if c.pendingScriptRefresh == nil {
645+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
646+ }
647+ c.pendingScriptRefresh[paneID] = true
648+ c.appearanceMu.Unlock()
649+}
650+
651+func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
652+ c.appearanceMu.Lock()
653+ defer c.appearanceMu.Unlock()
654+
655+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
656+ return false
657+ }
658+ delete(c.pendingScriptRefresh, paneID)
659+ return true
660+}
661+
662+func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
663+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
664+ return
665+ }
666+ if !c.takePendingScriptRefresh(paneID) {
667+ return
668+ }
669+ if c.injector == nil {
670+ return
671+ }
672+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
673+}
674+
675+func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
676+ if wv == nil || wv.IsDestroyed() {
677+ return false
678+ }
679+ if wv.IsLoading() {
680+ return true
681+ }
682+ return wv.EstimatedProgress() < 1.0
683+}
684+
685+func (c *ContentCoordinator) refreshInjectedScripts(
686+ ctx context.Context,
687+ injector *webkit.ContentInjector,
688+ paneID entity.PaneID,
689+ wv *webkit.WebView,
690+) {
691+ if injector == nil || wv == nil || wv.IsDestroyed() {
692+ return
693+ }
694+ ucm := wv.UserContentManager()
695+ if ucm == nil {
696+ return
697+ }
698+ ucm.RemoveAllScripts()
699+ ucm.RemoveAllStyleSheets()
700+ injector.InjectScripts(ctx, ucm, wv.ID())
701+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
702+}
703+
704+func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
705+ c.appearanceMu.Lock()
706+ if c.pendingScriptRefresh != nil {
707+ delete(c.pendingScriptRefresh, paneID)
708+ }
709+ if c.pendingThemePanes != nil {
710+ delete(c.pendingThemePanes, paneID)
711+ if len(c.pendingThemePanes) == 0 {
712+ c.hasPendingThemeUpdate = false
497713 }
498- wv.RunJavaScript(ctx, script, "")
499- log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
500714 }
715+ c.appearanceMu.Unlock()
501716 }
502717
503718 // GetTitle returns the current title for a pane.
@@ -704,13 +919,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
704919 // Skip showing if this is about:blank but the pane is loading a different URL
705920 // This prevents the brief flash of about:blank during initial navigation
706921 shouldShow := true
707- if url == "about:blank" {
922+ if url == aboutBlankURI {
708923 // Get the pane's intended URI from the workspace
709924 ws, _ := c.getActiveWS()
710925 if ws != nil {
711926 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712927 // Don't show about:blank if the pane is supposed to load a different URL
713- if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {
928+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
714929 shouldShow = false
715930 log.Debug().
716931 Str("pane_id", string(paneID)).
@@ -729,6 +944,10 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
729944 return
730945 }
731946
947+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
948+ c.applyCurrentTheme(ctx, paneID, wv)
949+ }
950+
732951 c.markPendingReveal(paneID)
733952 if wv.EstimatedProgress() > 0 {
734953 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
@@ -755,6 +974,27 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
755974 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
756975 }
757976
977+func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
978+ if wv == nil || wv.IsDestroyed() {
979+ return false
980+ }
981+ if wv.URI() != aboutBlankURI {
982+ return false
983+ }
984+ ws, _ := c.getActiveWS()
985+ if ws == nil {
986+ return false
987+ }
988+ paneNode := ws.FindPane(paneID)
989+ if paneNode == nil || paneNode.Pane == nil {
990+ return false
991+ }
992+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
993+ return true
994+ }
995+ return false
996+}
997+
758998 // onSPANavigation records history when URL changes via JavaScript (History API).
759999 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
7601000 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
@@ -792,7 +1032,7 @@ func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
7921032 }
7931033
7941034 // onLoadFinished hides the progress bar when page loading completes.
795-func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
1035+func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
7961036 _, wsView := c.getActiveWS()
7971037 if wsView == nil {
7981038 return
@@ -804,6 +1044,11 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
8041044 }
8051045
8061046 c.revealIfPending(context.Background(), paneID, "", "load-finished")
1047+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1048+ return
1049+ }
1050+ c.applyPendingThemeUpdate(ctx, paneID, wv)
1051+ c.refreshPendingScripts(ctx, paneID, wv)
8071052 }
8081053
8091054 // onProgressChanged updates the progress bar with current load progress.
@@ -1150,7 +1395,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
11501395 case webkit.LoadCommitted:
11511396 c.onLoadCommitted(ctx, paneID, wv)
11521397 case webkit.LoadFinished:
1153- c.onLoadFinished(paneID)
1398+ c.onLoadFinished(ctx, paneID, wv)
11541399 }
11551400 }
11561401
M internal/ui/deps.go +8 -2
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1112 "github.com/bnema/dumber/internal/infrastructure/config"
1213 "github.com/bnema/dumber/internal/infrastructure/favicon"
1314 "github.com/bnema/dumber/internal/infrastructure/filtering"
@@ -25,8 +26,10 @@ type Dependencies struct {
2526 RestoreSessionID string // Session ID to restore on startup (optional)
2627 OnFirstWebViewShown func(context.Context)
2728
28- // Theme management
29- Theme *theme.Manager
29+ // Theme and color scheme management
30+ Theme *theme.Manager
31+ ColorResolver port.ColorSchemeResolver
32+ AdwaitaDetector *colorscheme.AdwaitaDetector
3033
3134 // WebKit infrastructure
3235 WebContext *webkit.WebKitContext
@@ -57,6 +60,9 @@ type Dependencies struct {
5760 FilterManager *filtering.Manager
5861 IdleInhibitor port.IdleInhibitor
5962
63+ // Accent picker for dead keys support
64+ InsertAccentUC *usecase.InsertAccentUseCase
65+
6066 // Session management
6167 SessionStateRepo repository.SessionStateRepository
6268 SessionRepo repository.SessionRepository
M internal/ui/input/keyboard.go +108 -26
@@ -17,6 +17,20 @@ import (
1717 // Return an error if the action fails.
1818 type ActionHandler func(ctx context.Context, action Action) error
1919
20+// AccentHandler handles long-press accent detection.
21+// Called for character keys that may have accent variants.
22+type AccentHandler interface {
23+ // OnKeyPressed is called when a key is pressed.
24+ // Returns true if the accent handler is handling this key (picker visible or will show).
25+ OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
26+ // OnKeyReleased is called when a key is released.
27+ OnKeyReleased(ctx context.Context, char rune)
28+ // IsPickerVisible returns true if the accent picker is currently visible.
29+ IsPickerVisible() bool
30+ // Cancel cancels any pending long-press detection.
31+ Cancel(ctx context.Context)
32+}
33+
2034 // KeyboardHandler processes keyboard events and dispatches actions.
2135 // It manages modal input modes and routes key events to the appropriate handlers.
2236 type KeyboardHandler struct {
@@ -28,12 +42,15 @@ type KeyboardHandler struct {
2842 onAction ActionHandler
2943 // Optional bypass check (e.g., omnibox visible)
3044 shouldBypass func() bool
45+ // Optional accent handler for dead keys support
46+ accentHandler AccentHandler
3147
3248 // GTK controller (nil until attached)
3349 controller *gtk.EventControllerKey
3450
3551 // Callback retention: must stay reachable by Go GC.
36- keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
52+ keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
53+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
3754
3855 ctx context.Context
3956 mu sync.RWMutex
@@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
7592 h.shouldBypass = fn
7693 }
7794
95+// SetAccentHandler sets the handler for long-press accent detection.
96+func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
97+ h.mu.Lock()
98+ defer h.mu.Unlock()
99+ h.accentHandler = handler
100+}
101+
78102 // Mode returns the current input mode.
79103 func (h *KeyboardHandler) Mode() Mode {
80104 return h.modal.Mode()
@@ -106,6 +130,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
106130 }
107131 h.controller.ConnectKeyPressed(&h.keyPressedCb)
108132
133+ // Connect key released handler for accent detection.
134+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
135+ h.handleKeyRelease(keyval)
136+ }
137+ h.controller.ConnectKeyReleased(&h.keyReleasedCb)
138+
109139 // Add controller to window
110140 window.AddController(&h.controller.EventController)
111141
@@ -118,6 +148,7 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
118148 func (h *KeyboardHandler) Detach() {
119149 h.controller = nil
120150 h.keyPressedCb = nil
151+ h.keyReleasedCb = nil
121152 }
122153
123154 // handleKeyPress processes a key press event.
@@ -131,45 +162,71 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
131162
132163 h.mu.RLock()
133164 shouldBypass := h.shouldBypass
165+ accentHandler := h.accentHandler
134166 h.mu.RUnlock()
135- if shouldBypass != nil && shouldBypass() {
136- log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
137- return false
167+
168+ // Check if accent picker is visible - it takes priority
169+ if accentHandler != nil && accentHandler.IsPickerVisible() {
170+ return false // Let the accent picker handle the key via its own controller
138171 }
139172
140- // Build KeyBinding from event
141173 modifiers := Modifier(state) & modifierMask
142174
143- // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144- // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145- // keyvals and rely on ModShift to represent Shift.
146- if keyval >= uint('A') && keyval <= uint('Z') {
147- keyval += (uint('a') - uint('A'))
175+ // Check accent detection first - works in both normal and bypass mode
176+ // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView
177+ if h.tryAccentDetection(accentHandler, keyval, modifiers) {
178+ return true
148179 }
149180
150- binding := KeyBinding{
151- Keyval: keyval,
152- Modifiers: modifiers,
181+ if shouldBypass != nil && shouldBypass() {
182+ log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
183+ return false
153184 }
154185
155- mode := h.modal.Mode()
186+ // Normalize uppercase letters for consistent binding lookup
187+ keyval = normalizeKeyval(keyval)
156188
189+ binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
190+ mode := h.modal.Mode()
157191 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
158192
159193 if !found {
160- if mode != ModeNormal {
161- // In modal mode, consume unrecognized keys to prevent WebView handling
162- return true
163- }
164- // In normal mode, let the key pass through to WebView
194+ return mode != ModeNormal // Consume unrecognized keys in modal mode
195+ }
196+
197+ return h.dispatchAction(action, mode)
198+}
199+
200+// tryAccentDetection starts long-press detection for accent-eligible keys.
201+// Returns true if the key should be suppressed (blocked from reaching the WebView).
202+func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
203+ if h.modal.Mode() != ModeNormal || accentHandler == nil {
204+ return false
205+ }
206+ // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
207+ if modifiers != 0 && modifiers != ModShift {
165208 return false
166209 }
210+ if char := keyvalToRune(keyval); char != 0 {
211+ return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
212+ }
213+ return false
214+}
215+
216+// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
217+func normalizeKeyval(keyval uint) uint {
218+ if keyval >= uint('A') && keyval <= uint('Z') {
219+ return keyval + (uint('a') - uint('A'))
220+ }
221+ return keyval
222+}
167223
224+// dispatchAction dispatches the action and handles mode-related logic.
225+func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
168226 if h.handleModeAction(action) {
169227 return true
170228 }
171229
172- // Dispatch action to handler
173230 h.mu.RLock()
174231 handler := h.onAction
175232 h.mu.RUnlock()
@@ -177,19 +234,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
177234 if handler != nil {
178235 if err := handler(h.ctx, action); err != nil {
179236 log := logging.FromContext(h.ctx)
180- log.Error().
181- Err(err).
182- Str("action", string(action)).
183- Msg("action handler error")
237+ log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
184238 }
185239 }
186240
187- // In resize mode, any resize keypress should extend the timeout.
188241 if mode == ModeResize && isResizeAction(action) {
189242 h.modal.ResetTimeout(h.ctx)
190243 }
191244
192- // Auto-exit modal mode after certain actions
193245 if mode != ModeNormal && ShouldAutoExitMode(action) {
194246 h.modal.ExitMode(h.ctx)
195247 }
@@ -314,3 +366,33 @@ func (h *KeyboardHandler) EnterSessionMode() {
314366 func (h *KeyboardHandler) ExitMode() {
315367 h.modal.ExitMode(h.ctx)
316368 }
369+
370+// handleKeyRelease processes a key release event for accent detection.
371+func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
372+ h.mu.RLock()
373+ accentHandler := h.accentHandler
374+ h.mu.RUnlock()
375+
376+ if accentHandler == nil {
377+ return
378+ }
379+
380+ // Convert keyval to rune for the accent handler
381+ if char := keyvalToRune(keyval); char != 0 {
382+ accentHandler.OnKeyReleased(h.ctx, char)
383+ }
384+}
385+
386+// keyvalToRune converts a GTK keyval to a lowercase rune.
387+// Returns 0 if the keyval is not a printable character with potential accents.
388+func keyvalToRune(keyval uint) rune {
389+ // Handle lowercase a-z
390+ if keyval >= uint('a') && keyval <= uint('z') {
391+ return rune(keyval)
392+ }
393+ // Handle uppercase A-Z (convert to lowercase for accent lookup)
394+ if keyval >= uint('A') && keyval <= uint('Z') {
395+ return rune(keyval + ('a' - 'A'))
396+ }
397+ return 0
398+}
M internal/ui/layout/gtk_widgets.go +9 -0
@@ -34,6 +34,7 @@ func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible()
3434 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
3535 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
3636 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
37+func (w *gtkWidget) SetFocusable(focusable bool) { w.inner.SetFocusable(focusable) }
3738 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }
3839 func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) }
3940 func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) }
@@ -89,6 +90,7 @@ func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible()
8990 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
9091 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
9192 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
93+func (p *gtkPaned) SetFocusable(focusable bool) { p.inner.SetFocusable(focusable) }
9294 func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
9395 func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
9496 func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
@@ -210,6 +212,7 @@ func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }
210212 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
211213 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
212214 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
215+func (b *gtkBox) SetFocusable(focusable bool) { b.inner.SetFocusable(focusable) }
213216 func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) }
214217 func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) }
215218 func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) }
@@ -315,6 +318,7 @@ func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible(
315318 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }
316319 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
317320 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
321+func (o *gtkOverlay) SetFocusable(focusable bool) { o.inner.SetFocusable(focusable) }
318322 func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) }
319323 func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) }
320324 func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) }
@@ -428,6 +432,7 @@ func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible()
428432 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
429433 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
430434 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
435+func (l *gtkLabel) SetFocusable(focusable bool) { l.inner.SetFocusable(focusable) }
431436 func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) }
432437 func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) }
433438 func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) }
@@ -490,6 +495,7 @@ func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisib
490495 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }
491496 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
492497 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
498+func (btn *gtkButton) SetFocusable(focusable bool) { btn.inner.SetFocusable(focusable) }
493499 func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) }
494500 func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) }
495501 func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) }
@@ -571,6 +577,7 @@ func (img *gtkImage) IsVisible() bool { return img.inner.GetVisibl
571577 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }
572578 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
573579 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
580+func (img *gtkImage) SetFocusable(focusable bool) { img.inner.SetFocusable(focusable) }
574581 func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) }
575582 func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) }
576583 func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) }
@@ -642,6 +649,7 @@ func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisi
642649 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }
643650 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }
644651 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
652+func (p *gtkProgressBar) SetFocusable(focusable bool) { p.inner.SetFocusable(focusable) }
645653 func (p *gtkProgressBar) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
646654 func (p *gtkProgressBar) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
647655 func (p *gtkProgressBar) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
@@ -700,6 +708,7 @@ func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible(
700708 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
701709 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
702710 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
711+func (s *gtkSpinner) SetFocusable(focusable bool) { s.inner.SetFocusable(focusable) }
703712 func (s *gtkSpinner) SetFocusOnClick(focus bool) { s.inner.SetFocusOnClick(focus) }
704713 func (s *gtkSpinner) SetCanTarget(canTarget bool) { s.inner.SetCanTarget(canTarget) }
705714 func (s *gtkSpinner) SetHexpand(expand bool) { s.inner.SetHexpand(expand) }
M internal/ui/layout/mocks/mocks.go +360 -0
@@ -908,6 +908,46 @@ func (_c *MockWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick bo
908908 return _c
909909 }
910910
911+// SetFocusable provides a mock function for the type MockWidget
912+func (_mock *MockWidget) SetFocusable(focusable bool) {
913+ _mock.Called(focusable)
914+ return
915+}
916+
917+// MockWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
918+type MockWidget_SetFocusable_Call struct {
919+ *mock.Call
920+}
921+
922+// SetFocusable is a helper method to define mock.On call
923+// - focusable bool
924+func (_e *MockWidget_Expecter) SetFocusable(focusable interface{}) *MockWidget_SetFocusable_Call {
925+ return &MockWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
926+}
927+
928+func (_c *MockWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockWidget_SetFocusable_Call {
929+ _c.Call.Run(func(args mock.Arguments) {
930+ var arg0 bool
931+ if args[0] != nil {
932+ arg0 = args[0].(bool)
933+ }
934+ run(
935+ arg0,
936+ )
937+ })
938+ return _c
939+}
940+
941+func (_c *MockWidget_SetFocusable_Call) Return() *MockWidget_SetFocusable_Call {
942+ _c.Call.Return()
943+ return _c
944+}
945+
946+func (_c *MockWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockWidget_SetFocusable_Call {
947+ _c.Run(run)
948+ return _c
949+}
950+
911951 // SetHalign provides a mock function for the type MockWidget
912952 func (_mock *MockWidget) SetHalign(align gtk.Align) {
913953 _mock.Called(align)
@@ -2586,6 +2626,46 @@ func (_c *MockPanedWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
25862626 return _c
25872627 }
25882628
2629+// SetFocusable provides a mock function for the type MockPanedWidget
2630+func (_mock *MockPanedWidget) SetFocusable(focusable bool) {
2631+ _mock.Called(focusable)
2632+ return
2633+}
2634+
2635+// MockPanedWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
2636+type MockPanedWidget_SetFocusable_Call struct {
2637+ *mock.Call
2638+}
2639+
2640+// SetFocusable is a helper method to define mock.On call
2641+// - focusable bool
2642+func (_e *MockPanedWidget_Expecter) SetFocusable(focusable interface{}) *MockPanedWidget_SetFocusable_Call {
2643+ return &MockPanedWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
2644+}
2645+
2646+func (_c *MockPanedWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockPanedWidget_SetFocusable_Call {
2647+ _c.Call.Run(func(args mock.Arguments) {
2648+ var arg0 bool
2649+ if args[0] != nil {
2650+ arg0 = args[0].(bool)
2651+ }
2652+ run(
2653+ arg0,
2654+ )
2655+ })
2656+ return _c
2657+}
2658+
2659+func (_c *MockPanedWidget_SetFocusable_Call) Return() *MockPanedWidget_SetFocusable_Call {
2660+ _c.Call.Return()
2661+ return _c
2662+}
2663+
2664+func (_c *MockPanedWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockPanedWidget_SetFocusable_Call {
2665+ _c.Run(run)
2666+ return _c
2667+}
2668+
25892669 // SetHalign provides a mock function for the type MockPanedWidget
25902670 func (_mock *MockPanedWidget) SetHalign(align gtk.Align) {
25912671 _mock.Called(align)
@@ -4339,6 +4419,46 @@ func (_c *MockBoxWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick
43394419 return _c
43404420 }
43414421
4422+// SetFocusable provides a mock function for the type MockBoxWidget
4423+func (_mock *MockBoxWidget) SetFocusable(focusable bool) {
4424+ _mock.Called(focusable)
4425+ return
4426+}
4427+
4428+// MockBoxWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
4429+type MockBoxWidget_SetFocusable_Call struct {
4430+ *mock.Call
4431+}
4432+
4433+// SetFocusable is a helper method to define mock.On call
4434+// - focusable bool
4435+func (_e *MockBoxWidget_Expecter) SetFocusable(focusable interface{}) *MockBoxWidget_SetFocusable_Call {
4436+ return &MockBoxWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
4437+}
4438+
4439+func (_c *MockBoxWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockBoxWidget_SetFocusable_Call {
4440+ _c.Call.Run(func(args mock.Arguments) {
4441+ var arg0 bool
4442+ if args[0] != nil {
4443+ arg0 = args[0].(bool)
4444+ }
4445+ run(
4446+ arg0,
4447+ )
4448+ })
4449+ return _c
4450+}
4451+
4452+func (_c *MockBoxWidget_SetFocusable_Call) Return() *MockBoxWidget_SetFocusable_Call {
4453+ _c.Call.Return()
4454+ return _c
4455+}
4456+
4457+func (_c *MockBoxWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockBoxWidget_SetFocusable_Call {
4458+ _c.Run(run)
4459+ return _c
4460+}
4461+
43424462 // SetHalign provides a mock function for the type MockBoxWidget
43434463 func (_mock *MockBoxWidget) SetHalign(align gtk.Align) {
43444464 _mock.Called(align)
@@ -5902,6 +6022,46 @@ func (_c *MockOverlayWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnC
59026022 return _c
59036023 }
59046024
6025+// SetFocusable provides a mock function for the type MockOverlayWidget
6026+func (_mock *MockOverlayWidget) SetFocusable(focusable bool) {
6027+ _mock.Called(focusable)
6028+ return
6029+}
6030+
6031+// MockOverlayWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
6032+type MockOverlayWidget_SetFocusable_Call struct {
6033+ *mock.Call
6034+}
6035+
6036+// SetFocusable is a helper method to define mock.On call
6037+// - focusable bool
6038+func (_e *MockOverlayWidget_Expecter) SetFocusable(focusable interface{}) *MockOverlayWidget_SetFocusable_Call {
6039+ return &MockOverlayWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
6040+}
6041+
6042+func (_c *MockOverlayWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockOverlayWidget_SetFocusable_Call {
6043+ _c.Call.Run(func(args mock.Arguments) {
6044+ var arg0 bool
6045+ if args[0] != nil {
6046+ arg0 = args[0].(bool)
6047+ }
6048+ run(
6049+ arg0,
6050+ )
6051+ })
6052+ return _c
6053+}
6054+
6055+func (_c *MockOverlayWidget_SetFocusable_Call) Return() *MockOverlayWidget_SetFocusable_Call {
6056+ _c.Call.Return()
6057+ return _c
6058+}
6059+
6060+func (_c *MockOverlayWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockOverlayWidget_SetFocusable_Call {
6061+ _c.Run(run)
6062+ return _c
6063+}
6064+
59056065 // SetHalign provides a mock function for the type MockOverlayWidget
59066066 func (_mock *MockOverlayWidget) SetHalign(align gtk.Align) {
59076067 _mock.Called(align)
@@ -7161,6 +7321,46 @@ func (_c *MockLabelWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
71617321 return _c
71627322 }
71637323
7324+// SetFocusable provides a mock function for the type MockLabelWidget
7325+func (_mock *MockLabelWidget) SetFocusable(focusable bool) {
7326+ _mock.Called(focusable)
7327+ return
7328+}
7329+
7330+// MockLabelWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
7331+type MockLabelWidget_SetFocusable_Call struct {
7332+ *mock.Call
7333+}
7334+
7335+// SetFocusable is a helper method to define mock.On call
7336+// - focusable bool
7337+func (_e *MockLabelWidget_Expecter) SetFocusable(focusable interface{}) *MockLabelWidget_SetFocusable_Call {
7338+ return &MockLabelWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
7339+}
7340+
7341+func (_c *MockLabelWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockLabelWidget_SetFocusable_Call {
7342+ _c.Call.Run(func(args mock.Arguments) {
7343+ var arg0 bool
7344+ if args[0] != nil {
7345+ arg0 = args[0].(bool)
7346+ }
7347+ run(
7348+ arg0,
7349+ )
7350+ })
7351+ return _c
7352+}
7353+
7354+func (_c *MockLabelWidget_SetFocusable_Call) Return() *MockLabelWidget_SetFocusable_Call {
7355+ _c.Call.Return()
7356+ return _c
7357+}
7358+
7359+func (_c *MockLabelWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockLabelWidget_SetFocusable_Call {
7360+ _c.Run(run)
7361+ return _c
7362+}
7363+
71647364 // SetHalign provides a mock function for the type MockLabelWidget
71657365 func (_mock *MockLabelWidget) SetHalign(align gtk.Align) {
71667366 _mock.Called(align)
@@ -8631,6 +8831,46 @@ func (_c *MockButtonWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCl
86318831 return _c
86328832 }
86338833
8834+// SetFocusable provides a mock function for the type MockButtonWidget
8835+func (_mock *MockButtonWidget) SetFocusable(focusable bool) {
8836+ _mock.Called(focusable)
8837+ return
8838+}
8839+
8840+// MockButtonWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
8841+type MockButtonWidget_SetFocusable_Call struct {
8842+ *mock.Call
8843+}
8844+
8845+// SetFocusable is a helper method to define mock.On call
8846+// - focusable bool
8847+func (_e *MockButtonWidget_Expecter) SetFocusable(focusable interface{}) *MockButtonWidget_SetFocusable_Call {
8848+ return &MockButtonWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
8849+}
8850+
8851+func (_c *MockButtonWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockButtonWidget_SetFocusable_Call {
8852+ _c.Call.Run(func(args mock.Arguments) {
8853+ var arg0 bool
8854+ if args[0] != nil {
8855+ arg0 = args[0].(bool)
8856+ }
8857+ run(
8858+ arg0,
8859+ )
8860+ })
8861+ return _c
8862+}
8863+
8864+func (_c *MockButtonWidget_SetFocusable_Call) Return() *MockButtonWidget_SetFocusable_Call {
8865+ _c.Call.Return()
8866+ return _c
8867+}
8868+
8869+func (_c *MockButtonWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockButtonWidget_SetFocusable_Call {
8870+ _c.Run(run)
8871+ return _c
8872+}
8873+
86348874 // SetHalign provides a mock function for the type MockButtonWidget
86358875 func (_mock *MockButtonWidget) SetHalign(align gtk.Align) {
86368876 _mock.Called(align)
@@ -9833,6 +10073,46 @@ func (_c *MockImageWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
983310073 return _c
983410074 }
983510075
10076+// SetFocusable provides a mock function for the type MockImageWidget
10077+func (_mock *MockImageWidget) SetFocusable(focusable bool) {
10078+ _mock.Called(focusable)
10079+ return
10080+}
10081+
10082+// MockImageWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
10083+type MockImageWidget_SetFocusable_Call struct {
10084+ *mock.Call
10085+}
10086+
10087+// SetFocusable is a helper method to define mock.On call
10088+// - focusable bool
10089+func (_e *MockImageWidget_Expecter) SetFocusable(focusable interface{}) *MockImageWidget_SetFocusable_Call {
10090+ return &MockImageWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
10091+}
10092+
10093+func (_c *MockImageWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockImageWidget_SetFocusable_Call {
10094+ _c.Call.Run(func(args mock.Arguments) {
10095+ var arg0 bool
10096+ if args[0] != nil {
10097+ arg0 = args[0].(bool)
10098+ }
10099+ run(
10100+ arg0,
10101+ )
10102+ })
10103+ return _c
10104+}
10105+
10106+func (_c *MockImageWidget_SetFocusable_Call) Return() *MockImageWidget_SetFocusable_Call {
10107+ _c.Call.Return()
10108+ return _c
10109+}
10110+
10111+func (_c *MockImageWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockImageWidget_SetFocusable_Call {
10112+ _c.Run(run)
10113+ return _c
10114+}
10115+
983610116 // SetFromFile provides a mock function for the type MockImageWidget
983710117 func (_mock *MockImageWidget) SetFromFile(filename string) {
983810118 _mock.Called(filename)
@@ -11166,6 +11446,46 @@ func (_c *MockProgressBarWidget_SetFocusOnClick_Call) RunAndReturn(run func(focu
1116611446 return _c
1116711447 }
1116811448
11449+// SetFocusable provides a mock function for the type MockProgressBarWidget
11450+func (_mock *MockProgressBarWidget) SetFocusable(focusable bool) {
11451+ _mock.Called(focusable)
11452+ return
11453+}
11454+
11455+// MockProgressBarWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
11456+type MockProgressBarWidget_SetFocusable_Call struct {
11457+ *mock.Call
11458+}
11459+
11460+// SetFocusable is a helper method to define mock.On call
11461+// - focusable bool
11462+func (_e *MockProgressBarWidget_Expecter) SetFocusable(focusable interface{}) *MockProgressBarWidget_SetFocusable_Call {
11463+ return &MockProgressBarWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
11464+}
11465+
11466+func (_c *MockProgressBarWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockProgressBarWidget_SetFocusable_Call {
11467+ _c.Call.Run(func(args mock.Arguments) {
11468+ var arg0 bool
11469+ if args[0] != nil {
11470+ arg0 = args[0].(bool)
11471+ }
11472+ run(
11473+ arg0,
11474+ )
11475+ })
11476+ return _c
11477+}
11478+
11479+func (_c *MockProgressBarWidget_SetFocusable_Call) Return() *MockProgressBarWidget_SetFocusable_Call {
11480+ _c.Call.Return()
11481+ return _c
11482+}
11483+
11484+func (_c *MockProgressBarWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockProgressBarWidget_SetFocusable_Call {
11485+ _c.Run(run)
11486+ return _c
11487+}
11488+
1116911489 // SetFraction provides a mock function for the type MockProgressBarWidget
1117011490 func (_mock *MockProgressBarWidget) SetFraction(fraction float64) {
1117111491 _mock.Called(fraction)
@@ -12379,6 +12699,46 @@ func (_c *MockSpinnerWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnC
1237912699 return _c
1238012700 }
1238112701
12702+// SetFocusable provides a mock function for the type MockSpinnerWidget
12703+func (_mock *MockSpinnerWidget) SetFocusable(focusable bool) {
12704+ _mock.Called(focusable)
12705+ return
12706+}
12707+
12708+// MockSpinnerWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
12709+type MockSpinnerWidget_SetFocusable_Call struct {
12710+ *mock.Call
12711+}
12712+
12713+// SetFocusable is a helper method to define mock.On call
12714+// - focusable bool
12715+func (_e *MockSpinnerWidget_Expecter) SetFocusable(focusable interface{}) *MockSpinnerWidget_SetFocusable_Call {
12716+ return &MockSpinnerWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
12717+}
12718+
12719+func (_c *MockSpinnerWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockSpinnerWidget_SetFocusable_Call {
12720+ _c.Call.Run(func(args mock.Arguments) {
12721+ var arg0 bool
12722+ if args[0] != nil {
12723+ arg0 = args[0].(bool)
12724+ }
12725+ run(
12726+ arg0,
12727+ )
12728+ })
12729+ return _c
12730+}
12731+
12732+func (_c *MockSpinnerWidget_SetFocusable_Call) Return() *MockSpinnerWidget_SetFocusable_Call {
12733+ _c.Call.Return()
12734+ return _c
12735+}
12736+
12737+func (_c *MockSpinnerWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockSpinnerWidget_SetFocusable_Call {
12738+ _c.Run(run)
12739+ return _c
12740+}
12741+
1238212742 // SetHalign provides a mock function for the type MockSpinnerWidget
1238312743 func (_mock *MockSpinnerWidget) SetHalign(align gtk.Align) {
1238412744 _mock.Called(align)
M internal/ui/layout/widgets.go +1 -0
@@ -28,6 +28,7 @@ type Widget interface {
2828 GrabFocus() bool
2929 HasFocus() bool
3030 SetCanFocus(canFocus bool)
31+ SetFocusable(focusable bool) // GTK4: makes widget able to receive keyboard focus
3132 SetFocusOnClick(focusOnClick bool)
3233
3334 // Pointer events
A internal/ui/theme/accent_picker_css.go +56 -0
@@ -0,0 +1,56 @@
1+package theme
2+
3+// generateAccentPickerCSS creates accent picker overlay styles.
4+// The picker displays a row of accented characters that the user can select.
5+func generateAccentPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Accent Picker Styling ===== */
8+
9+/* Main container - horizontal row of accent characters */
10+.accent-picker {
11+ background-color: alpha(var(--surface-variant), 0.95);
12+ border: 0.0625em solid var(--border);
13+ border-radius: 0.25em;
14+ padding: 0.375em 0.5em;
15+ margin-bottom: 1em;
16+ box-shadow: 0 2px 8px alpha(black, 0.2);
17+ transition: border-color 100ms ease-in-out;
18+}
19+
20+/* Focused state - accent border like omnibox */
21+.accent-picker:focus,
22+.accent-picker:focus-within,
23+.accent-picker:focus-visible {
24+ border-color: var(--accent);
25+ outline-style: none;
26+ outline-width: 0px;
27+ outline-color: transparent;
28+}
29+
30+/* Individual accent character label */
31+.accent-picker-item {
32+ font-size: 1.5em;
33+ font-weight: 500;
34+ color: var(--text);
35+ padding: 0.25em 0.5em;
36+ border-radius: 0.125em;
37+ min-width: 1.5em;
38+ transition: background-color 100ms ease-in-out;
39+}
40+
41+.accent-picker-item:hover {
42+ background-color: alpha(var(--accent), 0.15);
43+}
44+
45+/* Selected accent character */
46+.accent-picker-selected {
47+ background-color: alpha(var(--accent), 0.25);
48+ color: var(--accent);
49+}
50+
51+/* Numbered items (1-9) could show subtle number hints */
52+.accent-picker-numbered {
53+ /* Could add ::after pseudo-element for number hint if needed */
54+}
55+`
56+}
M internal/ui/theme/css.go +4 -0
@@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
126126
127127 // Tab picker styling
128128 sb.WriteString(generateTabPickerCSS(p))
129+ sb.WriteString("\n")
130+
131+ // Accent picker styling
132+ sb.WriteString(generateAccentPickerCSS(p))
129133
130134 return sb.String()
131135 }
M internal/ui/theme/manager.go +29 -21
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/infrastructure/config"
89 "github.com/bnema/dumber/internal/logging"
910 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -12,18 +13,20 @@ import (
1213
1314 // Manager handles theme state and CSS application.
1415 type Manager struct {
15- scheme string // "light", "dark", "system"
16- prefersDark bool // Resolved dark mode preference
17- lightPalette Palette // Light theme colors
18- darkPalette Palette // Dark theme colors
19- uiScale float64 // UI scaling factor (1.0 = 100%)
20- fonts FontConfig
21- modeColors ModeColors // Modal mode indicator colors
22- cssProvider *gtk.CssProvider
16+ scheme string // "light", "dark", "system"
17+ prefersDark bool // Resolved dark mode preference
18+ lightPalette Palette // Light theme colors
19+ darkPalette Palette // Dark theme colors
20+ uiScale float64 // UI scaling factor (1.0 = 100%)
21+ fonts FontConfig
22+ modeColors ModeColors // Modal mode indicator colors
23+ cssProvider *gtk.CssProvider
24+ colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
2325 }
2426
2527 // NewManager creates a new theme manager from configuration.
26-func NewManager(ctx context.Context, cfg *config.Config) *Manager {
28+// The ColorSchemeResolver is required for proper color scheme detection.
29+func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
2730 log := logging.FromContext(ctx)
2831
2932 // Determine color scheme preference
@@ -32,8 +35,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
3235 scheme = cfg.Appearance.ColorScheme
3336 }
3437
35- // Resolve whether we should use dark mode
36- prefersDark := ResolveColorScheme(scheme)
38+ // Resolve whether we should use dark mode via resolver
39+ pref := resolver.Resolve()
40+ prefersDark := pref.PrefersDark
3741
3842 // Build palettes from config or defaults
3943 var lightPalette, darkPalette Palette
@@ -69,13 +73,14 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
6973 Msg("theme manager initialized")
7074
7175 return &Manager{
72- scheme: scheme,
73- prefersDark: prefersDark,
74- lightPalette: lightPalette,
75- darkPalette: darkPalette,
76- uiScale: uiScale,
77- fonts: fonts,
78- modeColors: modeColors,
76+ scheme: scheme,
77+ prefersDark: prefersDark,
78+ lightPalette: lightPalette,
79+ darkPalette: darkPalette,
80+ uiScale: uiScale,
81+ fonts: fonts,
82+ modeColors: modeColors,
83+ colorResolver: resolver,
7984 }
8085 }
8186
@@ -178,11 +183,13 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
178183 log := logging.FromContext(ctx)
179184
180185 m.scheme = scheme
181- m.prefersDark = ResolveColorScheme(scheme)
186+ pref := m.colorResolver.Refresh()
187+ m.prefersDark = pref.PrefersDark
182188
183189 log.Info().
184190 Str("scheme", scheme).
185191 Bool("prefers_dark", m.prefersDark).
192+ Str("source", pref.Source).
186193 Msg("color scheme changed")
187194
188195 // Re-apply CSS if display is available
@@ -199,13 +206,14 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
199206 return
200207 }
201208
202- // Update scheme and resolve prefersDark
209+ // Update scheme and resolve prefersDark via resolver
203210 scheme := "system"
204211 if cfg.Appearance.ColorScheme != "" {
205212 scheme = cfg.Appearance.ColorScheme
206213 }
207214 m.scheme = scheme
208- m.prefersDark = ResolveColorScheme(scheme)
215+ pref := m.colorResolver.Refresh()
216+ m.prefersDark = pref.PrefersDark
209217
210218 // Update palettes
211219 m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
A internal/ui/theme/manager_test.go +342 -0
@@ -0,0 +1,342 @@
1+package theme
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestNewManager_DarkMode(t *testing.T) {
15+ ctx := context.Background()
16+ cfg := &config.Config{
17+ Appearance: config.AppearanceConfig{
18+ ColorScheme: "default",
19+ },
20+ }
21+
22+ mockResolver := mocks.NewMockColorSchemeResolver(t)
23+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
24+ PrefersDark: true,
25+ Source: "test",
26+ })
27+
28+ manager := NewManager(ctx, cfg, mockResolver)
29+
30+ assert.True(t, manager.PrefersDark())
31+ assert.Equal(t, "default", manager.scheme)
32+}
33+
34+func TestNewManager_LightMode(t *testing.T) {
35+ ctx := context.Background()
36+ cfg := &config.Config{
37+ Appearance: config.AppearanceConfig{
38+ ColorScheme: "default",
39+ },
40+ }
41+
42+ mockResolver := mocks.NewMockColorSchemeResolver(t)
43+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
44+ PrefersDark: false,
45+ Source: "test",
46+ })
47+
48+ manager := NewManager(ctx, cfg, mockResolver)
49+
50+ assert.False(t, manager.PrefersDark())
51+}
52+
53+func TestNewManager_WithNilConfig(t *testing.T) {
54+ ctx := context.Background()
55+
56+ mockResolver := mocks.NewMockColorSchemeResolver(t)
57+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
58+ PrefersDark: true,
59+ Source: "fallback",
60+ })
61+
62+ manager := NewManager(ctx, nil, mockResolver)
63+
64+ require.NotNil(t, manager)
65+ assert.True(t, manager.PrefersDark())
66+ assert.Equal(t, "system", manager.scheme)
67+}
68+
69+func TestNewManager_UsesConfigScheme(t *testing.T) {
70+ ctx := context.Background()
71+ cfg := &config.Config{
72+ Appearance: config.AppearanceConfig{
73+ ColorScheme: "prefer-dark",
74+ },
75+ }
76+
77+ mockResolver := mocks.NewMockColorSchemeResolver(t)
78+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
79+ PrefersDark: true,
80+ Source: "config",
81+ })
82+
83+ manager := NewManager(ctx, cfg, mockResolver)
84+
85+ assert.Equal(t, "prefer-dark", manager.scheme)
86+}
87+
88+func TestManager_GetCurrentPalette_Dark(t *testing.T) {
89+ ctx := context.Background()
90+ cfg := &config.Config{}
91+
92+ mockResolver := mocks.NewMockColorSchemeResolver(t)
93+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
94+ PrefersDark: true,
95+ Source: "test",
96+ })
97+
98+ manager := NewManager(ctx, cfg, mockResolver)
99+ palette := manager.GetCurrentPalette()
100+
101+ // Dark palette should be returned
102+ assert.Equal(t, manager.GetDarkPalette(), palette)
103+}
104+
105+func TestManager_GetCurrentPalette_Light(t *testing.T) {
106+ ctx := context.Background()
107+ cfg := &config.Config{}
108+
109+ mockResolver := mocks.NewMockColorSchemeResolver(t)
110+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
111+ PrefersDark: false,
112+ Source: "test",
113+ })
114+
115+ manager := NewManager(ctx, cfg, mockResolver)
116+ palette := manager.GetCurrentPalette()
117+
118+ // Light palette should be returned
119+ assert.Equal(t, manager.GetLightPalette(), palette)
120+}
121+
122+func TestManager_SetColorScheme(t *testing.T) {
123+ ctx := context.Background()
124+ cfg := &config.Config{}
125+
126+ mockResolver := mocks.NewMockColorSchemeResolver(t)
127+ // Initial resolve call
128+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
129+ PrefersDark: true,
130+ Source: "test",
131+ })
132+ // Refresh call when SetColorScheme is called
133+ mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
134+ PrefersDark: false,
135+ Source: "config",
136+ })
137+
138+ manager := NewManager(ctx, cfg, mockResolver)
139+ assert.True(t, manager.PrefersDark())
140+
141+ // Change scheme - this calls Refresh on resolver
142+ manager.SetColorScheme(ctx, "prefer-light", nil)
143+
144+ assert.False(t, manager.PrefersDark())
145+ assert.Equal(t, "prefer-light", manager.scheme)
146+}
147+
148+func TestManager_UpdateFromConfig(t *testing.T) {
149+ ctx := context.Background()
150+ cfg := &config.Config{
151+ Appearance: config.AppearanceConfig{
152+ ColorScheme: "default",
153+ },
154+ }
155+
156+ mockResolver := mocks.NewMockColorSchemeResolver(t)
157+ // Initial resolve
158+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
159+ PrefersDark: true,
160+ Source: "test",
161+ })
162+ // Refresh when UpdateFromConfig is called
163+ mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
164+ PrefersDark: false,
165+ Source: "config",
166+ })
167+
168+ manager := NewManager(ctx, cfg, mockResolver)
169+ assert.True(t, manager.PrefersDark())
170+
171+ // Update with new config
172+ newCfg := &config.Config{
173+ Appearance: config.AppearanceConfig{
174+ ColorScheme: "prefer-light",
175+ },
176+ }
177+ manager.UpdateFromConfig(ctx, newCfg, nil)
178+
179+ assert.False(t, manager.PrefersDark())
180+ assert.Equal(t, "prefer-light", manager.scheme)
181+}
182+
183+func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
184+ ctx := context.Background()
185+ cfg := &config.Config{}
186+
187+ mockResolver := mocks.NewMockColorSchemeResolver(t)
188+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
189+ PrefersDark: true,
190+ Source: "test",
191+ })
192+
193+ manager := NewManager(ctx, cfg, mockResolver)
194+ initialScheme := manager.scheme
195+
196+ // UpdateFromConfig with nil should be a no-op
197+ manager.UpdateFromConfig(ctx, nil, nil)
198+
199+ assert.Equal(t, initialScheme, manager.scheme)
200+}
201+
202+func TestManager_GetWebUIThemeCSS(t *testing.T) {
203+ ctx := context.Background()
204+ cfg := &config.Config{}
205+
206+ mockResolver := mocks.NewMockColorSchemeResolver(t)
207+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
208+ PrefersDark: true,
209+ Source: "test",
210+ })
211+
212+ manager := NewManager(ctx, cfg, mockResolver)
213+ css := manager.GetWebUIThemeCSS()
214+
215+ // Should contain both light and dark variables
216+ assert.Contains(t, css, ":root{")
217+ assert.Contains(t, css, ".dark{")
218+}
219+
220+func TestManager_GetBackgroundRGBA(t *testing.T) {
221+ ctx := context.Background()
222+ cfg := &config.Config{}
223+
224+ mockResolver := mocks.NewMockColorSchemeResolver(t)
225+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
226+ PrefersDark: true,
227+ Source: "test",
228+ })
229+
230+ manager := NewManager(ctx, cfg, mockResolver)
231+ r, g, b, a := manager.GetBackgroundRGBA()
232+
233+ // Should return valid RGBA values (0-1 range)
234+ assert.GreaterOrEqual(t, r, float32(0))
235+ assert.LessOrEqual(t, r, float32(1))
236+ assert.GreaterOrEqual(t, g, float32(0))
237+ assert.LessOrEqual(t, g, float32(1))
238+ assert.GreaterOrEqual(t, b, float32(0))
239+ assert.LessOrEqual(t, b, float32(1))
240+ assert.GreaterOrEqual(t, a, float32(0))
241+ assert.LessOrEqual(t, a, float32(1))
242+}
243+
244+func TestManager_CustomPalettes(t *testing.T) {
245+ ctx := context.Background()
246+ cfg := &config.Config{
247+ Appearance: config.AppearanceConfig{
248+ LightPalette: config.ColorPalette{
249+ Background: "#ffffff",
250+ Text: "#000000",
251+ },
252+ DarkPalette: config.ColorPalette{
253+ Background: "#000000",
254+ Text: "#ffffff",
255+ },
256+ },
257+ }
258+
259+ mockResolver := mocks.NewMockColorSchemeResolver(t)
260+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
261+ PrefersDark: true,
262+ Source: "test",
263+ })
264+
265+ manager := NewManager(ctx, cfg, mockResolver)
266+
267+ lightPalette := manager.GetLightPalette()
268+ darkPalette := manager.GetDarkPalette()
269+
270+ assert.Equal(t, "#ffffff", lightPalette.Background)
271+ assert.Equal(t, "#000000", lightPalette.Text)
272+ assert.Equal(t, "#000000", darkPalette.Background)
273+ assert.Equal(t, "#ffffff", darkPalette.Text)
274+}
275+
276+func TestManager_UIScale(t *testing.T) {
277+ ctx := context.Background()
278+ cfg := &config.Config{
279+ DefaultUIScale: 1.5,
280+ }
281+
282+ mockResolver := mocks.NewMockColorSchemeResolver(t)
283+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
284+ PrefersDark: true,
285+ Source: "test",
286+ })
287+
288+ manager := NewManager(ctx, cfg, mockResolver)
289+
290+ // UI scale should be stored (we can't directly access it, but we can verify
291+ // the manager was created without error)
292+ require.NotNil(t, manager)
293+}
294+
295+func TestManager_Fonts(t *testing.T) {
296+ ctx := context.Background()
297+ cfg := &config.Config{
298+ Appearance: config.AppearanceConfig{
299+ SansFont: "Inter",
300+ MonospaceFont: "JetBrains Mono",
301+ },
302+ }
303+
304+ mockResolver := mocks.NewMockColorSchemeResolver(t)
305+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
306+ PrefersDark: true,
307+ Source: "test",
308+ })
309+
310+ manager := NewManager(ctx, cfg, mockResolver)
311+
312+ // Fonts should be stored (manager creation should succeed)
313+ require.NotNil(t, manager)
314+}
315+
316+func TestManager_ModeColors(t *testing.T) {
317+ ctx := context.Background()
318+ cfg := &config.Config{
319+ Workspace: config.WorkspaceConfig{
320+ Styling: config.WorkspaceStylingConfig{
321+ PaneModeColor: "#ff0000",
322+ TabModeColor: "#00ff00",
323+ SessionModeColor: "#0000ff",
324+ ResizeModeColor: "#ffff00",
325+ },
326+ },
327+ }
328+
329+ mockResolver := mocks.NewMockColorSchemeResolver(t)
330+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
331+ PrefersDark: true,
332+ Source: "test",
333+ })
334+
335+ manager := NewManager(ctx, cfg, mockResolver)
336+ modeColors := manager.GetModeColors()
337+
338+ assert.Equal(t, "#ff0000", modeColors.PaneMode)
339+ assert.Equal(t, "#00ff00", modeColors.TabMode)
340+ assert.Equal(t, "#0000ff", modeColors.SessionMode)
341+ assert.Equal(t, "#ffff00", modeColors.ResizeMode)
342+}
D internal/ui/theme/system.go +0 -78
@@ -1,78 +0,0 @@
1-package theme
2-
3-import (
4- "os"
5- "os/exec"
6- "strings"
7-
8- "github.com/jwijenbergh/puregotk/v4/gtk"
9-)
10-
11-// DetectSystemDarkMode checks if the system prefers dark mode.
12-// It checks multiple sources in order of preference:
13-// 1. GTK_THEME environment variable (contains "dark")
14-// 2. GNOME gsettings color-scheme (prefer-dark/prefer-light)
15-// 3. GTK Settings gtk-application-prefer-dark-theme property
16-// Returns true if dark mode is preferred.
17-func DetectSystemDarkMode() bool {
18- // Check GTK_THEME environment variable first
19- gtkTheme := os.Getenv("GTK_THEME")
20- if gtkTheme != "" {
21- if strings.Contains(strings.ToLower(gtkTheme), "dark") {
22- return true
23- }
24- // If GTK_THEME is set but doesn't contain "dark", it's likely light
25- return false
26- }
27-
28- // Check GNOME gsettings color-scheme (modern GNOME/GTK4 method)
29- if colorScheme := getGsettingsColorScheme(); colorScheme != "" {
30- switch colorScheme {
31- case "prefer-dark":
32- return true
33- case "prefer-light":
34- return false
35- // "default" falls through to next check
36- }
37- }
38-
39- // Try GTK Settings
40- settings := gtk.SettingsGetDefault()
41- if settings != nil {
42- // gtk-application-prefer-dark-theme is a boolean property
43- preferDark := settings.GetPropertyGtkApplicationPreferDarkTheme()
44- return preferDark
45- }
46-
47- // Default to dark mode if detection fails (better for eyes)
48- return true
49-}
50-
51-// getGsettingsColorScheme queries GNOME gsettings for the color-scheme preference.
52-// Returns "prefer-dark", "prefer-light", "default", or empty string on error.
53-func getGsettingsColorScheme() string {
54- cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
55- output, err := cmd.Output()
56- if err != nil {
57- return ""
58- }
59- // Output is like "'prefer-dark'\n", strip quotes and whitespace
60- result := strings.TrimSpace(string(output))
61- result = strings.Trim(result, "'\"")
62- return result
63-}
64-
65-// ResolveColorScheme determines the effective dark mode preference.
66-// It takes the config value ("prefer-dark", "prefer-light", "default")
67-// and resolves "default" to the system preference.
68-func ResolveColorScheme(configScheme string) bool {
69- switch strings.ToLower(configScheme) {
70- case "prefer-dark", "dark":
71- return true
72- case "prefer-light", "light":
73- return false
74- default:
75- // "default" or empty - follow system
76- return DetectSystemDarkMode()
77- }
78-}
M webui/package-lock.json +64 -0 generated file: collapsed
M webui/package.json +1 -0
@@ -42,6 +42,7 @@
4242 "bits-ui": "^2.14.4",
4343 "clsx": "^2.1.1",
4444 "frappe-charts": "^1.6.2",
45+ "mode-watcher": "^1.1.0",
4546 "tailwind-merge": "^3.4.0"
4647 }
4748 }
M webui/src/pages/ConfigPage.svelte +243 -3
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount } from "svelte";
3+ import { ModeWatcher } from "mode-watcher";
34 import ConfigShell from "./config/ConfigShell.svelte";
45 import ShortcutsTable from "./config/ShortcutsTable.svelte";
56 import * as AlertDialog from "$lib/components/ui/alert-dialog";
@@ -10,6 +11,7 @@
1011 import { Spinner } from "$lib/components/ui/spinner";
1112 import * as Card from "$lib/components/ui/card";
1213 import * as Tabs from "$lib/components/ui/tabs";
14+ import { FlaskConical, RefreshCw } from "@lucide/svelte";
1315
1416 type ColorPalette = {
1517 background: string;
@@ -26,6 +28,40 @@
2628 description: string;
2729 };
2830
31+ type ResolvedPerformance = {
32+ skia_cpu_threads: number;
33+ skia_gpu_threads: number;
34+ web_process_memory_mb: number;
35+ network_process_memory_mb: number;
36+ webview_pool_prewarm: number;
37+ conservative_threshold: number;
38+ strict_threshold: number;
39+ };
40+
41+ type HardwareInfo = {
42+ cpu_cores: number;
43+ cpu_threads: number;
44+ total_ram_mb: number;
45+ gpu_vendor: string;
46+ gpu_name: string;
47+ vram_mb: number;
48+ };
49+
50+ type CustomPerformance = {
51+ skia_cpu_threads: number;
52+ skia_gpu_threads: number;
53+ web_process_memory_mb: number;
54+ network_process_memory_mb: number;
55+ webview_pool_prewarm: number;
56+ };
57+
58+ type PerformanceConfig = {
59+ profile: string;
60+ custom: CustomPerformance;
61+ resolved: ResolvedPerformance;
62+ hardware: HardwareInfo;
63+ };
64+
2965 type ConfigDTO = {
3066 appearance: {
3167 sans_font: string;
@@ -36,11 +72,19 @@
3672 light_palette: ColorPalette;
3773 dark_palette: ColorPalette;
3874 };
75+ performance: PerformanceConfig;
3976 default_ui_scale: number;
4077 default_search_engine: string;
4178 search_shortcuts: Record<string, SearchShortcut>;
4279 };
4380
81+ const PERFORMANCE_PROFILES = [
82+ { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." },
83+ { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." },
84+ { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." },
85+ { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." },
86+ ];
87+
4488 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
4589 { key: "background", label: "Background" },
4690 { key: "surface", label: "Surface" },
@@ -62,6 +106,7 @@
62106 let saving = $state(false);
63107 let saveSuccess = $state(false);
64108 let saveError = $state<string | null>(null);
109+ let showRestartWarning = $state(false);
65110 let themeEvents = $state(0);
66111 let activeTab = $state("appearance");
67112
@@ -130,9 +175,6 @@
130175 if (!bridge) {
131176 throw new Error("WebKit bridge not available (not running inside Dumber)");
132177 }
133- if (!webviewId) {
134- throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
135- }
136178 if (!config) {
137179 throw new Error("Config not loaded");
138180 }
@@ -158,6 +200,11 @@
158200 saveSuccess = true;
159201 saving = false;
160202
203+ // Show restart warning if performance tab was active
204+ if (activeTab === "performance") {
205+ showRestartWarning = true;
206+ }
207+
161208 // Refresh config from backend (in case watcher normalized values)
162209 reloadConfig();
163210
@@ -218,6 +265,7 @@
218265 });
219266 </script>
220267
268+<ModeWatcher />
221269 <ConfigShell>
222270 <div class="flex w-full flex-col gap-6 px-6 py-8">
223271 {#if loading}
@@ -250,6 +298,7 @@
250298 <Tabs.List class="bg-muted/60">
251299 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
252300 <Tabs.Trigger value="search">Search</Tabs.Trigger>
301+ <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
253302 </Tabs.List>
254303 </div>
255304
@@ -374,6 +423,184 @@
374423 </Card.Content>
375424 </Card.Root>
376425 </Tabs.Content>
426+
427+ <Tabs.Content value="performance">
428+ <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
429+ <Card.Header>
430+ <Card.Title>Performance Profile</Card.Title>
431+ <Card.Description>
432+ WebKitGTK rendering and memory tuning presets.
433+ </Card.Description>
434+ </Card.Header>
435+ <Card.Content class="space-y-6">
436+ <!-- Detected Hardware Card -->
437+ {#if config.performance?.hardware}
438+ {@const hw = config.performance.hardware}
439+ <div class="rounded-md border border-border bg-muted/30 px-4 py-3">
440+ <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
441+ Detected Hardware
442+ </div>
443+ <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
444+ <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div>
445+ <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div>
446+ <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div>
447+ <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div>
448+ </div>
449+ </div>
450+ {/if}
451+
452+ <!-- Experimental feature warning -->
453+ <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3">
454+ <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" />
455+ <div class="space-y-1">
456+ <p class="text-sm font-medium text-info">Experimental Feature</p>
457+ <p class="text-xs text-muted-foreground">
458+ Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version.
459+ </p>
460+ </div>
461+ </div>
462+
463+ <!-- Profile selector -->
464+ {#if config.performance}
465+ <div class="space-y-4">
466+ {#each PERFORMANCE_PROFILES as profile (profile.value)}
467+ <label
468+ class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}"
469+ >
470+ <input
471+ type="radio"
472+ name="performance_profile"
473+ value={profile.value}
474+ checked={config.performance.profile === profile.value}
475+ onchange={() => config!.performance.profile = profile.value}
476+ class="mt-1 accent-primary"
477+ />
478+ <div class="space-y-1">
479+ <div class="font-medium">{profile.label}</div>
480+ <div class="text-sm text-muted-foreground">{profile.description}</div>
481+ </div>
482+ </label>
483+ {/each}
484+ </div>
485+ {:else}
486+ <div class="text-sm text-muted-foreground">
487+ Performance settings not available. This may be due to a configuration error.
488+ </div>
489+ {/if}
490+
491+ <!-- Custom profile fields -->
492+ {#if config.performance?.profile === "custom"}
493+ <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4">
494+ <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
495+ Custom Settings
496+ </div>
497+ <div class="grid gap-4 md:grid-cols-2">
498+ <div class="space-y-2">
499+ <Label for="skia_cpu_threads">Skia CPU Threads</Label>
500+ <Input
501+ id="skia_cpu_threads"
502+ type="number"
503+ min="0"
504+ max="8"
505+ bind:value={config.performance.custom.skia_cpu_threads}
506+ />
507+ <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p>
508+ </div>
509+ <div class="space-y-2">
510+ <Label for="skia_gpu_threads">Skia GPU Threads</Label>
511+ <Input
512+ id="skia_gpu_threads"
513+ type="number"
514+ min="-1"
515+ bind:value={config.performance.custom.skia_gpu_threads}
516+ />
517+ <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p>
518+ </div>
519+ <div class="space-y-2">
520+ <Label for="web_process_memory">Web Process Memory (MB)</Label>
521+ <Input
522+ id="web_process_memory"
523+ type="number"
524+ min="0"
525+ step="256"
526+ bind:value={config.performance.custom.web_process_memory_mb}
527+ />
528+ <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
529+ </div>
530+ <div class="space-y-2">
531+ <Label for="network_process_memory">Network Process Memory (MB)</Label>
532+ <Input
533+ id="network_process_memory"
534+ type="number"
535+ min="0"
536+ step="128"
537+ bind:value={config.performance.custom.network_process_memory_mb}
538+ />
539+ <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
540+ </div>
541+ <div class="space-y-2">
542+ <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label>
543+ <Input
544+ id="webview_pool_prewarm"
545+ type="number"
546+ min="0"
547+ max="20"
548+ bind:value={config.performance.custom.webview_pool_prewarm}
549+ />
550+ <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p>
551+ </div>
552+ </div>
553+ </div>
554+ {/if}
555+
556+ <!-- Resolved values display -->
557+ {#if config.performance?.resolved}
558+ <div class="space-y-3">
559+ <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
560+ Resolved Settings (applied on restart)
561+ </div>
562+ <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm">
563+ <div class="space-y-1">
564+ <div class="flex justify-between">
565+ <span class="font-medium">Skia CPU Threads</span>
566+ <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span>
567+ </div>
568+ <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p>
569+ </div>
570+ <div class="space-y-1">
571+ <div class="flex justify-between">
572+ <span class="font-medium">Skia GPU Threads</span>
573+ <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span>
574+ </div>
575+ <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p>
576+ </div>
577+ <div class="space-y-1">
578+ <div class="flex justify-between">
579+ <span class="font-medium">Web Process Memory</span>
580+ <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span>
581+ </div>
582+ <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p>
583+ </div>
584+ <div class="space-y-1">
585+ <div class="flex justify-between">
586+ <span class="font-medium">Network Process Memory</span>
587+ <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span>
588+ </div>
589+ <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p>
590+ </div>
591+ <div class="space-y-1">
592+ <div class="flex justify-between">
593+ <span class="font-medium">WebView Pool Prewarm</span>
594+ <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span>
595+ </div>
596+ <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p>
597+ </div>
598+ </div>
599+ </div>
600+ {/if}
601+ </Card.Content>
602+ </Card.Root>
603+ </Tabs.Content>
377604 </Tabs.Root>
378605
379606 <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
@@ -424,6 +651,19 @@
424651 {saveError}
425652 </div>
426653 {/if}
654+
655+ <!-- Restart warning after saving performance settings -->
656+ {#if showRestartWarning}
657+ <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3">
658+ <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" />
659+ <div class="space-y-1">
660+ <p class="text-sm font-medium text-warning">Restart Required</p>
661+ <p class="text-xs text-muted-foreground">
662+ Performance settings are applied at browser startup. Restart Dumber for changes to take effect.
663+ </p>
664+ </div>
665+ </div>
666+ {/if}
427667 {/if}
428668 </div>
429669 </ConfigShell>
M webui/src/pages/ErrorPage.svelte +2 -0
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount } from 'svelte';
3+ import { ModeWatcher } from 'mode-watcher';
34 import { Button } from '$lib/components/ui/button';
45 import * as Card from '$lib/components/ui/card';
56
@@ -137,6 +138,7 @@
137138 <title>{config.title}</title>
138139 </svelte:head>
139140
141+<ModeWatcher />
140142 <div class="min-h-screen flex items-center justify-center p-8 bg-background">
141143 <Card.Root class="w-full max-w-md">
142144 <Card.Header class="text-center">
M webui/src/pages/Homepage.svelte +2 -0
@@ -1,9 +1,11 @@
11 <script lang="ts">
2+ import { ModeWatcher } from 'mode-watcher';
23 import { HomepageShell } from './homepage/shell';
34 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
45 import { homepageState } from './homepage/state.svelte';
56 </script>
67
8+<ModeWatcher />
79 <HomepageShell>
810 {#if homepageState.activePanel === 'history'}
911 <HistoryPanel />

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close