detached 3489869

refactor(M7): decouple coordinators and UI subsystems from infrastructure

Remove all infrastructure/ imports from internal/ui/ production code
(except deps.go which retains *config.Config with a documented TODO).

Changes per file:
- input/global_shortcuts.go: *config.Config → *entity.WorkspaceConfig + *entity.SessionConfig
- input/keyboard.go: same narrowing; nil-guard added to NewShortcutSet helpers
- input/shortcuts.go: nil-guards for registerConfiguredShortcuts and mode builders
- dispatcher/keyboard.go: inject newPaneURL string, drop config.Get() call
- coordinator/workspace.go: inject newPaneURL, resizeStep/MinPercent; replace config.PopupBehavior* with entity.*
- coordinator/content/coordinator.go: add onLaunchExternalURL callback field + setter
- coordinator/content/navigation.go: replace desktop.LaunchExternalURL with injected callback
- config_migration.go: replace config.Get()/NewMigrator() with deps.ConfigMigrator port
- adapter/favicon.go: inject IsInternalURL/InternalDomain/NormalizedIconSize/GetLogoBytes via FaviconAdapterConfig
- deps.go: add ConfigMigrator, LaunchExternalURL, FaviconAdapterConfig fields
- app.go + main.go: update all call sites to pass narrowed params
- *_test.go: update tests to use entity types instead of config.Config
commit
34898690912154deab2eff362bde572a3afc8993
author
brice · · 2026-03-17 10:59 UTC
parent
115d8d0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +793 -691
M cmd/dumber/main.go +14 -5
@@ -31,6 +31,7 @@ import (
3131 "github.com/bnema/dumber/internal/infrastructure/xdg"
3232 "github.com/bnema/dumber/internal/logging"
3333 "github.com/bnema/dumber/internal/ui"
34+ "github.com/bnema/dumber/internal/ui/adapter"
3435 "github.com/bnema/dumber/internal/ui/component"
3536 "github.com/bnema/dumber/internal/ui/theme"
3637 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -543,11 +544,17 @@ func buildUIDependencies(
543544 CopyURLUC: uc.copyURL,
544545 Clipboard: uc.clipboard,
545546 FaviconService: uc.favicon,
546- IdleInhibitor: idleInhibitor,
547- SessionRepo: repos.session,
548- SessionStateRepo: repos.sessionState,
549- CurrentSessionID: currentSessionID,
550- SnapshotUC: uc.snapshot,
547+ FaviconAdapterConfig: adapter.FaviconAdapterConfig{
548+ IsInternalURL: favicon.IsInternalURL,
549+ InternalDomain: favicon.InternalDomain,
550+ NormalizedIconSize: favicon.NormalizedIconSize,
551+ GetLogoBytes: favicon.GetLogoBytes,
552+ },
553+ IdleInhibitor: idleInhibitor,
554+ SessionRepo: repos.session,
555+ SessionStateRepo: repos.sessionState,
556+ CurrentSessionID: currentSessionID,
557+ SnapshotUC: uc.snapshot,
551558 SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {
552559 return snapshot.NewService(uc.snapshot, provider, intervalMs)
553560 },
@@ -570,6 +577,8 @@ func buildUIDependencies(
570577 WatchConfig: func() error {
571578 return config.GetManager().Watch()
572579 },
580+ LaunchExternalURL: desktop.LaunchExternalURL,
581+ ConfigMigrator: config.NewMigrator(),
573582 }
574583
575584 return uiDeps
@@ -31,6 +31,7 @@ import (
31 "github.com/bnema/dumber/internal/infrastructure/xdg"31 "github.com/bnema/dumber/internal/infrastructure/xdg"
32 "github.com/bnema/dumber/internal/logging"32 "github.com/bnema/dumber/internal/logging"
33 "github.com/bnema/dumber/internal/ui"33 "github.com/bnema/dumber/internal/ui"
34+ "github.com/bnema/dumber/internal/ui/adapter"
34 "github.com/bnema/dumber/internal/ui/component"35 "github.com/bnema/dumber/internal/ui/component"
35 "github.com/bnema/dumber/internal/ui/theme"36 "github.com/bnema/dumber/internal/ui/theme"
36 "github.com/jwijenbergh/puregotk/v4/gtk"37 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -543,11 +544,17 @@ func buildUIDependencies(
543 CopyURLUC: uc.copyURL,544 CopyURLUC: uc.copyURL,
544 Clipboard: uc.clipboard,545 Clipboard: uc.clipboard,
545 FaviconService: uc.favicon,546 FaviconService: uc.favicon,
546- IdleInhibitor: idleInhibitor,547+ FaviconAdapterConfig: adapter.FaviconAdapterConfig{
547- SessionRepo: repos.session,548+ IsInternalURL: favicon.IsInternalURL,
548- SessionStateRepo: repos.sessionState,549+ InternalDomain: favicon.InternalDomain,
549- CurrentSessionID: currentSessionID,550+ NormalizedIconSize: favicon.NormalizedIconSize,
550- SnapshotUC: uc.snapshot,551+ GetLogoBytes: favicon.GetLogoBytes,
552+ },
553+ IdleInhibitor: idleInhibitor,
554+ SessionRepo: repos.session,
555+ SessionStateRepo: repos.sessionState,
556+ CurrentSessionID: currentSessionID,
557+ SnapshotUC: uc.snapshot,
551 SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {558 SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {
552 return snapshot.NewService(uc.snapshot, provider, intervalMs)559 return snapshot.NewService(uc.snapshot, provider, intervalMs)
553 },560 },
@@ -570,6 +577,8 @@ func buildUIDependencies(
570 WatchConfig: func() error {577 WatchConfig: func() error {
571 return config.GetManager().Watch()578 return config.GetManager().Watch()
572 },579 },
580+ LaunchExternalURL: desktop.LaunchExternalURL,
581+ ConfigMigrator: config.NewMigrator(),
573 }582 }
574 583
575 return uiDeps584 return uiDeps
M internal/bootstrap/gui.go +1 -1
@@ -125,7 +125,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
125125 var themeManager *theme.Manager
126126 go func() {
127127 defer wg.Done()
128- themeManager = theme.NewManager(input.Ctx, input.Config, resolver)
128+ themeManager = theme.NewManager(input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale, &input.Config.Workspace.Styling, resolver)
129129 }()
130130
131131 wg.Wait()
@@ -125,7 +125,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
125 var themeManager *theme.Manager125 var themeManager *theme.Manager
126 go func() {126 go func() {
127 defer wg.Done()127 defer wg.Done()
128- themeManager = theme.NewManager(input.Ctx, input.Config, resolver)128+ themeManager = theme.NewManager(input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale, &input.Config.Workspace.Styling, resolver)
129 }()129 }()
130 130
131 wg.Wait()131 wg.Wait()
A internal/domain/entity/config_types.go +215 -0
@@ -0,0 +1,215 @@
1+package entity
2+
3+// Config value types shared between infrastructure/config and the UI layer.
4+// Keeping these in domain/entity allows UI packages to use them without
5+// importing infrastructure/config.
6+
7+// AppearanceConfig holds visual appearance settings.
8+type AppearanceConfig struct {
9+ SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
10+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
11+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
12+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
13+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
14+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
15+ ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
16+}
17+
18+// ColorPalette defines the color scheme for a theme variant (light/dark).
19+type ColorPalette struct {
20+ Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
21+ Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
22+ SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
23+ Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
24+ Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
25+ Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
26+ Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
27+}
28+
29+// ActionBinding maps a config action name to key bindings.
30+type ActionBinding struct {
31+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
32+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
33+}
34+
35+// PaneModeConfig holds pane mode shortcut configuration.
36+type PaneModeConfig struct {
37+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
38+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
39+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
40+}
41+
42+// GetKeyBindings returns a map from key string to action name.
43+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
44+ keyToAction := make(map[string]string)
45+ for action, binding := range p.Actions {
46+ for _, key := range binding.Keys {
47+ keyToAction[key] = action
48+ }
49+ }
50+ return keyToAction
51+}
52+
53+// TabModeConfig holds tab mode shortcut configuration.
54+type TabModeConfig struct {
55+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
56+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
57+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
58+}
59+
60+// GetKeyBindings returns a map from key string to action name.
61+func (t *TabModeConfig) GetKeyBindings() map[string]string {
62+ keyToAction := make(map[string]string)
63+ for action, binding := range t.Actions {
64+ for _, key := range binding.Keys {
65+ keyToAction[key] = action
66+ }
67+ }
68+ return keyToAction
69+}
70+
71+// ResizeModeConfig holds resize mode shortcut configuration.
72+type ResizeModeConfig struct {
73+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
74+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
75+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
76+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
77+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
78+}
79+
80+// GetKeyBindings returns a map from key string to action name.
81+func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
82+ keyToAction := make(map[string]string)
83+ for action, binding := range r.Actions {
84+ for _, key := range binding.Keys {
85+ keyToAction[key] = action
86+ }
87+ }
88+ return keyToAction
89+}
90+
91+// SessionModeConfig holds session mode shortcut configuration.
92+type SessionModeConfig struct {
93+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
94+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
95+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
96+}
97+
98+// GetKeyBindings returns a map from key string to action name.
99+func (s *SessionModeConfig) GetKeyBindings() map[string]string {
100+ keyToAction := make(map[string]string)
101+ for action, binding := range s.Actions {
102+ for _, key := range binding.Keys {
103+ keyToAction[key] = action
104+ }
105+ }
106+ return keyToAction
107+}
108+
109+// SessionConfig holds session persistence and restoration settings.
110+type SessionConfig struct {
111+ AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
112+
113+ SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
114+
115+ MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
116+
117+ MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
118+
119+ SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
120+}
121+
122+// GlobalShortcutsConfig holds workspace-level global shortcut bindings.
123+type GlobalShortcutsConfig struct {
124+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
125+}
126+
127+// GetKeyBindings returns a map from key string to action name.
128+func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
129+ keyToAction := make(map[string]string)
130+ for action, binding := range g.Actions {
131+ for _, key := range binding.Keys {
132+ keyToAction[key] = action
133+ }
134+ }
135+ return keyToAction
136+}
137+
138+// FloatingPaneProfile defines a named floating pane preset.
139+type FloatingPaneProfile struct {
140+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
141+ URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
142+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
143+}
144+
145+// FloatingPaneConfig holds floating pane layout and profile settings.
146+type FloatingPaneConfig struct {
147+ WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
148+ HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
149+ Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
150+}
151+
152+// WorkspaceStylingConfig defines visual styling for workspace elements.
153+type WorkspaceStylingConfig struct {
154+ BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
155+ BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
156+ ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
157+
158+ PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
159+ TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
160+ SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
161+ ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
162+
163+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
164+
165+ TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
166+}
167+
168+// PopupBehavior defines how a popup is placed in the workspace.
169+type PopupBehavior string
170+
171+const (
172+ PopupBehaviorSplit PopupBehavior = "split"
173+ PopupBehaviorStacked PopupBehavior = "stacked"
174+ PopupBehaviorTabbed PopupBehavior = "tabbed"
175+ PopupBehaviorWindowed PopupBehavior = "windowed"
176+)
177+
178+// PopupBehaviorConfig controls how popup windows are handled.
179+type PopupBehaviorConfig struct {
180+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
181+
182+ Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
183+
184+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
185+
186+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line
187+
188+ BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line
189+
190+ EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line
191+
192+ OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
193+}
194+
195+// WorkspaceConfig holds all workspace layout and behaviour settings.
196+type WorkspaceConfig struct {
197+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"`
198+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
199+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
200+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
201+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
202+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
203+ TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
204+ HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
205+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
206+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
207+ Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
208+}
209+
210+// UpdateConfig holds auto-update behaviour settings.
211+type UpdateConfig struct {
212+ EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
213+ AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
214+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
215+}
@@ -0,0 +1,215 @@
1+package entity
2+
3+// Config value types shared between infrastructure/config and the UI layer.
4+// Keeping these in domain/entity allows UI packages to use them without
5+// importing infrastructure/config.
6+
7+// AppearanceConfig holds visual appearance settings.
8+type AppearanceConfig struct {
9+ SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
10+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
11+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
12+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
13+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
14+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
15+ ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
16+}
17+
18+// ColorPalette defines the color scheme for a theme variant (light/dark).
19+type ColorPalette struct {
20+ Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
21+ Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
22+ SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
23+ Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
24+ Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
25+ Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
26+ Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
27+}
28+
29+// ActionBinding maps a config action name to key bindings.
30+type ActionBinding struct {
31+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
32+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
33+}
34+
35+// PaneModeConfig holds pane mode shortcut configuration.
36+type PaneModeConfig struct {
37+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
38+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
39+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
40+}
41+
42+// GetKeyBindings returns a map from key string to action name.
43+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
44+ keyToAction := make(map[string]string)
45+ for action, binding := range p.Actions {
46+ for _, key := range binding.Keys {
47+ keyToAction[key] = action
48+ }
49+ }
50+ return keyToAction
51+}
52+
53+// TabModeConfig holds tab mode shortcut configuration.
54+type TabModeConfig struct {
55+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
56+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
57+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
58+}
59+
60+// GetKeyBindings returns a map from key string to action name.
61+func (t *TabModeConfig) GetKeyBindings() map[string]string {
62+ keyToAction := make(map[string]string)
63+ for action, binding := range t.Actions {
64+ for _, key := range binding.Keys {
65+ keyToAction[key] = action
66+ }
67+ }
68+ return keyToAction
69+}
70+
71+// ResizeModeConfig holds resize mode shortcut configuration.
72+type ResizeModeConfig struct {
73+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
74+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
75+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
76+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
77+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
78+}
79+
80+// GetKeyBindings returns a map from key string to action name.
81+func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
82+ keyToAction := make(map[string]string)
83+ for action, binding := range r.Actions {
84+ for _, key := range binding.Keys {
85+ keyToAction[key] = action
86+ }
87+ }
88+ return keyToAction
89+}
90+
91+// SessionModeConfig holds session mode shortcut configuration.
92+type SessionModeConfig struct {
93+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
94+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
95+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
96+}
97+
98+// GetKeyBindings returns a map from key string to action name.
99+func (s *SessionModeConfig) GetKeyBindings() map[string]string {
100+ keyToAction := make(map[string]string)
101+ for action, binding := range s.Actions {
102+ for _, key := range binding.Keys {
103+ keyToAction[key] = action
104+ }
105+ }
106+ return keyToAction
107+}
108+
109+// SessionConfig holds session persistence and restoration settings.
110+type SessionConfig struct {
111+ AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
112+
113+ SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
114+
115+ MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
116+
117+ MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
118+
119+ SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
120+}
121+
122+// GlobalShortcutsConfig holds workspace-level global shortcut bindings.
123+type GlobalShortcutsConfig struct {
124+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
125+}
126+
127+// GetKeyBindings returns a map from key string to action name.
128+func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
129+ keyToAction := make(map[string]string)
130+ for action, binding := range g.Actions {
131+ for _, key := range binding.Keys {
132+ keyToAction[key] = action
133+ }
134+ }
135+ return keyToAction
136+}
137+
138+// FloatingPaneProfile defines a named floating pane preset.
139+type FloatingPaneProfile struct {
140+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
141+ URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
142+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
143+}
144+
145+// FloatingPaneConfig holds floating pane layout and profile settings.
146+type FloatingPaneConfig struct {
147+ WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
148+ HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
149+ Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
150+}
151+
152+// WorkspaceStylingConfig defines visual styling for workspace elements.
153+type WorkspaceStylingConfig struct {
154+ BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
155+ BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
156+ ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
157+
158+ PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
159+ TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
160+ SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
161+ ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
162+
163+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
164+
165+ TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
166+}
167+
168+// PopupBehavior defines how a popup is placed in the workspace.
169+type PopupBehavior string
170+
171+const (
172+ PopupBehaviorSplit PopupBehavior = "split"
173+ PopupBehaviorStacked PopupBehavior = "stacked"
174+ PopupBehaviorTabbed PopupBehavior = "tabbed"
175+ PopupBehaviorWindowed PopupBehavior = "windowed"
176+)
177+
178+// PopupBehaviorConfig controls how popup windows are handled.
179+type PopupBehaviorConfig struct {
180+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
181+
182+ Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
183+
184+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
185+
186+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line
187+
188+ BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line
189+
190+ EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line
191+
192+ OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
193+}
194+
195+// WorkspaceConfig holds all workspace layout and behaviour settings.
196+type WorkspaceConfig struct {
197+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"`
198+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
199+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
200+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
201+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
202+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
203+ TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
204+ HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
205+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
206+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
207+ Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
208+}
209+
210+// UpdateConfig holds auto-update behaviour settings.
211+type UpdateConfig struct {
212+ EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
213+ AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
214+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
215+}
M internal/infrastructure/config/schema.go +65 -272
@@ -1,5 +1,70 @@
11 package config
22
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// Type aliases for config value types now defined in domain/entity.
6+// These aliases keep all existing config-package callers working without
7+// any changes to their import paths.
8+
9+// AppearanceConfig holds UI/rendering preferences.
10+type AppearanceConfig = entity.AppearanceConfig
11+
12+// ColorPalette contains semantic color tokens for light/dark themes.
13+type ColorPalette = entity.ColorPalette
14+
15+// ActionBinding defines a keybinding with optional description.
16+type ActionBinding = entity.ActionBinding
17+
18+// PaneModeConfig defines modal behavior for pane management.
19+type PaneModeConfig = entity.PaneModeConfig
20+
21+// TabModeConfig defines modal behavior for tab management (Zellij-style).
22+type TabModeConfig = entity.TabModeConfig
23+
24+// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
25+type ResizeModeConfig = entity.ResizeModeConfig
26+
27+// SessionModeConfig defines modal behavior for session management.
28+type SessionModeConfig = entity.SessionModeConfig
29+
30+// SessionConfig holds session persistence settings.
31+type SessionConfig = entity.SessionConfig
32+
33+// GlobalShortcutsConfig defines global shortcuts (always active, not modal).
34+type GlobalShortcutsConfig = entity.GlobalShortcutsConfig
35+
36+// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
37+type FloatingPaneProfile = entity.FloatingPaneProfile
38+
39+// FloatingPaneConfig defines persistent floating pane behavior.
40+type FloatingPaneConfig = entity.FloatingPaneConfig
41+
42+// WorkspaceStylingConfig defines visual styling for workspace panes.
43+type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
44+
45+// PopupBehavior defines how popup windows should be opened.
46+type PopupBehavior = entity.PopupBehavior
47+
48+const (
49+ // PopupBehaviorSplit opens popups in a split pane (default)
50+ PopupBehaviorSplit = entity.PopupBehaviorSplit
51+ // PopupBehaviorStacked opens popups in a stacked pane
52+ PopupBehaviorStacked = entity.PopupBehaviorStacked
53+ // PopupBehaviorTabbed opens popups as a new tab
54+ PopupBehaviorTabbed = entity.PopupBehaviorTabbed
55+ // PopupBehaviorWindowed opens popups in a new workspace window
56+ PopupBehaviorWindowed = entity.PopupBehaviorWindowed
57+)
58+
59+// PopupBehaviorConfig defines handling for popup windows.
60+type PopupBehaviorConfig = entity.PopupBehaviorConfig
61+
62+// WorkspaceConfig captures layout, pane, and tab behavior preferences.
63+type WorkspaceConfig = entity.WorkspaceConfig
64+
65+// UpdateConfig holds automatic update settings.
66+type UpdateConfig = entity.UpdateConfig
67+
368 // Config represents the complete configuration for dumber.
469 type Config struct {
570 Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"`
@@ -256,31 +321,6 @@ type LoggingConfig struct {
256321 CaptureGTKLogs bool `mapstructure:"capture_gtk_logs" yaml:"capture_gtk_logs" toml:"capture_gtk_logs" json:"capture_gtk_logs"`
257322 }
258323
259-// AppearanceConfig holds UI/rendering preferences.
260-type AppearanceConfig struct {
261- // Default fonts for pages that do not specify fonts.
262- SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
263- SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
264- MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
265- // Default font size in CSS pixels (approx).
266- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
267- LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
268- DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
269- // ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
270- ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
271-}
272-
273-// ColorPalette contains semantic color tokens for light/dark themes.
274-type ColorPalette struct {
275- Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
276- Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
277- SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
278- Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
279- Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
280- Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
281- Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
282-}
283-
284324 // ContentFilteringConfig holds content filtering and ad blocking preferences
285325 type ContentFilteringConfig struct {
286326 // Enabled controls whether ad blocking is active (default: true)
@@ -316,256 +356,9 @@ type DebugConfig struct {
316356 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
317357 }
318358
319-// UpdateConfig holds automatic update settings.
320-type UpdateConfig struct {
321- // EnableOnStartup enables checking for updates when the browser starts.
322- // Default: true
323- EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
324- // AutoDownload automatically downloads updates in the background.
325- // When enabled, updates are applied on browser exit.
326- // Default: false
327- AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
328- // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
329- // Default: true
330- NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
331-}
332-
333359 // DownloadsConfig holds file download preferences.
334360 type DownloadsConfig struct {
335361 // Path is the directory where downloads are saved.
336362 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
337363 Path string `mapstructure:"path" yaml:"path" toml:"path"`
338364 }
339-
340-// WorkspaceConfig captures layout, pane, and tab behavior preferences.
341-type WorkspaceConfig struct {
342- // NewPaneURL is the URL loaded when creating a new empty pane/tab.
343- // Default: "about:blank"
344- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"`
345- // PaneMode defines modal pane behavior and bindings.
346- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
347- // TabMode defines modal tab behavior and bindings (Alt+T).
348- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
349- // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N).
350- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
351- // Shortcuts holds global (non-modal) keyboard shortcuts.
352- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
353- // FloatingPane configures workspace-level floating pane behavior.
354- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
355- // TabBarPosition determines tab bar placement: "top" or "bottom".
356- TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
357- // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
358- HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
359- // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
360- // Default: true
361- SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
362- // Popups configures default popup placement rules.
363- Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
364- // Styling configures workspace visual appearance.
365- Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
366-}
367-
368-// ActionBinding defines a keybinding with optional description.
369-type ActionBinding struct {
370- Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
371- Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
372-}
373-
374-// PaneModeConfig defines modal behavior for pane management.
375-type PaneModeConfig struct {
376- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
377- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
378- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
379-}
380-
381-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
382-// This is built from the action→keys structure in the config.
383-func (p *PaneModeConfig) GetKeyBindings() map[string]string {
384- keyToAction := make(map[string]string)
385- for action, binding := range p.Actions {
386- for _, key := range binding.Keys {
387- keyToAction[key] = action
388- }
389- }
390- return keyToAction
391-}
392-
393-// TabModeConfig defines modal behavior for tab management (Zellij-style).
394-type TabModeConfig struct {
395- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
396- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
397- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
398-}
399-
400-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
401-// This is built from the action→keys structure in the config.
402-func (t *TabModeConfig) GetKeyBindings() map[string]string {
403- keyToAction := make(map[string]string)
404- for action, binding := range t.Actions {
405- for _, key := range binding.Keys {
406- keyToAction[key] = action
407- }
408- }
409- return keyToAction
410-}
411-
412-// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
413-type ResizeModeConfig struct {
414- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
415- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
416- StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
417- MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
418- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
419-}
420-
421-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
422-// This is built from the action→keys structure in the config.
423-func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
424- keyToAction := make(map[string]string)
425- for action, binding := range r.Actions {
426- for _, key := range binding.Keys {
427- keyToAction[key] = action
428- }
429- }
430- return keyToAction
431-}
432-
433-// GlobalShortcutsConfig defines global shortcuts (always active, not modal).
434-type GlobalShortcutsConfig struct {
435- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
436-}
437-
438-// FloatingPaneConfig defines persistent floating pane behavior.
439-type FloatingPaneConfig struct {
440- WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
441- HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
442- Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
443-}
444-
445-// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
446-type FloatingPaneProfile struct {
447- Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
448- URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
449- Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
450-}
451-
452-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
453-func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
454- keyToAction := make(map[string]string)
455- for action, binding := range g.Actions {
456- for _, key := range binding.Keys {
457- keyToAction[key] = action
458- }
459- }
460- return keyToAction
461-}
462-
463-// PopupBehavior defines how popup windows should be opened
464-type PopupBehavior string
465-
466-const (
467- // PopupBehaviorSplit opens popups in a split pane (default)
468- PopupBehaviorSplit PopupBehavior = "split"
469- // PopupBehaviorStacked opens popups in a stacked pane
470- PopupBehaviorStacked PopupBehavior = "stacked"
471- // PopupBehaviorTabbed opens popups as a new tab
472- PopupBehaviorTabbed PopupBehavior = "tabbed"
473- // PopupBehaviorWindowed opens popups in a new workspace window
474- PopupBehaviorWindowed PopupBehavior = "windowed"
475-)
476-
477-// PopupBehaviorConfig defines handling for popup windows.
478-type PopupBehaviorConfig struct {
479- // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
480- Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
481-
482- // Placement specifies direction for split behavior ("right", "left", "top", "bottom")
483- // Only used when Behavior is "split"
484- Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
485-
486- // OpenInNewPane controls whether popups are opened in workspace or blocked
487- OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
488-
489- // FollowPaneContext determines if popup placement follows parent pane context
490- FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line
491-
492- // BlankTargetBehavior determines how target="_blank" links are opened
493- // Accepted values: "split", "stacked" (default), "tabbed"
494- // This is separate from Behavior which controls JavaScript popups
495- BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line
496-
497- // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
498- EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line
499-
500- // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
501- OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
502-}
503-
504-// WorkspaceStylingConfig defines visual styling for workspace panes.
505-type WorkspaceStylingConfig struct {
506- // BorderWidth in pixels for active pane borders (overlay)
507- BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
508- // BorderColor for focused panes (CSS color value or theme variable)
509- BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
510-
511- // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
512- ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
513-
514- // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
515- PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
516- // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
517- TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
518- // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
519- SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
520- // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
521- ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
522-
523- // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
524- // Default: true
525- ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
526-
527- // TransitionDuration in milliseconds for border animations
528- TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
529-}
530-
531-// SessionConfig holds session persistence settings.
532-type SessionConfig struct {
533- // AutoRestore automatically restores the last session on startup.
534- // Default: false
535- AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
536-
537- // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds.
538- // Default: 5000
539- SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
540-
541- // MaxExitedSessions is the maximum number of exited sessions to keep.
542- // Default: 50
543- MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
544-
545- // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
546- // Sessions older than this will be automatically deleted on startup.
547- // Default: 7
548- MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
549-
550- // SessionMode defines modal behavior for session management (Ctrl+O).
551- SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
552-}
553-
554-// SessionModeConfig defines modal behavior for session management.
555-type SessionModeConfig struct {
556- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
557- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
558- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
559-}
560-
561-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
562-// This is built from the action→keys structure in the config.
563-func (s *SessionModeConfig) GetKeyBindings() map[string]string {
564- keyToAction := make(map[string]string)
565- for action, binding := range s.Actions {
566- for _, key := range binding.Keys {
567- keyToAction[key] = action
568- }
569- }
570- return keyToAction
571-}
@@ -1,5 +1,70 @@
1 package config1 package config
2 2
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// Type aliases for config value types now defined in domain/entity.
6+// These aliases keep all existing config-package callers working without
7+// any changes to their import paths.
8+
9+// AppearanceConfig holds UI/rendering preferences.
10+type AppearanceConfig = entity.AppearanceConfig
11+
12+// ColorPalette contains semantic color tokens for light/dark themes.
13+type ColorPalette = entity.ColorPalette
14+
15+// ActionBinding defines a keybinding with optional description.
16+type ActionBinding = entity.ActionBinding
17+
18+// PaneModeConfig defines modal behavior for pane management.
19+type PaneModeConfig = entity.PaneModeConfig
20+
21+// TabModeConfig defines modal behavior for tab management (Zellij-style).
22+type TabModeConfig = entity.TabModeConfig
23+
24+// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
25+type ResizeModeConfig = entity.ResizeModeConfig
26+
27+// SessionModeConfig defines modal behavior for session management.
28+type SessionModeConfig = entity.SessionModeConfig
29+
30+// SessionConfig holds session persistence settings.
31+type SessionConfig = entity.SessionConfig
32+
33+// GlobalShortcutsConfig defines global shortcuts (always active, not modal).
34+type GlobalShortcutsConfig = entity.GlobalShortcutsConfig
35+
36+// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
37+type FloatingPaneProfile = entity.FloatingPaneProfile
38+
39+// FloatingPaneConfig defines persistent floating pane behavior.
40+type FloatingPaneConfig = entity.FloatingPaneConfig
41+
42+// WorkspaceStylingConfig defines visual styling for workspace panes.
43+type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
44+
45+// PopupBehavior defines how popup windows should be opened.
46+type PopupBehavior = entity.PopupBehavior
47+
48+const (
49+ // PopupBehaviorSplit opens popups in a split pane (default)
50+ PopupBehaviorSplit = entity.PopupBehaviorSplit
51+ // PopupBehaviorStacked opens popups in a stacked pane
52+ PopupBehaviorStacked = entity.PopupBehaviorStacked
53+ // PopupBehaviorTabbed opens popups as a new tab
54+ PopupBehaviorTabbed = entity.PopupBehaviorTabbed
55+ // PopupBehaviorWindowed opens popups in a new workspace window
56+ PopupBehaviorWindowed = entity.PopupBehaviorWindowed
57+)
58+
59+// PopupBehaviorConfig defines handling for popup windows.
60+type PopupBehaviorConfig = entity.PopupBehaviorConfig
61+
62+// WorkspaceConfig captures layout, pane, and tab behavior preferences.
63+type WorkspaceConfig = entity.WorkspaceConfig
64+
65+// UpdateConfig holds automatic update settings.
66+type UpdateConfig = entity.UpdateConfig
67+
3 // Config represents the complete configuration for dumber.68 // Config represents the complete configuration for dumber.
4 type Config struct {69 type Config struct {
5 Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"`70 Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"`
@@ -256,31 +321,6 @@ type LoggingConfig struct {
256 CaptureGTKLogs bool `mapstructure:"capture_gtk_logs" yaml:"capture_gtk_logs" toml:"capture_gtk_logs" json:"capture_gtk_logs"`321 CaptureGTKLogs bool `mapstructure:"capture_gtk_logs" yaml:"capture_gtk_logs" toml:"capture_gtk_logs" json:"capture_gtk_logs"`
257 }322 }
258 323
259-// AppearanceConfig holds UI/rendering preferences.
260-type AppearanceConfig struct {
261- // Default fonts for pages that do not specify fonts.
262- SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
263- SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
264- MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
265- // Default font size in CSS pixels (approx).
266- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
267- LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
268- DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
269- // ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
270- ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
271-}
272-
273-// ColorPalette contains semantic color tokens for light/dark themes.
274-type ColorPalette struct {
275- Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
276- Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
277- SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
278- Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
279- Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
280- Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
281- Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
282-}
283-
284 // ContentFilteringConfig holds content filtering and ad blocking preferences324 // ContentFilteringConfig holds content filtering and ad blocking preferences
285 type ContentFilteringConfig struct {325 type ContentFilteringConfig struct {
286 // Enabled controls whether ad blocking is active (default: true)326 // Enabled controls whether ad blocking is active (default: true)
@@ -316,256 +356,9 @@ type DebugConfig struct {
316 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`356 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
317 }357 }
318 358
319-// UpdateConfig holds automatic update settings.
320-type UpdateConfig struct {
321- // EnableOnStartup enables checking for updates when the browser starts.
322- // Default: true
323- EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
324- // AutoDownload automatically downloads updates in the background.
325- // When enabled, updates are applied on browser exit.
326- // Default: false
327- AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
328- // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
329- // Default: true
330- NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
331-}
332-
333 // DownloadsConfig holds file download preferences.359 // DownloadsConfig holds file download preferences.
334 type DownloadsConfig struct {360 type DownloadsConfig struct {
335 // Path is the directory where downloads are saved.361 // Path is the directory where downloads are saved.
336 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.362 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
337 Path string `mapstructure:"path" yaml:"path" toml:"path"`363 Path string `mapstructure:"path" yaml:"path" toml:"path"`
338 }364 }
339-
340-// WorkspaceConfig captures layout, pane, and tab behavior preferences.
341-type WorkspaceConfig struct {
342- // NewPaneURL is the URL loaded when creating a new empty pane/tab.
343- // Default: "about:blank"
344- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"`
345- // PaneMode defines modal pane behavior and bindings.
346- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
347- // TabMode defines modal tab behavior and bindings (Alt+T).
348- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
349- // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N).
350- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
351- // Shortcuts holds global (non-modal) keyboard shortcuts.
352- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
353- // FloatingPane configures workspace-level floating pane behavior.
354- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
355- // TabBarPosition determines tab bar placement: "top" or "bottom".
356- TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
357- // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
358- HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
359- // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
360- // Default: true
361- SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
362- // Popups configures default popup placement rules.
363- Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
364- // Styling configures workspace visual appearance.
365- Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
366-}
367-
368-// ActionBinding defines a keybinding with optional description.
369-type ActionBinding struct {
370- Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
371- Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
372-}
373-
374-// PaneModeConfig defines modal behavior for pane management.
375-type PaneModeConfig struct {
376- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
377- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
378- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
379-}
380-
381-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
382-// This is built from the action→keys structure in the config.
383-func (p *PaneModeConfig) GetKeyBindings() map[string]string {
384- keyToAction := make(map[string]string)
385- for action, binding := range p.Actions {
386- for _, key := range binding.Keys {
387- keyToAction[key] = action
388- }
389- }
390- return keyToAction
391-}
392-
393-// TabModeConfig defines modal behavior for tab management (Zellij-style).
394-type TabModeConfig struct {
395- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
396- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
397- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
398-}
399-
400-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
401-// This is built from the action→keys structure in the config.
402-func (t *TabModeConfig) GetKeyBindings() map[string]string {
403- keyToAction := make(map[string]string)
404- for action, binding := range t.Actions {
405- for _, key := range binding.Keys {
406- keyToAction[key] = action
407- }
408- }
409- return keyToAction
410-}
411-
412-// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
413-type ResizeModeConfig struct {
414- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
415- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
416- StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
417- MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
418- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
419-}
420-
421-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
422-// This is built from the action→keys structure in the config.
423-func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
424- keyToAction := make(map[string]string)
425- for action, binding := range r.Actions {
426- for _, key := range binding.Keys {
427- keyToAction[key] = action
428- }
429- }
430- return keyToAction
431-}
432-
433-// GlobalShortcutsConfig defines global shortcuts (always active, not modal).
434-type GlobalShortcutsConfig struct {
435- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
436-}
437-
438-// FloatingPaneConfig defines persistent floating pane behavior.
439-type FloatingPaneConfig struct {
440- WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
441- HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
442- Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
443-}
444-
445-// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
446-type FloatingPaneProfile struct {
447- Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
448- URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
449- Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
450-}
451-
452-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
453-func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
454- keyToAction := make(map[string]string)
455- for action, binding := range g.Actions {
456- for _, key := range binding.Keys {
457- keyToAction[key] = action
458- }
459- }
460- return keyToAction
461-}
462-
463-// PopupBehavior defines how popup windows should be opened
464-type PopupBehavior string
465-
466-const (
467- // PopupBehaviorSplit opens popups in a split pane (default)
468- PopupBehaviorSplit PopupBehavior = "split"
469- // PopupBehaviorStacked opens popups in a stacked pane
470- PopupBehaviorStacked PopupBehavior = "stacked"
471- // PopupBehaviorTabbed opens popups as a new tab
472- PopupBehaviorTabbed PopupBehavior = "tabbed"
473- // PopupBehaviorWindowed opens popups in a new workspace window
474- PopupBehaviorWindowed PopupBehavior = "windowed"
475-)
476-
477-// PopupBehaviorConfig defines handling for popup windows.
478-type PopupBehaviorConfig struct {
479- // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
480- Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
481-
482- // Placement specifies direction for split behavior ("right", "left", "top", "bottom")
483- // Only used when Behavior is "split"
484- Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
485-
486- // OpenInNewPane controls whether popups are opened in workspace or blocked
487- OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
488-
489- // FollowPaneContext determines if popup placement follows parent pane context
490- FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line
491-
492- // BlankTargetBehavior determines how target="_blank" links are opened
493- // Accepted values: "split", "stacked" (default), "tabbed"
494- // This is separate from Behavior which controls JavaScript popups
495- BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line
496-
497- // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
498- EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line
499-
500- // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
501- OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
502-}
503-
504-// WorkspaceStylingConfig defines visual styling for workspace panes.
505-type WorkspaceStylingConfig struct {
506- // BorderWidth in pixels for active pane borders (overlay)
507- BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
508- // BorderColor for focused panes (CSS color value or theme variable)
509- BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
510-
511- // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
512- ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
513-
514- // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
515- PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
516- // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
517- TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
518- // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
519- SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
520- // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
521- ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
522-
523- // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
524- // Default: true
525- ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
526-
527- // TransitionDuration in milliseconds for border animations
528- TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
529-}
530-
531-// SessionConfig holds session persistence settings.
532-type SessionConfig struct {
533- // AutoRestore automatically restores the last session on startup.
534- // Default: false
535- AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
536-
537- // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds.
538- // Default: 5000
539- SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
540-
541- // MaxExitedSessions is the maximum number of exited sessions to keep.
542- // Default: 50
543- MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
544-
545- // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
546- // Sessions older than this will be automatically deleted on startup.
547- // Default: 7
548- MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
549-
550- // SessionMode defines modal behavior for session management (Ctrl+O).
551- SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
552-}
553-
554-// SessionModeConfig defines modal behavior for session management.
555-type SessionModeConfig struct {
556- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
557- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
558- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
559-}
560-
561-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
562-// This is built from the action→keys structure in the config.
563-func (s *SessionModeConfig) GetKeyBindings() map[string]string {
564- keyToAction := make(map[string]string)
565- for action, binding := range s.Actions {
566- for _, key := range binding.Keys {
567- keyToAction[key] = action
568- }
569- }
570- return keyToAction
571-}
M internal/ui/adapter/favicon.go +42 -16
@@ -7,7 +7,6 @@ import (
77
88 "github.com/bnema/dumber/internal/application/port"
99 domainurl "github.com/bnema/dumber/internal/domain/url"
10- "github.com/bnema/dumber/internal/infrastructure/favicon"
1110 "github.com/bnema/dumber/internal/logging"
1211 "github.com/jwijenbergh/puregotk/v4/gdk"
1312 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -23,18 +22,41 @@ type FaviconAdapter struct {
2322 mu sync.RWMutex
2423 warnMu sync.Mutex
2524 warnCounts map[string]int
25+
26+ // App-logo / internal-URL helpers (injected to decouple from infrastructure/favicon)
27+ isInternalURL func(url string) bool
28+ internalDomain string
29+ normalizedIconSize int
30+ getLogoBytes func() []byte
2631 }
2732
2833 type warningLogFunc func(log *zerolog.Logger, err error)
2934
35+// FaviconAdapterConfig holds optional configuration for FaviconAdapter.
36+// Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
37+type FaviconAdapterConfig struct {
38+ // IsInternalURL reports whether a URL uses the internal app scheme (e.g. dumb://).
39+ IsInternalURL func(url string) bool
40+ // InternalDomain is the pseudo-domain used to cache the app logo favicon.
41+ InternalDomain string
42+ // NormalizedIconSize is the target size for dmenu/fuzzel icons (0 = skip).
43+ NormalizedIconSize int
44+ // GetLogoBytes returns the raw bytes of the app logo (nil = skip logo favicon).
45+ GetLogoBytes func() []byte
46+}
47+
3048 // NewFaviconAdapter creates a new FaviconAdapter.
3149 // The faviconDB can be nil if a favicon database is not available.
32-func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase) *FaviconAdapter {
50+func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
3351 return &FaviconAdapter{
34- service: service,
35- faviconDB: faviconDB,
36- textureCache: make(map[string]*gdk.Texture),
37- warnCounts: make(map[string]int),
52+ service: service,
53+ faviconDB: faviconDB,
54+ textureCache: make(map[string]*gdk.Texture),
55+ warnCounts: make(map[string]int),
56+ isInternalURL: cfg.IsInternalURL,
57+ internalDomain: cfg.InternalDomain,
58+ normalizedIconSize: cfg.NormalizedIconSize,
59+ getLogoBytes: cfg.GetLogoBytes,
3860 }
3961 }
4062
@@ -54,8 +76,8 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
5476 // For internal dumb:// URLs, returns the app logo texture.
5577 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
5678 // Handle internal dumb:// scheme URLs
57- if favicon.IsInternalURL(pageURL) {
58- return a.GetTexture(favicon.InternalDomain)
79+ if a.isInternalURL != nil && a.isInternalURL(pageURL) {
80+ return a.GetTexture(a.internalDomain)
5981 }
6082 domain := domainurl.ExtractDomain(pageURL)
6183 return a.GetTexture(domain)
@@ -72,7 +94,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
7294 log := logging.FromContext(ctx)
7395
7496 // Handle internal dumb:// scheme URLs - use app logo
75- if favicon.IsInternalURL(pageURL) {
97+ if a.isInternalURL != nil && a.isInternalURL(pageURL) {
7698 texture := a.getOrCreateLogoTexture()
7799 callback(texture)
78100 return
@@ -226,7 +248,7 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
226248
227249 // Create normalized sized copy for dmenu/fuzzel (async)
228250 go func() {
229- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
251+ if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
230252 a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
231253 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
232254 })
@@ -316,7 +338,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
316338 log := logging.FromContext(ctx)
317339
318340 hasPNG := a.service.HasPNGOnDisk(domain)
319- hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
341+ hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize)
320342
321343 log.Debug().
322344 Str("domain", domain).
@@ -327,7 +349,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
327349 // Only create if original PNG exists but sized version doesn't
328350 if hasPNG && !hasSized {
329351 log.Debug().Str("domain", domain).Msg("creating sized PNG")
330- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
352+ if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
331353 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
332354 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
333355 })
@@ -377,18 +399,22 @@ func (a *FaviconAdapter) logWarningDedup(
377399 }
378400
379401 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
380-// The texture is cached under the InternalDomain key.
402+// The texture is cached under the internalDomain key.
381403 func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
404+ if a.internalDomain == "" || a.getLogoBytes == nil {
405+ return nil
406+ }
407+
382408 // Check cache first
383- if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
409+ if texture := a.GetTexture(a.internalDomain); texture != nil {
384410 return texture
385411 }
386412
387413 // Create texture from logo bytes
388- logoBytes := favicon.GetLogoBytes()
414+ logoBytes := a.getLogoBytes()
389415 texture := bytesToTexture(logoBytes)
390416 if texture != nil {
391- a.setTexture(favicon.InternalDomain, texture)
417+ a.setTexture(a.internalDomain, texture)
392418 }
393419 return texture
394420 }
@@ -7,7 +7,6 @@ import (
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
10- "github.com/bnema/dumber/internal/infrastructure/favicon"
11 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
12 "github.com/jwijenbergh/puregotk/v4/gdk"11 "github.com/jwijenbergh/puregotk/v4/gdk"
13 "github.com/jwijenbergh/puregotk/v4/glib"12 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -23,18 +22,41 @@ type FaviconAdapter struct {
23 mu sync.RWMutex22 mu sync.RWMutex
24 warnMu sync.Mutex23 warnMu sync.Mutex
25 warnCounts map[string]int24 warnCounts map[string]int
25+
26+ // App-logo / internal-URL helpers (injected to decouple from infrastructure/favicon)
27+ isInternalURL func(url string) bool
28+ internalDomain string
29+ normalizedIconSize int
30+ getLogoBytes func() []byte
26 }31 }
27 32
28 type warningLogFunc func(log *zerolog.Logger, err error)33 type warningLogFunc func(log *zerolog.Logger, err error)
29 34
35+// FaviconAdapterConfig holds optional configuration for FaviconAdapter.
36+// Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
37+type FaviconAdapterConfig struct {
38+ // IsInternalURL reports whether a URL uses the internal app scheme (e.g. dumb://).
39+ IsInternalURL func(url string) bool
40+ // InternalDomain is the pseudo-domain used to cache the app logo favicon.
41+ InternalDomain string
42+ // NormalizedIconSize is the target size for dmenu/fuzzel icons (0 = skip).
43+ NormalizedIconSize int
44+ // GetLogoBytes returns the raw bytes of the app logo (nil = skip logo favicon).
45+ GetLogoBytes func() []byte
46+}
47+
30 // NewFaviconAdapter creates a new FaviconAdapter.48 // NewFaviconAdapter creates a new FaviconAdapter.
31 // The faviconDB can be nil if a favicon database is not available.49 // The faviconDB can be nil if a favicon database is not available.
32-func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase) *FaviconAdapter {50+func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
33 return &FaviconAdapter{51 return &FaviconAdapter{
34- service: service,52+ service: service,
35- faviconDB: faviconDB,53+ faviconDB: faviconDB,
36- textureCache: make(map[string]*gdk.Texture),54+ textureCache: make(map[string]*gdk.Texture),
37- warnCounts: make(map[string]int),55+ warnCounts: make(map[string]int),
56+ isInternalURL: cfg.IsInternalURL,
57+ internalDomain: cfg.InternalDomain,
58+ normalizedIconSize: cfg.NormalizedIconSize,
59+ getLogoBytes: cfg.GetLogoBytes,
38 }60 }
39 }61 }
40 62
@@ -54,8 +76,8 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
54 // For internal dumb:// URLs, returns the app logo texture.76 // For internal dumb:// URLs, returns the app logo texture.
55 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {77 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
56 // Handle internal dumb:// scheme URLs78 // Handle internal dumb:// scheme URLs
57- if favicon.IsInternalURL(pageURL) {79+ if a.isInternalURL != nil && a.isInternalURL(pageURL) {
58- return a.GetTexture(favicon.InternalDomain)80+ return a.GetTexture(a.internalDomain)
59 }81 }
60 domain := domainurl.ExtractDomain(pageURL)82 domain := domainurl.ExtractDomain(pageURL)
61 return a.GetTexture(domain)83 return a.GetTexture(domain)
@@ -72,7 +94,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
72 log := logging.FromContext(ctx)94 log := logging.FromContext(ctx)
73 95
74 // Handle internal dumb:// scheme URLs - use app logo96 // Handle internal dumb:// scheme URLs - use app logo
75- if favicon.IsInternalURL(pageURL) {97+ if a.isInternalURL != nil && a.isInternalURL(pageURL) {
76 texture := a.getOrCreateLogoTexture()98 texture := a.getOrCreateLogoTexture()
77 callback(texture)99 callback(texture)
78 return100 return
@@ -226,7 +248,7 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
226 248
227 // Create normalized sized copy for dmenu/fuzzel (async)249 // Create normalized sized copy for dmenu/fuzzel (async)
228 go func() {250 go func() {
229- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {251+ if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
230 a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {252 a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
231 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")253 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
232 })254 })
@@ -316,7 +338,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
316 log := logging.FromContext(ctx)338 log := logging.FromContext(ctx)
317 339
318 hasPNG := a.service.HasPNGOnDisk(domain)340 hasPNG := a.service.HasPNGOnDisk(domain)
319- hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)341+ hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize)
320 342
321 log.Debug().343 log.Debug().
322 Str("domain", domain).344 Str("domain", domain).
@@ -327,7 +349,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
327 // Only create if original PNG exists but sized version doesn't349 // Only create if original PNG exists but sized version doesn't
328 if hasPNG && !hasSized {350 if hasPNG && !hasSized {
329 log.Debug().Str("domain", domain).Msg("creating sized PNG")351 log.Debug().Str("domain", domain).Msg("creating sized PNG")
330- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {352+ if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
331 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {353 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
332 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")354 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
333 })355 })
@@ -377,18 +399,22 @@ func (a *FaviconAdapter) logWarningDedup(
377 }399 }
378 400
379 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.401 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
380-// The texture is cached under the InternalDomain key.402+// The texture is cached under the internalDomain key.
381 func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {403 func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
404+ if a.internalDomain == "" || a.getLogoBytes == nil {
405+ return nil
406+ }
407+
382 // Check cache first408 // Check cache first
383- if texture := a.GetTexture(favicon.InternalDomain); texture != nil {409+ if texture := a.GetTexture(a.internalDomain); texture != nil {
384 return texture410 return texture
385 }411 }
386 412
387 // Create texture from logo bytes413 // Create texture from logo bytes
388- logoBytes := favicon.GetLogoBytes()414+ logoBytes := a.getLogoBytes()
389 texture := bytesToTexture(logoBytes)415 texture := bytesToTexture(logoBytes)
390 if texture != nil {416 if texture != nil {
391- a.setTexture(favicon.InternalDomain, texture)417+ a.setTexture(a.internalDomain, texture)
392 }418 }
393 return texture419 return texture
394 }420 }
M internal/ui/adapter/favicon_test.go +3 -3
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
11- adapter := NewFaviconAdapter(nil, nil)
11+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
1212
1313 first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com")
1414 if !first {
@@ -36,7 +36,7 @@ func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
3636 }
3737
3838 func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
39- adapter := NewFaviconAdapter(nil, nil)
39+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
4040 key := "sized-png:example.com"
4141
4242 first, _ := adapter.shouldLogWarningDedup(key)
@@ -56,7 +56,7 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
5656 }
5757
5858 func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
59- adapter := NewFaviconAdapter(nil, nil)
59+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
6060 key := "save-png:example.com"
6161 var calls atomic.Int32
6262
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {10 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
11- adapter := NewFaviconAdapter(nil, nil)11+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
12 12
13 first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com")13 first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com")
14 if !first {14 if !first {
@@ -36,7 +36,7 @@ func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
36 }36 }
37 37
38 func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {38 func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
39- adapter := NewFaviconAdapter(nil, nil)39+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
40 key := "sized-png:example.com"40 key := "sized-png:example.com"
41 41
42 first, _ := adapter.shouldLogWarningDedup(key)42 first, _ := adapter.shouldLogWarningDedup(key)
@@ -56,7 +56,7 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
56 }56 }
57 57
58 func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {58 func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
59- adapter := NewFaviconAdapter(nil, nil)59+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
60 key := "save-png:example.com"60 key := "save-png:example.com"
61 var calls atomic.Int3261 var calls atomic.Int32
62 62
M internal/ui/app.go +30 -19
@@ -371,7 +371,7 @@ func (a *App) prewarmWebViewPoolAsync(_ context.Context) {
371371 }
372372
373373 func (a *App) createMainWindow(ctx context.Context) error {
374- mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
374+ mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
375375 if err != nil {
376376 return err
377377 }
@@ -637,7 +637,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
637637 }
638638
639639 // Create keyboard handler and wire to dispatcher.
640- a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
640+ a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
641641 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
642642 if action == input.ActionClosePane {
643643 if a.closeAndReleaseActiveFloatingPane(ctx) {
@@ -712,7 +712,8 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
712712 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
713713 ctx,
714714 a.mainWindow.Window(),
715- a.deps.Config,
715+ &a.deps.Config.Workspace,
716+ &a.deps.Config.Session,
716717 a.keyboardHandler,
717718 func(ctx context.Context, action input.Action) error {
718719 return a.kbDispatcher.Dispatch(ctx, action)
@@ -1271,7 +1272,8 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
12711272 a.deps.CheckUpdateUC,
12721273 a.deps.ApplyUpdateUC,
12731274 a.appToaster,
1274- a.deps.Config,
1275+ a.deps.Config.Update.EnableOnStartup,
1276+ a.deps.Config.Update.AutoDownload,
12751277 )
12761278
12771279 // Start async update check
@@ -1520,7 +1522,7 @@ func (a *App) initCoordinators(ctx context.Context) {
15201522 }
15211523
15221524 // Create FaviconAdapter with service and engine FaviconDatabase
1523- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase())
1525+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
15241526
15251527 // 1. Content Coordinator (no dependencies on other coordinators)
15261528 a.contentCoord = content.NewCoordinator(
@@ -1539,6 +1541,11 @@ func (a *App) initCoordinators(ctx context.Context) {
15391541 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
15401542 }
15411543
1544+ // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
1545+ if a.deps.LaunchExternalURL != nil {
1546+ a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
1547+ }
1548+
15421549 // Wire deferred init trigger - runs after first navigation starts
15431550 a.contentCoord.SetOnFirstLoadStarted(func() {
15441551 a.triggerDeferredInit(ctx)
@@ -1546,10 +1553,10 @@ func (a *App) initCoordinators(ctx context.Context) {
15461553
15471554 // 2. Tab Coordinator
15481555 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1549- TabsUC: a.tabsUC,
1550- Tabs: a.tabs,
1551- MainWindow: a.mainWindow,
1552- Config: a.deps.Config,
1556+ TabsUC: a.tabsUC,
1557+ Tabs: a.tabs,
1558+ MainWindow: a.mainWindow,
1559+ HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
15531560 })
15541561 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
15551562 a.createWorkspaceView(ctx, tab)
@@ -1589,13 +1596,16 @@ func (a *App) initCoordinators(ctx context.Context) {
15891596
15901597 // 3. Workspace Coordinator
15911598 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1592- PanesUC: a.panesUC,
1593- FocusMgr: a.focusMgr,
1594- StackedPaneMgr: a.stackedPaneMgr,
1595- WidgetFactory: a.widgetFactory,
1596- ContentCoord: a.contentCoord,
1597- GetActiveWS: getActiveWS,
1598- GenerateID: a.generateID,
1599+ PanesUC: a.panesUC,
1600+ FocusMgr: a.focusMgr,
1601+ StackedPaneMgr: a.stackedPaneMgr,
1602+ WidgetFactory: a.widgetFactory,
1603+ ContentCoord: a.contentCoord,
1604+ GetActiveWS: getActiveWS,
1605+ GenerateID: a.generateID,
1606+ NewPaneURL: a.deps.Config.Workspace.NewPaneURL,
1607+ ResizeStepPercent: a.deps.Config.Workspace.ResizeMode.StepPercent,
1608+ ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
15991609 })
16001610 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
16011611 return a.tabCoord.Close(ctx)
@@ -1699,6 +1709,7 @@ func (a *App) initCoordinators(ctx context.Context) {
16991709 a.navCoord,
17001710 a.deps.ZoomUC,
17011711 a.deps.CopyURLUC,
1712+ a.deps.Config.Workspace.NewPaneURL,
17021713 )
17031714 a.wireKeyboardActions()
17041715
@@ -3067,11 +3078,11 @@ func (a *App) initConfigWatcher(ctx context.Context) {
30673078 a.applyAppearanceConfig(ctx)
30683079 // Reload keyboard shortcuts
30693080 if a.keyboardHandler != nil {
3070- a.keyboardHandler.ReloadShortcuts(ctx, a.deps.Config)
3081+ a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
30713082 }
30723083 // Reload global shortcuts (removes stale, re-registers from config)
30733084 if a.globalShortcutHandler != nil {
3074- a.globalShortcutHandler.ReloadShortcuts(ctx, a.deps.Config)
3085+ a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
30753086 }
30763087 return false
30773088 })
@@ -3103,7 +3114,7 @@ func (a *App) applyAppearanceConfig(ctx context.Context) {
31033114 if a.mainWindow != nil && a.mainWindow.Window() != nil {
31043115 display = a.mainWindow.Window().GetDisplay()
31053116 }
3106- a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
3117+ a.deps.Theme.UpdateFromConfig(ctx, &cfg.Appearance, cfg.DefaultUIScale, &cfg.Workspace.Styling, display)
31073118
31083119 var inj port.ContentInjector
31093120 if a.engine != nil {
@@ -371,7 +371,7 @@ func (a *App) prewarmWebViewPoolAsync(_ context.Context) {
371 }371 }
372 372
373 func (a *App) createMainWindow(ctx context.Context) error {373 func (a *App) createMainWindow(ctx context.Context) error {
374- mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)374+ mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
375 if err != nil {375 if err != nil {
376 return err376 return err
377 }377 }
@@ -637,7 +637,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
637 }637 }
638 638
639 // Create keyboard handler and wire to dispatcher.639 // Create keyboard handler and wire to dispatcher.
640- a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)640+ a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
641 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {641 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
642 if action == input.ActionClosePane {642 if action == input.ActionClosePane {
643 if a.closeAndReleaseActiveFloatingPane(ctx) {643 if a.closeAndReleaseActiveFloatingPane(ctx) {
@@ -712,7 +712,8 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
712 a.globalShortcutHandler = input.NewGlobalShortcutHandler(712 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
713 ctx,713 ctx,
714 a.mainWindow.Window(),714 a.mainWindow.Window(),
715- a.deps.Config,715+ &a.deps.Config.Workspace,
716+ &a.deps.Config.Session,
716 a.keyboardHandler,717 a.keyboardHandler,
717 func(ctx context.Context, action input.Action) error {718 func(ctx context.Context, action input.Action) error {
718 return a.kbDispatcher.Dispatch(ctx, action)719 return a.kbDispatcher.Dispatch(ctx, action)
@@ -1271,7 +1272,8 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
1271 a.deps.CheckUpdateUC,1272 a.deps.CheckUpdateUC,
1272 a.deps.ApplyUpdateUC,1273 a.deps.ApplyUpdateUC,
1273 a.appToaster,1274 a.appToaster,
1274- a.deps.Config,1275+ a.deps.Config.Update.EnableOnStartup,
1276+ a.deps.Config.Update.AutoDownload,
1275 )1277 )
1276 1278
1277 // Start async update check1279 // Start async update check
@@ -1520,7 +1522,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1520 }1522 }
1521 1523
1522 // Create FaviconAdapter with service and engine FaviconDatabase1524 // Create FaviconAdapter with service and engine FaviconDatabase
1523- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase())1525+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
1524 1526
1525 // 1. Content Coordinator (no dependencies on other coordinators)1527 // 1. Content Coordinator (no dependencies on other coordinators)
1526 a.contentCoord = content.NewCoordinator(1528 a.contentCoord = content.NewCoordinator(
@@ -1539,6 +1541,11 @@ func (a *App) initCoordinators(ctx context.Context) {
1539 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)1541 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1540 }1542 }
1541 1543
1544+ // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
1545+ if a.deps.LaunchExternalURL != nil {
1546+ a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
1547+ }
1548+
1542 // Wire deferred init trigger - runs after first navigation starts1549 // Wire deferred init trigger - runs after first navigation starts
1543 a.contentCoord.SetOnFirstLoadStarted(func() {1550 a.contentCoord.SetOnFirstLoadStarted(func() {
1544 a.triggerDeferredInit(ctx)1551 a.triggerDeferredInit(ctx)
@@ -1546,10 +1553,10 @@ func (a *App) initCoordinators(ctx context.Context) {
1546 1553
1547 // 2. Tab Coordinator1554 // 2. Tab Coordinator
1548 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{1555 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1549- TabsUC: a.tabsUC,1556+ TabsUC: a.tabsUC,
1550- Tabs: a.tabs,1557+ Tabs: a.tabs,
1551- MainWindow: a.mainWindow,1558+ MainWindow: a.mainWindow,
1552- Config: a.deps.Config,1559+ HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
1553 })1560 })
1554 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {1561 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1555 a.createWorkspaceView(ctx, tab)1562 a.createWorkspaceView(ctx, tab)
@@ -1589,13 +1596,16 @@ func (a *App) initCoordinators(ctx context.Context) {
1589 1596
1590 // 3. Workspace Coordinator1597 // 3. Workspace Coordinator
1591 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{1598 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1592- PanesUC: a.panesUC,1599+ PanesUC: a.panesUC,
1593- FocusMgr: a.focusMgr,1600+ FocusMgr: a.focusMgr,
1594- StackedPaneMgr: a.stackedPaneMgr,1601+ StackedPaneMgr: a.stackedPaneMgr,
1595- WidgetFactory: a.widgetFactory,1602+ WidgetFactory: a.widgetFactory,
1596- ContentCoord: a.contentCoord,1603+ ContentCoord: a.contentCoord,
1597- GetActiveWS: getActiveWS,1604+ GetActiveWS: getActiveWS,
1598- GenerateID: a.generateID,1605+ GenerateID: a.generateID,
1606+ NewPaneURL: a.deps.Config.Workspace.NewPaneURL,
1607+ ResizeStepPercent: a.deps.Config.Workspace.ResizeMode.StepPercent,
1608+ ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
1599 })1609 })
1600 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {1610 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1601 return a.tabCoord.Close(ctx)1611 return a.tabCoord.Close(ctx)
@@ -1699,6 +1709,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1699 a.navCoord,1709 a.navCoord,
1700 a.deps.ZoomUC,1710 a.deps.ZoomUC,
1701 a.deps.CopyURLUC,1711 a.deps.CopyURLUC,
1712+ a.deps.Config.Workspace.NewPaneURL,
1702 )1713 )
1703 a.wireKeyboardActions()1714 a.wireKeyboardActions()
1704 1715
@@ -3067,11 +3078,11 @@ func (a *App) initConfigWatcher(ctx context.Context) {
3067 a.applyAppearanceConfig(ctx)3078 a.applyAppearanceConfig(ctx)
3068 // Reload keyboard shortcuts3079 // Reload keyboard shortcuts
3069 if a.keyboardHandler != nil {3080 if a.keyboardHandler != nil {
3070- a.keyboardHandler.ReloadShortcuts(ctx, a.deps.Config)3081+ a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3071 }3082 }
3072 // Reload global shortcuts (removes stale, re-registers from config)3083 // Reload global shortcuts (removes stale, re-registers from config)
3073 if a.globalShortcutHandler != nil {3084 if a.globalShortcutHandler != nil {
3074- a.globalShortcutHandler.ReloadShortcuts(ctx, a.deps.Config)3085+ a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3075 }3086 }
3076 return false3087 return false
3077 })3088 })
@@ -3103,7 +3114,7 @@ func (a *App) applyAppearanceConfig(ctx context.Context) {
3103 if a.mainWindow != nil && a.mainWindow.Window() != nil {3114 if a.mainWindow != nil && a.mainWindow.Window() != nil {
3104 display = a.mainWindow.Window().GetDisplay()3115 display = a.mainWindow.Window().GetDisplay()
3105 }3116 }
3106- a.deps.Theme.UpdateFromConfig(ctx, cfg, display)3117+ a.deps.Theme.UpdateFromConfig(ctx, &cfg.Appearance, cfg.DefaultUIScale, &cfg.Workspace.Styling, display)
3107 3118
3108 var inj port.ContentInjector3119 var inj port.ContentInjector
3109 if a.engine != nil {3120 if a.engine != nil {
M internal/ui/config_migration.go +6 -5
@@ -4,7 +4,6 @@ import (
44 "context"
55 "fmt"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
87 "github.com/bnema/dumber/internal/logging"
98 "github.com/bnema/dumber/internal/ui/component"
109 )
@@ -12,10 +11,12 @@ import (
1211 // checkConfigMigration checks if user config needs migration and shows a toast.
1312 func (a *App) checkConfigMigration(ctx context.Context) {
1413 log := logging.FromContext(ctx)
15- cfg := config.Get()
1614
17- migrator := config.NewMigrator()
18- result, err := migrator.CheckMigration()
15+ if a.deps.ConfigMigrator == nil {
16+ return
17+ }
18+
19+ result, err := a.deps.ConfigMigrator.CheckMigration()
1920 if err != nil {
2021 log.Debug().Err(err).Msg("config migration check failed")
2122 return
@@ -31,7 +32,7 @@ func (a *App) checkConfigMigration(ctx context.Context) {
3132 Msg("config migration available")
3233
3334 // Show toast notification if enabled
34- if cfg.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ if a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil {
3536 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
3637 a.appToaster.Show(ctx, msg, component.ToastInfo)
3738 }
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7- "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/bnema/dumber/internal/logging"7 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/dumber/internal/ui/component"8 "github.com/bnema/dumber/internal/ui/component"
10 )9 )
@@ -12,10 +11,12 @@ import (
12 // checkConfigMigration checks if user config needs migration and shows a toast.11 // checkConfigMigration checks if user config needs migration and shows a toast.
13 func (a *App) checkConfigMigration(ctx context.Context) {12 func (a *App) checkConfigMigration(ctx context.Context) {
14 log := logging.FromContext(ctx)13 log := logging.FromContext(ctx)
15- cfg := config.Get()
16 14
17- migrator := config.NewMigrator()15+ if a.deps.ConfigMigrator == nil {
18- result, err := migrator.CheckMigration()16+ return
17+ }
18+
19+ result, err := a.deps.ConfigMigrator.CheckMigration()
19 if err != nil {20 if err != nil {
20 log.Debug().Err(err).Msg("config migration check failed")21 log.Debug().Err(err).Msg("config migration check failed")
21 return22 return
@@ -31,7 +32,7 @@ func (a *App) checkConfigMigration(ctx context.Context) {
31 Msg("config migration available")32 Msg("config migration available")
32 33
33 // Show toast notification if enabled34 // Show toast notification if enabled
34- if cfg.Update.NotifyOnNewSettings && a.appToaster != nil {35+ if a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil {
35 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))36 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
36 a.appToaster.Show(ctx, msg, component.ToastInfo)37 a.appToaster.Show(ctx, msg, component.ToastInfo)
37 }38 }
M internal/ui/coordinator/content/coordinator.go +12 -2
@@ -8,7 +8,7 @@ import (
88 "github.com/bnema/dumber/internal/application/port"
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/config"
11+
1212 "github.com/bnema/dumber/internal/logging"
1313 "github.com/bnema/dumber/internal/ui/adapter"
1414 "github.com/bnema/dumber/internal/ui/component"
@@ -79,7 +79,7 @@ type Coordinator struct {
7979
8080 // Popup handling
8181 factory port.WebViewFactory
82- popupConfig *config.PopupBehaviorConfig
82+ popupConfig *entity.PopupBehaviorConfig
8383 pendingPopups map[port.WebViewID]*PendingPopup
8484 popupOAuth map[port.WebViewID]*popupOAuthState
8585 popupRefresh map[entity.PaneID]*time.Timer
@@ -106,6 +106,10 @@ type Coordinator struct {
106106 // Callback for first load_started event (triggers deferred initialization)
107107 onFirstLoadStarted func()
108108 loadStartedOnce sync.Once
109+
110+ // Callback to open a URL with the system's default handler (e.g. xdg-open).
111+ // Used for external URL schemes like vscode://, spotify://, etc.
112+ onLaunchExternalURL func(uri string)
109113 }
110114
111115 type pendingThemeUpdate struct {
@@ -228,6 +232,12 @@ func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
228232 c.onFirstLoadStarted = fn
229233 }
230234
235+// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
236+// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
237+func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
238+ c.onLaunchExternalURL = fn
239+}
240+
231241 // ActivePaneID returns the currently active pane ID used by navigation.
232242 func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
233243 if paneID, ok := c.activePaneOverrideID(); ok {
@@ -8,7 +8,7 @@ import (
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/config"11+
12 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/adapter"13 "github.com/bnema/dumber/internal/ui/adapter"
14 "github.com/bnema/dumber/internal/ui/component"14 "github.com/bnema/dumber/internal/ui/component"
@@ -79,7 +79,7 @@ type Coordinator struct {
79 79
80 // Popup handling80 // Popup handling
81 factory port.WebViewFactory81 factory port.WebViewFactory
82- popupConfig *config.PopupBehaviorConfig82+ popupConfig *entity.PopupBehaviorConfig
83 pendingPopups map[port.WebViewID]*PendingPopup83 pendingPopups map[port.WebViewID]*PendingPopup
84 popupOAuth map[port.WebViewID]*popupOAuthState84 popupOAuth map[port.WebViewID]*popupOAuthState
85 popupRefresh map[entity.PaneID]*time.Timer85 popupRefresh map[entity.PaneID]*time.Timer
@@ -106,6 +106,10 @@ type Coordinator struct {
106 // Callback for first load_started event (triggers deferred initialization)106 // Callback for first load_started event (triggers deferred initialization)
107 onFirstLoadStarted func()107 onFirstLoadStarted func()
108 loadStartedOnce sync.Once108 loadStartedOnce sync.Once
109+
110+ // Callback to open a URL with the system's default handler (e.g. xdg-open).
111+ // Used for external URL schemes like vscode://, spotify://, etc.
112+ onLaunchExternalURL func(uri string)
109 }113 }
110 114
111 type pendingThemeUpdate struct {115 type pendingThemeUpdate struct {
@@ -228,6 +232,12 @@ func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
228 c.onFirstLoadStarted = fn232 c.onFirstLoadStarted = fn
229 }233 }
230 234
235+// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
236+// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
237+func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
238+ c.onLaunchExternalURL = fn
239+}
240+
231 // ActivePaneID returns the currently active pane ID used by navigation.241 // ActivePaneID returns the currently active pane ID used by navigation.
232 func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {242 func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
233 if paneID, ok := c.activePaneOverrideID(); ok {243 if paneID, ok := c.activePaneOverrideID(); ok {
M internal/ui/coordinator/content/navigation.go +4 -3
@@ -8,7 +8,6 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 urlutil "github.com/bnema/dumber/internal/domain/url"
11- "github.com/bnema/dumber/internal/infrastructure/desktop"
1211 "github.com/bnema/dumber/internal/logging"
1312 "github.com/jwijenbergh/puregotk/v4/gtk"
1413 )
@@ -324,8 +323,10 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
324323 if isExternal {
325324 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
326325
327- // Launch externally
328- desktop.LaunchExternalURL(uri)
326+ // Launch externally via injected callback
327+ if c.onLaunchExternalURL != nil {
328+ c.onLaunchExternalURL(uri)
329+ }
329330
330331 // Stop loading to prevent WebKit from showing an error page
331332 // The page stays on the previous URL before the JS redirect
@@ -8,7 +8,6 @@ import (
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 urlutil "github.com/bnema/dumber/internal/domain/url"10 urlutil "github.com/bnema/dumber/internal/domain/url"
11- "github.com/bnema/dumber/internal/infrastructure/desktop"
12 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
13 "github.com/jwijenbergh/puregotk/v4/gtk"12 "github.com/jwijenbergh/puregotk/v4/gtk"
14 )13 )
@@ -324,8 +323,10 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
324 if isExternal {323 if isExternal {
325 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")324 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
326 325
327- // Launch externally326+ // Launch externally via injected callback
328- desktop.LaunchExternalURL(uri)327+ if c.onLaunchExternalURL != nil {
328+ c.onLaunchExternalURL(uri)
329+ }
329 330
330 // Stop loading to prevent WebKit from showing an error page331 // Stop loading to prevent WebKit from showing an error page
331 // The page stays on the previous URL before the JS redirect332 // The page stays on the previous URL before the JS redirect
M internal/ui/coordinator/content/popup.go +10 -11
@@ -7,7 +7,6 @@ import (
77
88 "github.com/bnema/dumber/internal/application/port"
99 "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/config"
1110 "github.com/bnema/dumber/internal/logging"
1211 )
1312
@@ -93,7 +92,7 @@ type InsertPopupInput struct {
9392 WebView port.WebView
9493
9594 // Behavior determines how the popup is inserted (split/stacked/tabbed).
96- Behavior config.PopupBehavior
95+ Behavior entity.PopupBehavior
9796
9897 // Placement specifies direction for split behavior (right/left/top/bottom).
9998 Placement string
@@ -106,9 +105,9 @@ type InsertPopupInput struct {
106105 }
107106
108107 // GetBehavior returns the appropriate behavior based on popup type and config.
109-func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
108+func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.PopupBehavior {
110109 if cfg == nil {
111- return config.PopupBehaviorSplit // Default
110+ return entity.PopupBehaviorSplit // Default
112111 }
113112
114113 switch popupType {
@@ -116,13 +115,13 @@ func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.Po
116115 // Tab-like popups (_blank) use blank_target_behavior
117116 switch cfg.BlankTargetBehavior {
118117 case "split":
119- return config.PopupBehaviorSplit
118+ return entity.PopupBehaviorSplit
120119 case "stacked":
121- return config.PopupBehaviorStacked
120+ return entity.PopupBehaviorStacked
122121 case "tabbed":
123- return config.PopupBehaviorTabbed
122+ return entity.PopupBehaviorTabbed
124123 default:
125- return config.PopupBehaviorStacked // Default for _blank
124+ return entity.PopupBehaviorStacked // Default for _blank
126125 }
127126 case PopupTypePopup:
128127 // JS popups use the main behavior setting
@@ -135,7 +134,7 @@ func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.Po
135134 // SetPopupConfig configures popup handling.
136135 func (c *Coordinator) SetPopupConfig(
137136 factory port.WebViewFactory,
138- popupConfig *config.PopupBehaviorConfig,
137+ popupConfig *entity.PopupBehaviorConfig,
139138 generateID func() string,
140139 ) {
141140 c.factory = factory
@@ -476,9 +475,9 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
476475 c.setupWebViewCallbacks(ctx, paneID, newWV)
477476
478477 // Get behavior from config (same as _blank links)
479- behavior := config.PopupBehaviorStacked // default
478+ behavior := entity.PopupBehaviorStacked // default
480479 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
481- behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
480+ behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)
482481 }
483482 placement := "right"
484483 if c.popupConfig != nil {
@@ -7,7 +7,6 @@ import (
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
12 )11 )
13 12
@@ -93,7 +92,7 @@ type InsertPopupInput struct {
93 WebView port.WebView92 WebView port.WebView
94 93
95 // Behavior determines how the popup is inserted (split/stacked/tabbed).94 // Behavior determines how the popup is inserted (split/stacked/tabbed).
96- Behavior config.PopupBehavior95+ Behavior entity.PopupBehavior
97 96
98 // Placement specifies direction for split behavior (right/left/top/bottom).97 // Placement specifies direction for split behavior (right/left/top/bottom).
99 Placement string98 Placement string
@@ -106,9 +105,9 @@ type InsertPopupInput struct {
106 }105 }
107 106
108 // GetBehavior returns the appropriate behavior based on popup type and config.107 // GetBehavior returns the appropriate behavior based on popup type and config.
109-func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {108+func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.PopupBehavior {
110 if cfg == nil {109 if cfg == nil {
111- return config.PopupBehaviorSplit // Default110+ return entity.PopupBehaviorSplit // Default
112 }111 }
113 112
114 switch popupType {113 switch popupType {
@@ -116,13 +115,13 @@ func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.Po
116 // Tab-like popups (_blank) use blank_target_behavior115 // Tab-like popups (_blank) use blank_target_behavior
117 switch cfg.BlankTargetBehavior {116 switch cfg.BlankTargetBehavior {
118 case "split":117 case "split":
119- return config.PopupBehaviorSplit118+ return entity.PopupBehaviorSplit
120 case "stacked":119 case "stacked":
121- return config.PopupBehaviorStacked120+ return entity.PopupBehaviorStacked
122 case "tabbed":121 case "tabbed":
123- return config.PopupBehaviorTabbed122+ return entity.PopupBehaviorTabbed
124 default:123 default:
125- return config.PopupBehaviorStacked // Default for _blank124+ return entity.PopupBehaviorStacked // Default for _blank
126 }125 }
127 case PopupTypePopup:126 case PopupTypePopup:
128 // JS popups use the main behavior setting127 // JS popups use the main behavior setting
@@ -135,7 +134,7 @@ func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.Po
135 // SetPopupConfig configures popup handling.134 // SetPopupConfig configures popup handling.
136 func (c *Coordinator) SetPopupConfig(135 func (c *Coordinator) SetPopupConfig(
137 factory port.WebViewFactory,136 factory port.WebViewFactory,
138- popupConfig *config.PopupBehaviorConfig,137+ popupConfig *entity.PopupBehaviorConfig,
139 generateID func() string,138 generateID func() string,
140 ) {139 ) {
141 c.factory = factory140 c.factory = factory
@@ -476,9 +475,9 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
476 c.setupWebViewCallbacks(ctx, paneID, newWV)475 c.setupWebViewCallbacks(ctx, paneID, newWV)
477 476
478 // Get behavior from config (same as _blank links)477 // Get behavior from config (same as _blank links)
479- behavior := config.PopupBehaviorStacked // default478+ behavior := entity.PopupBehaviorStacked // default
480 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {479 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
481- behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)480+ behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)
482 }481 }
483 placement := "right"482 placement := "right"
484 if c.popupConfig != nil {483 if c.popupConfig != nil {
M internal/ui/coordinator/tab.go +13 -17
@@ -6,7 +6,6 @@ import (
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/config"
109 "github.com/bnema/dumber/internal/logging"
1110 "github.com/bnema/dumber/internal/ui/component"
1211 "github.com/bnema/dumber/internal/ui/window"
@@ -14,10 +13,10 @@ import (
1413
1514 // TabCoordinator manages tab lifecycle operations.
1615 type TabCoordinator struct {
17- tabsUC *usecase.ManageTabsUseCase
18- tabs *entity.TabList
19- mainWindow *window.MainWindow
20- config *config.Config
16+ tabsUC *usecase.ManageTabsUseCase
17+ tabs *entity.TabList
18+ mainWindow *window.MainWindow
19+ hideTabBarWhenSingleTab bool
2120
2221 // Callbacks to avoid circular dependencies
2322 onTabCreated func(ctx context.Context, tab *entity.Tab)
@@ -29,10 +28,10 @@ type TabCoordinator struct {
2928
3029 // TabCoordinatorConfig holds configuration for TabCoordinator.
3130 type TabCoordinatorConfig struct {
32- TabsUC *usecase.ManageTabsUseCase
33- Tabs *entity.TabList
34- MainWindow *window.MainWindow
35- Config *config.Config
31+ TabsUC *usecase.ManageTabsUseCase
32+ Tabs *entity.TabList
33+ MainWindow *window.MainWindow
34+ HideTabBarWhenSingleTab bool
3635 }
3736
3837 // NewTabCoordinator creates a new TabCoordinator.
@@ -41,10 +40,10 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi
4140 log.Debug().Msg("creating tab coordinator")
4241
4342 return &TabCoordinator{
44- tabsUC: cfg.TabsUC,
45- tabs: cfg.Tabs,
46- mainWindow: cfg.MainWindow,
47- config: cfg.Config,
43+ tabsUC: cfg.TabsUC,
44+ tabs: cfg.Tabs,
45+ mainWindow: cfg.MainWindow,
46+ hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
4847 }
4948 }
5049
@@ -304,10 +303,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
304303 log := logging.FromContext(ctx)
305304
306305 // Check if feature is enabled
307- hideEnabled := true
308- if c.config != nil {
309- hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab
310- }
306+ hideEnabled := c.hideTabBarWhenSingleTab
311307
312308 if !hideEnabled {
313309 log.Debug().Msg("tab bar auto-hide disabled by config")
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- "github.com/bnema/dumber/internal/infrastructure/config"
10 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"10 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/window"11 "github.com/bnema/dumber/internal/ui/window"
@@ -14,10 +13,10 @@ import (
14 13
15 // TabCoordinator manages tab lifecycle operations.14 // TabCoordinator manages tab lifecycle operations.
16 type TabCoordinator struct {15 type TabCoordinator struct {
17- tabsUC *usecase.ManageTabsUseCase16+ tabsUC *usecase.ManageTabsUseCase
18- tabs *entity.TabList17+ tabs *entity.TabList
19- mainWindow *window.MainWindow18+ mainWindow *window.MainWindow
20- config *config.Config19+ hideTabBarWhenSingleTab bool
21 20
22 // Callbacks to avoid circular dependencies21 // Callbacks to avoid circular dependencies
23 onTabCreated func(ctx context.Context, tab *entity.Tab)22 onTabCreated func(ctx context.Context, tab *entity.Tab)
@@ -29,10 +28,10 @@ type TabCoordinator struct {
29 28
30 // TabCoordinatorConfig holds configuration for TabCoordinator.29 // TabCoordinatorConfig holds configuration for TabCoordinator.
31 type TabCoordinatorConfig struct {30 type TabCoordinatorConfig struct {
32- TabsUC *usecase.ManageTabsUseCase31+ TabsUC *usecase.ManageTabsUseCase
33- Tabs *entity.TabList32+ Tabs *entity.TabList
34- MainWindow *window.MainWindow33+ MainWindow *window.MainWindow
35- Config *config.Config34+ HideTabBarWhenSingleTab bool
36 }35 }
37 36
38 // NewTabCoordinator creates a new TabCoordinator.37 // NewTabCoordinator creates a new TabCoordinator.
@@ -41,10 +40,10 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi
41 log.Debug().Msg("creating tab coordinator")40 log.Debug().Msg("creating tab coordinator")
42 41
43 return &TabCoordinator{42 return &TabCoordinator{
44- tabsUC: cfg.TabsUC,43+ tabsUC: cfg.TabsUC,
45- tabs: cfg.Tabs,44+ tabs: cfg.Tabs,
46- mainWindow: cfg.MainWindow,45+ mainWindow: cfg.MainWindow,
47- config: cfg.Config,46+ hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
48 }47 }
49 }48 }
50 49
@@ -304,10 +303,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
304 log := logging.FromContext(ctx)303 log := logging.FromContext(ctx)
305 304
306 // Check if feature is enabled305 // Check if feature is enabled
307- hideEnabled := true306+ hideEnabled := c.hideTabBarWhenSingleTab
308- if c.config != nil {
309- hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab
310- }
311 307
312 if !hideEnabled {308 if !hideEnabled {
313 log.Debug().Msg("tab bar auto-hide disabled by config")309 log.Debug().Msg("tab bar auto-hide disabled by config")
M internal/ui/coordinator/update.go +25 -32
@@ -9,44 +9,47 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
12- "github.com/bnema/dumber/internal/infrastructure/config"
1312 "github.com/bnema/dumber/internal/logging"
1413 "github.com/bnema/dumber/internal/ui/component"
1514 )
1615
1716 // UpdateCoordinator handles update checking and notification.
1817 type UpdateCoordinator struct {
19- checkUC *usecase.CheckUpdateUseCase
20- applyUC *usecase.ApplyUpdateUseCase
21- toaster *component.Toaster
22- config *config.Config
23- mu sync.RWMutex // Protects status and lastInfo
24- status entity.UpdateStatus
25- lastInfo *usecase.CheckUpdateOutput
18+ checkUC *usecase.CheckUpdateUseCase
19+ applyUC *usecase.ApplyUpdateUseCase
20+ toaster *component.Toaster
21+ enableOnStartup bool
22+ autoDownload bool
23+ mu sync.RWMutex // Protects status and lastInfo
24+ status entity.UpdateStatus
25+ lastInfo *usecase.CheckUpdateOutput
2626 }
2727
2828 // NewUpdateCoordinator creates a new update coordinator.
29+// enableOnStartup controls whether an update check runs at startup.
30+// autoDownload controls whether updates are downloaded automatically in the background.
2931 func NewUpdateCoordinator(
3032 checkUC *usecase.CheckUpdateUseCase,
3133 applyUC *usecase.ApplyUpdateUseCase,
3234 toaster *component.Toaster,
33- cfg *config.Config,
35+ enableOnStartup bool,
36+ autoDownload bool,
3437 ) *UpdateCoordinator {
3538 return &UpdateCoordinator{
36- checkUC: checkUC,
37- applyUC: applyUC,
38- toaster: toaster,
39- config: cfg,
40- status: entity.UpdateStatusUnknown,
39+ checkUC: checkUC,
40+ applyUC: applyUC,
41+ toaster: toaster,
42+ enableOnStartup: enableOnStartup,
43+ autoDownload: autoDownload,
44+ status: entity.UpdateStatusUnknown,
4145 }
4246 }
4347
44-// CheckOnStartup performs an async update check if enabled in config.
45-// This should be called during app initialization.
48+// CheckOnStartup triggers an update check if enabled by config.
4649 func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
4750 log := logging.FromContext(ctx)
4851
49- if !c.config.Update.EnableOnStartup {
52+ if !c.enableOnStartup {
5053 log.Debug().Msg("update check on startup disabled")
5154 return
5255 }
@@ -54,7 +57,6 @@ func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
5457 go c.checkAsync(ctx)
5558 }
5659
57-// checkAsync performs the update check in a goroutine.
5860 func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
5961 log := logging.FromContext(ctx)
6062
@@ -67,7 +69,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
6769 return
6870 }
6971
70- // Update both lastInfo and status atomically to avoid inconsistent reads.
7172 c.mu.Lock()
7273 c.lastInfo = result
7374 if !result.UpdateAvailable {
@@ -86,27 +87,22 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
8687 Bool("can_auto_update", result.CanAutoUpdate).
8788 Msg("update available")
8889
89- // Show notification on GTK main thread.
9090 c.showUpdateNotification(ctx, result)
9191
92- // Auto-download if enabled and possible.
93- if c.config.Update.AutoDownload && result.CanAutoUpdate {
94- // Spawn download in separate goroutine to not block check completion.
92+ if c.autoDownload && result.CanAutoUpdate {
9593 go c.downloadAsync(ctx, result.DownloadURL)
9694 }
9795 }
9896
99-// showUpdateNotification displays a toast notification about the update.
10097 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {
10198 var msg string
10299 switch {
103- case result.CanAutoUpdate && c.config.Update.AutoDownload:
100+ case result.CanAutoUpdate && c.autoDownload:
104101 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)
105102 default:
106103 msg = fmt.Sprintf("Update %s available", result.LatestVersion)
107104 }
108105
109- // Dispatch to GTK main thread.
110106 cb := glib.SourceFunc(func(_ uintptr) bool {
111107 c.toaster.Show(ctx, msg, component.ToastInfo)
112108 return false
@@ -114,7 +110,6 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *
114110 glib.IdleAdd(&cb, 0)
115111 }
116112
117-// downloadAsync downloads and stages the update in the background.
118113 func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) {
119114 log := logging.FromContext(ctx)
120115
@@ -148,7 +143,6 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin
148143 }
149144 }
150145
151-// showToast dispatches a toast notification to the GTK main thread.
152146 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
153147 cb := glib.SourceFunc(func(_ uintptr) bool {
154148 c.toaster.Show(ctx, msg, level)
@@ -157,8 +151,7 @@ func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level com
157151 glib.IdleAdd(&cb, 0)
158152 }
159153
160-// FinalizeOnExit applies any staged update during shutdown.
161-// This should be called from the app's shutdown handler.
154+// FinalizeOnExit applies a staged update on process exit.
162155 func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error {
163156 if c.applyUC == nil {
164157 return nil
@@ -173,14 +166,14 @@ func (c *UpdateCoordinator) Status() entity.UpdateStatus {
173166 return c.status
174167 }
175168
176-// LastCheckResult returns the result of the last update check.
169+// LastCheckResult returns the most recent update check result.
177170 func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput {
178171 c.mu.RLock()
179172 defer c.mu.RUnlock()
180173 return c.lastInfo
181174 }
182175
183-// HasPendingUpdate returns true if an update is staged for exit.
176+// HasPendingUpdate returns true if a downloaded update is waiting to be applied.
184177 func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool {
185178 if c.applyUC == nil {
186179 return false
@@ -9,44 +9,47 @@ import (
9 9
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12- "github.com/bnema/dumber/internal/infrastructure/config"
13 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/ui/component"13 "github.com/bnema/dumber/internal/ui/component"
15 )14 )
16 15
17 // UpdateCoordinator handles update checking and notification.16 // UpdateCoordinator handles update checking and notification.
18 type UpdateCoordinator struct {17 type UpdateCoordinator struct {
19- checkUC *usecase.CheckUpdateUseCase18+ checkUC *usecase.CheckUpdateUseCase
20- applyUC *usecase.ApplyUpdateUseCase19+ applyUC *usecase.ApplyUpdateUseCase
21- toaster *component.Toaster20+ toaster *component.Toaster
22- config *config.Config21+ enableOnStartup bool
23- mu sync.RWMutex // Protects status and lastInfo22+ autoDownload bool
24- status entity.UpdateStatus23+ mu sync.RWMutex // Protects status and lastInfo
25- lastInfo *usecase.CheckUpdateOutput24+ status entity.UpdateStatus
25+ lastInfo *usecase.CheckUpdateOutput
26 }26 }
27 27
28 // NewUpdateCoordinator creates a new update coordinator.28 // NewUpdateCoordinator creates a new update coordinator.
29+// enableOnStartup controls whether an update check runs at startup.
30+// autoDownload controls whether updates are downloaded automatically in the background.
29 func NewUpdateCoordinator(31 func NewUpdateCoordinator(
30 checkUC *usecase.CheckUpdateUseCase,32 checkUC *usecase.CheckUpdateUseCase,
31 applyUC *usecase.ApplyUpdateUseCase,33 applyUC *usecase.ApplyUpdateUseCase,
32 toaster *component.Toaster,34 toaster *component.Toaster,
33- cfg *config.Config,35+ enableOnStartup bool,
36+ autoDownload bool,
34 ) *UpdateCoordinator {37 ) *UpdateCoordinator {
35 return &UpdateCoordinator{38 return &UpdateCoordinator{
36- checkUC: checkUC,39+ checkUC: checkUC,
37- applyUC: applyUC,40+ applyUC: applyUC,
38- toaster: toaster,41+ toaster: toaster,
39- config: cfg,42+ enableOnStartup: enableOnStartup,
40- status: entity.UpdateStatusUnknown,43+ autoDownload: autoDownload,
44+ status: entity.UpdateStatusUnknown,
41 }45 }
42 }46 }
43 47
44-// CheckOnStartup performs an async update check if enabled in config.48+// CheckOnStartup triggers an update check if enabled by config.
45-// This should be called during app initialization.
46 func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {49 func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
47 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
48 51
49- if !c.config.Update.EnableOnStartup {52+ if !c.enableOnStartup {
50 log.Debug().Msg("update check on startup disabled")53 log.Debug().Msg("update check on startup disabled")
51 return54 return
52 }55 }
@@ -54,7 +57,6 @@ func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
54 go c.checkAsync(ctx)57 go c.checkAsync(ctx)
55 }58 }
56 59
57-// checkAsync performs the update check in a goroutine.
58 func (c *UpdateCoordinator) checkAsync(ctx context.Context) {60 func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
59 log := logging.FromContext(ctx)61 log := logging.FromContext(ctx)
60 62
@@ -67,7 +69,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
67 return69 return
68 }70 }
69 71
70- // Update both lastInfo and status atomically to avoid inconsistent reads.
71 c.mu.Lock()72 c.mu.Lock()
72 c.lastInfo = result73 c.lastInfo = result
73 if !result.UpdateAvailable {74 if !result.UpdateAvailable {
@@ -86,27 +87,22 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
86 Bool("can_auto_update", result.CanAutoUpdate).87 Bool("can_auto_update", result.CanAutoUpdate).
87 Msg("update available")88 Msg("update available")
88 89
89- // Show notification on GTK main thread.
90 c.showUpdateNotification(ctx, result)90 c.showUpdateNotification(ctx, result)
91 91
92- // Auto-download if enabled and possible.92+ if c.autoDownload && result.CanAutoUpdate {
93- if c.config.Update.AutoDownload && result.CanAutoUpdate {
94- // Spawn download in separate goroutine to not block check completion.
95 go c.downloadAsync(ctx, result.DownloadURL)93 go c.downloadAsync(ctx, result.DownloadURL)
96 }94 }
97 }95 }
98 96
99-// showUpdateNotification displays a toast notification about the update.
100 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {97 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {
101 var msg string98 var msg string
102 switch {99 switch {
103- case result.CanAutoUpdate && c.config.Update.AutoDownload:100+ case result.CanAutoUpdate && c.autoDownload:
104 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)101 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)
105 default:102 default:
106 msg = fmt.Sprintf("Update %s available", result.LatestVersion)103 msg = fmt.Sprintf("Update %s available", result.LatestVersion)
107 }104 }
108 105
109- // Dispatch to GTK main thread.
110 cb := glib.SourceFunc(func(_ uintptr) bool {106 cb := glib.SourceFunc(func(_ uintptr) bool {
111 c.toaster.Show(ctx, msg, component.ToastInfo)107 c.toaster.Show(ctx, msg, component.ToastInfo)
112 return false108 return false
@@ -114,7 +110,6 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *
114 glib.IdleAdd(&cb, 0)110 glib.IdleAdd(&cb, 0)
115 }111 }
116 112
117-// downloadAsync downloads and stages the update in the background.
118 func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) {113 func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) {
119 log := logging.FromContext(ctx)114 log := logging.FromContext(ctx)
120 115
@@ -148,7 +143,6 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin
148 }143 }
149 }144 }
150 145
151-// showToast dispatches a toast notification to the GTK main thread.
152 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {146 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
153 cb := glib.SourceFunc(func(_ uintptr) bool {147 cb := glib.SourceFunc(func(_ uintptr) bool {
154 c.toaster.Show(ctx, msg, level)148 c.toaster.Show(ctx, msg, level)
@@ -157,8 +151,7 @@ func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level com
157 glib.IdleAdd(&cb, 0)151 glib.IdleAdd(&cb, 0)
158 }152 }
159 153
160-// FinalizeOnExit applies any staged update during shutdown.154+// FinalizeOnExit applies a staged update on process exit.
161-// This should be called from the app's shutdown handler.
162 func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error {155 func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error {
163 if c.applyUC == nil {156 if c.applyUC == nil {
164 return nil157 return nil
@@ -173,14 +166,14 @@ func (c *UpdateCoordinator) Status() entity.UpdateStatus {
173 return c.status166 return c.status
174 }167 }
175 168
176-// LastCheckResult returns the result of the last update check.169+// LastCheckResult returns the most recent update check result.
177 func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput {170 func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput {
178 c.mu.RLock()171 c.mu.RLock()
179 defer c.mu.RUnlock()172 defer c.mu.RUnlock()
180 return c.lastInfo173 return c.lastInfo
181 }174 }
182 175
183-// HasPendingUpdate returns true if an update is staged for exit.176+// HasPendingUpdate returns true if a downloaded update is waiting to be applied.
184 func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool {177 func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool {
185 if c.applyUC == nil {178 if c.applyUC == nil {
186 return false179 return false
M internal/ui/coordinator/workspace.go +32 -24
@@ -8,7 +8,6 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 domainurl "github.com/bnema/dumber/internal/domain/url"
11- "github.com/bnema/dumber/internal/infrastructure/config"
1211 "github.com/bnema/dumber/internal/logging"
1312 "github.com/bnema/dumber/internal/ui/component"
1413 "github.com/bnema/dumber/internal/ui/coordinator/content"
@@ -30,6 +29,11 @@ type WorkspaceCoordinator struct {
3029 widgetFactory layout.WidgetFactory
3130 contentCoord *content.Coordinator
3231
32+ // Config-derived values (injected to avoid direct config dependency)
33+ newPaneURL string
34+ resizeStepPercent float64
35+ resizeMinPanePercent float64
36+
3337 // Callbacks to avoid circular dependencies
3438 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
3539 generateID func() string
@@ -41,13 +45,16 @@ type WorkspaceCoordinator struct {
4145
4246 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
4347 type WorkspaceCoordinatorConfig struct {
44- PanesUC *usecase.ManagePanesUseCase
45- FocusMgr *focus.Manager
46- StackedPaneMgr *component.StackedPaneManager
47- WidgetFactory layout.WidgetFactory
48- ContentCoord *content.Coordinator
49- GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
50- GenerateID func() string
48+ PanesUC *usecase.ManagePanesUseCase
49+ FocusMgr *focus.Manager
50+ StackedPaneMgr *component.StackedPaneManager
51+ WidgetFactory layout.WidgetFactory
52+ ContentCoord *content.Coordinator
53+ GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
54+ GenerateID func() string
55+ NewPaneURL string
56+ ResizeStepPercent float64
57+ ResizeMinPanePercent float64
5158 }
5259
5360 type splitContext struct {
@@ -82,13 +89,16 @@ func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig
8289 log.Debug().Msg("creating workspace coordinator")
8390
8491 return &WorkspaceCoordinator{
85- panesUC: cfg.PanesUC,
86- focusMgr: cfg.FocusMgr,
87- stackedPaneMgr: cfg.StackedPaneMgr,
88- widgetFactory: cfg.WidgetFactory,
89- contentCoord: cfg.ContentCoord,
90- getActiveWS: cfg.GetActiveWS,
91- generateID: cfg.GenerateID,
92+ panesUC: cfg.PanesUC,
93+ focusMgr: cfg.FocusMgr,
94+ stackedPaneMgr: cfg.StackedPaneMgr,
95+ widgetFactory: cfg.WidgetFactory,
96+ contentCoord: cfg.ContentCoord,
97+ getActiveWS: cfg.GetActiveWS,
98+ generateID: cfg.GenerateID,
99+ newPaneURL: cfg.NewPaneURL,
100+ resizeStepPercent: cfg.ResizeStepPercent,
101+ resizeMinPanePercent: cfg.ResizeMinPanePercent,
92102 }
93103 }
94104
@@ -144,7 +154,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
144154 Workspace: splitCtx.ws,
145155 TargetPane: splitCtx.activePane,
146156 Direction: direction,
147- InitialURL: domainurl.Normalize(config.Get().Workspace.NewPaneURL),
157+ InitialURL: domainurl.Normalize(c.newPaneURL),
148158 })
149159 if err != nil {
150160 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
@@ -1350,7 +1360,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
13501360 // Create new pane entity
13511361 newPaneID := entity.PaneID(c.generateID())
13521362 newPane := entity.NewPane(newPaneID)
1353- newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
1363+ newPane.URI = domainurl.Normalize(c.newPaneURL)
13541364 newPane.Title = defaultPaneTitle
13551365
13561366 // Determine if we need to create a new stack or add to existing
@@ -1775,11 +1785,11 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.In
17751785 Msg("inserting popup into workspace")
17761786
17771787 switch input.Behavior {
1778- case config.PopupBehaviorSplit:
1788+ case entity.PopupBehaviorSplit:
17791789 return c.insertPopupSplit(ctx, input)
1780- case config.PopupBehaviorStacked:
1790+ case entity.PopupBehaviorStacked:
17811791 return c.insertPopupStacked(ctx, input)
1782- case config.PopupBehaviorTabbed:
1792+ case entity.PopupBehaviorTabbed:
17831793 return c.insertPopupTabbed(ctx, input)
17841794 default:
17851795 // Default to split right
@@ -2198,8 +2208,7 @@ func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDir
21982208 return nil
21992209 }
22002210
2201- cfg := config.Get()
2202- err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
2211+ err := c.panesUC.Resize(ctx, ws, target, dir, c.resizeStepPercent, c.resizeMinPanePercent)
22032212 if errors.Is(err, usecase.ErrNothingToResize) {
22042213 c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
22052214 return nil
@@ -2230,12 +2239,11 @@ func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID st
22302239 return nil
22312240 }
22322241
2233- cfg := config.Get()
22342242 err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
22352243 Workspace: ws,
22362244 SplitNodeID: splitNodeID,
22372245 Ratio: ratio,
2238- MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,
2246+ MinPanePercent: c.resizeMinPanePercent,
22392247 })
22402248 if err != nil {
22412249 return err
@@ -8,7 +8,6 @@ import (
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 domainurl "github.com/bnema/dumber/internal/domain/url"10 domainurl "github.com/bnema/dumber/internal/domain/url"
11- "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/component"12 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/dumber/internal/ui/coordinator/content"13 "github.com/bnema/dumber/internal/ui/coordinator/content"
@@ -30,6 +29,11 @@ type WorkspaceCoordinator struct {
30 widgetFactory layout.WidgetFactory29 widgetFactory layout.WidgetFactory
31 contentCoord *content.Coordinator30 contentCoord *content.Coordinator
32 31
32+ // Config-derived values (injected to avoid direct config dependency)
33+ newPaneURL string
34+ resizeStepPercent float64
35+ resizeMinPanePercent float64
36+
33 // Callbacks to avoid circular dependencies37 // Callbacks to avoid circular dependencies
34 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)38 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
35 generateID func() string39 generateID func() string
@@ -41,13 +45,16 @@ type WorkspaceCoordinator struct {
41 45
42 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.46 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
43 type WorkspaceCoordinatorConfig struct {47 type WorkspaceCoordinatorConfig struct {
44- PanesUC *usecase.ManagePanesUseCase48+ PanesUC *usecase.ManagePanesUseCase
45- FocusMgr *focus.Manager49+ FocusMgr *focus.Manager
46- StackedPaneMgr *component.StackedPaneManager50+ StackedPaneMgr *component.StackedPaneManager
47- WidgetFactory layout.WidgetFactory51+ WidgetFactory layout.WidgetFactory
48- ContentCoord *content.Coordinator52+ ContentCoord *content.Coordinator
49- GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)53+ GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
50- GenerateID func() string54+ GenerateID func() string
55+ NewPaneURL string
56+ ResizeStepPercent float64
57+ ResizeMinPanePercent float64
51 }58 }
52 59
53 type splitContext struct {60 type splitContext struct {
@@ -82,13 +89,16 @@ func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig
82 log.Debug().Msg("creating workspace coordinator")89 log.Debug().Msg("creating workspace coordinator")
83 90
84 return &WorkspaceCoordinator{91 return &WorkspaceCoordinator{
85- panesUC: cfg.PanesUC,92+ panesUC: cfg.PanesUC,
86- focusMgr: cfg.FocusMgr,93+ focusMgr: cfg.FocusMgr,
87- stackedPaneMgr: cfg.StackedPaneMgr,94+ stackedPaneMgr: cfg.StackedPaneMgr,
88- widgetFactory: cfg.WidgetFactory,95+ widgetFactory: cfg.WidgetFactory,
89- contentCoord: cfg.ContentCoord,96+ contentCoord: cfg.ContentCoord,
90- getActiveWS: cfg.GetActiveWS,97+ getActiveWS: cfg.GetActiveWS,
91- generateID: cfg.GenerateID,98+ generateID: cfg.GenerateID,
99+ newPaneURL: cfg.NewPaneURL,
100+ resizeStepPercent: cfg.ResizeStepPercent,
101+ resizeMinPanePercent: cfg.ResizeMinPanePercent,
92 }102 }
93 }103 }
94 104
@@ -144,7 +154,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
144 Workspace: splitCtx.ws,154 Workspace: splitCtx.ws,
145 TargetPane: splitCtx.activePane,155 TargetPane: splitCtx.activePane,
146 Direction: direction,156 Direction: direction,
147- InitialURL: domainurl.Normalize(config.Get().Workspace.NewPaneURL),157+ InitialURL: domainurl.Normalize(c.newPaneURL),
148 })158 })
149 if err != nil {159 if err != nil {
150 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")160 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
@@ -1350,7 +1360,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1350 // Create new pane entity1360 // Create new pane entity
1351 newPaneID := entity.PaneID(c.generateID())1361 newPaneID := entity.PaneID(c.generateID())
1352 newPane := entity.NewPane(newPaneID)1362 newPane := entity.NewPane(newPaneID)
1353- newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)1363+ newPane.URI = domainurl.Normalize(c.newPaneURL)
1354 newPane.Title = defaultPaneTitle1364 newPane.Title = defaultPaneTitle
1355 1365
1356 // Determine if we need to create a new stack or add to existing1366 // Determine if we need to create a new stack or add to existing
@@ -1775,11 +1785,11 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.In
1775 Msg("inserting popup into workspace")1785 Msg("inserting popup into workspace")
1776 1786
1777 switch input.Behavior {1787 switch input.Behavior {
1778- case config.PopupBehaviorSplit:1788+ case entity.PopupBehaviorSplit:
1779 return c.insertPopupSplit(ctx, input)1789 return c.insertPopupSplit(ctx, input)
1780- case config.PopupBehaviorStacked:1790+ case entity.PopupBehaviorStacked:
1781 return c.insertPopupStacked(ctx, input)1791 return c.insertPopupStacked(ctx, input)
1782- case config.PopupBehaviorTabbed:1792+ case entity.PopupBehaviorTabbed:
1783 return c.insertPopupTabbed(ctx, input)1793 return c.insertPopupTabbed(ctx, input)
1784 default:1794 default:
1785 // Default to split right1795 // Default to split right
@@ -2198,8 +2208,7 @@ func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDir
2198 return nil2208 return nil
2199 }2209 }
2200 2210
2201- cfg := config.Get()2211+ err := c.panesUC.Resize(ctx, ws, target, dir, c.resizeStepPercent, c.resizeMinPanePercent)
2202- err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
2203 if errors.Is(err, usecase.ErrNothingToResize) {2212 if errors.Is(err, usecase.ErrNothingToResize) {
2204 c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)2213 c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
2205 return nil2214 return nil
@@ -2230,12 +2239,11 @@ func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID st
2230 return nil2239 return nil
2231 }2240 }
2232 2241
2233- cfg := config.Get()
2234 err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{2242 err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
2235 Workspace: ws,2243 Workspace: ws,
2236 SplitNodeID: splitNodeID,2244 SplitNodeID: splitNodeID,
2237 Ratio: ratio,2245 Ratio: ratio,
2238- MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,2246+ MinPanePercent: c.resizeMinPanePercent,
2239 })2247 })
2240 if err != nil {2248 if err != nil {
2241 return err2249 return err
M internal/ui/deps.go +14 -4
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/domain/repository"
1111 "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/ui/adapter"
1213 "github.com/bnema/dumber/internal/ui/theme"
1314 "github.com/jwijenbergh/puregotk/v4/gtk"
1415 )
@@ -55,10 +56,11 @@ type Dependencies struct {
5556 CopyURLUC *usecase.CopyURLUseCase
5657
5758 // Infrastructure Adapters
58- Clipboard port.Clipboard
59- FaviconService port.FaviconService
60- FilterManager port.FilterManager
61- IdleInhibitor port.IdleInhibitor
59+ Clipboard port.Clipboard
60+ FaviconService port.FaviconService
61+ FaviconAdapterConfig adapter.FaviconAdapterConfig
62+ FilterManager port.FilterManager
63+ IdleInhibitor port.IdleInhibitor
6264
6365 // Accent picker for dead keys support
6466 InsertAccentUC *usecase.InsertAccentUseCase
@@ -88,6 +90,14 @@ type Dependencies struct {
8890 // Update management
8991 CheckUpdateUC *usecase.CheckUpdateUseCase
9092 ApplyUpdateUC *usecase.ApplyUpdateUseCase
93+
94+ // Config migration checker (optional; nil disables migration notifications)
95+ ConfigMigrator port.ConfigMigrator
96+
97+ // LaunchExternalURL opens a URI with the system's default handler (e.g. xdg-open).
98+ // Used for external URL schemes (vscode://, spotify://, etc.).
99+ // Optional: if nil, external URLs are silently dropped.
100+ LaunchExternalURL func(uri string)
91101 }
92102
93103 // Validate checks that all required dependencies are set.
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/domain/repository"10 "github.com/bnema/dumber/internal/domain/repository"
11 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/ui/adapter"
12 "github.com/bnema/dumber/internal/ui/theme"13 "github.com/bnema/dumber/internal/ui/theme"
13 "github.com/jwijenbergh/puregotk/v4/gtk"14 "github.com/jwijenbergh/puregotk/v4/gtk"
14 )15 )
@@ -55,10 +56,11 @@ type Dependencies struct {
55 CopyURLUC *usecase.CopyURLUseCase56 CopyURLUC *usecase.CopyURLUseCase
56 57
57 // Infrastructure Adapters58 // Infrastructure Adapters
58- Clipboard port.Clipboard59+ Clipboard port.Clipboard
59- FaviconService port.FaviconService60+ FaviconService port.FaviconService
60- FilterManager port.FilterManager61+ FaviconAdapterConfig adapter.FaviconAdapterConfig
61- IdleInhibitor port.IdleInhibitor62+ FilterManager port.FilterManager
63+ IdleInhibitor port.IdleInhibitor
62 64
63 // Accent picker for dead keys support65 // Accent picker for dead keys support
64 InsertAccentUC *usecase.InsertAccentUseCase66 InsertAccentUC *usecase.InsertAccentUseCase
@@ -88,6 +90,14 @@ type Dependencies struct {
88 // Update management90 // Update management
89 CheckUpdateUC *usecase.CheckUpdateUseCase91 CheckUpdateUC *usecase.CheckUpdateUseCase
90 ApplyUpdateUC *usecase.ApplyUpdateUseCase92 ApplyUpdateUC *usecase.ApplyUpdateUseCase
93+
94+ // Config migration checker (optional; nil disables migration notifications)
95+ ConfigMigrator port.ConfigMigrator
96+
97+ // LaunchExternalURL opens a URI with the system's default handler (e.g. xdg-open).
98+ // Used for external URL schemes (vscode://, spotify://, etc.).
99+ // Optional: if nil, external URLs are silently dropped.
100+ LaunchExternalURL func(uri string)
91 }101 }
92 102
93 // Validate checks that all required dependencies are set.103 // Validate checks that all required dependencies are set.
M internal/ui/dispatcher/keyboard.go +9 -8
@@ -7,7 +7,6 @@ import (
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
99 domainurl "github.com/bnema/dumber/internal/domain/url"
10- "github.com/bnema/dumber/internal/infrastructure/config"
1110 "github.com/bnema/dumber/internal/logging"
1211 "github.com/bnema/dumber/internal/ui/component"
1312 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -22,6 +21,7 @@ type KeyboardDispatcher struct {
2221 navCoord *coordinator.NavigationCoordinator
2322 zoomUC *usecase.ManageZoomUseCase
2423 copyURLUC *usecase.CopyURLUseCase
24+ newPaneURL string
2525 actionHandlers map[input.Action]func(ctx context.Context) error
2626 onQuit func()
2727 onFindOpen func(ctx context.Context) error
@@ -43,16 +43,18 @@ func NewKeyboardDispatcher(
4343 navCoord *coordinator.NavigationCoordinator,
4444 zoomUC *usecase.ManageZoomUseCase,
4545 copyURLUC *usecase.CopyURLUseCase,
46+ newPaneURL string,
4647 ) *KeyboardDispatcher {
4748 log := logging.FromContext(ctx)
4849 log.Debug().Msg("creating keyboard dispatcher")
4950
5051 dispatcher := &KeyboardDispatcher{
51- tabCoord: tabCoord,
52- wsCoord: wsCoord,
53- navCoord: navCoord,
54- zoomUC: zoomUC,
55- copyURLUC: copyURLUC,
52+ tabCoord: tabCoord,
53+ wsCoord: wsCoord,
54+ navCoord: navCoord,
55+ zoomUC: zoomUC,
56+ copyURLUC: copyURLUC,
57+ newPaneURL: newPaneURL,
5658 }
5759 dispatcher.initActionHandlers()
5860 return dispatcher
@@ -130,8 +132,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
130132 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
131133 // Tab actions
132134 input.ActionNewTab: func(ctx context.Context) error {
133- cfg := config.Get()
134- _, err := d.tabCoord.Create(ctx, domainurl.Normalize(cfg.Workspace.NewPaneURL))
135+ _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL))
135136 return err
136137 },
137138 input.ActionCloseTab: d.tabCoord.Close,
@@ -7,7 +7,6 @@ import (
7 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
10- "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
13 "github.com/bnema/dumber/internal/ui/coordinator"12 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -22,6 +21,7 @@ type KeyboardDispatcher struct {
22 navCoord *coordinator.NavigationCoordinator21 navCoord *coordinator.NavigationCoordinator
23 zoomUC *usecase.ManageZoomUseCase22 zoomUC *usecase.ManageZoomUseCase
24 copyURLUC *usecase.CopyURLUseCase23 copyURLUC *usecase.CopyURLUseCase
24+ newPaneURL string
25 actionHandlers map[input.Action]func(ctx context.Context) error25 actionHandlers map[input.Action]func(ctx context.Context) error
26 onQuit func()26 onQuit func()
27 onFindOpen func(ctx context.Context) error27 onFindOpen func(ctx context.Context) error
@@ -43,16 +43,18 @@ func NewKeyboardDispatcher(
43 navCoord *coordinator.NavigationCoordinator,43 navCoord *coordinator.NavigationCoordinator,
44 zoomUC *usecase.ManageZoomUseCase,44 zoomUC *usecase.ManageZoomUseCase,
45 copyURLUC *usecase.CopyURLUseCase,45 copyURLUC *usecase.CopyURLUseCase,
46+ newPaneURL string,
46 ) *KeyboardDispatcher {47 ) *KeyboardDispatcher {
47 log := logging.FromContext(ctx)48 log := logging.FromContext(ctx)
48 log.Debug().Msg("creating keyboard dispatcher")49 log.Debug().Msg("creating keyboard dispatcher")
49 50
50 dispatcher := &KeyboardDispatcher{51 dispatcher := &KeyboardDispatcher{
51- tabCoord: tabCoord,52+ tabCoord: tabCoord,
52- wsCoord: wsCoord,53+ wsCoord: wsCoord,
53- navCoord: navCoord,54+ navCoord: navCoord,
54- zoomUC: zoomUC,55+ zoomUC: zoomUC,
55- copyURLUC: copyURLUC,56+ copyURLUC: copyURLUC,
57+ newPaneURL: newPaneURL,
56 }58 }
57 dispatcher.initActionHandlers()59 dispatcher.initActionHandlers()
58 return dispatcher60 return dispatcher
@@ -130,8 +132,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
130 d.actionHandlers = map[input.Action]func(ctx context.Context) error{132 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
131 // Tab actions133 // Tab actions
132 input.ActionNewTab: func(ctx context.Context) error {134 input.ActionNewTab: func(ctx context.Context) error {
133- cfg := config.Get()135+ _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL))
134- _, err := d.tabCoord.Create(ctx, domainurl.Normalize(cfg.Workspace.NewPaneURL))
135 return err136 return err
136 },137 },
137 input.ActionCloseTab: d.tabCoord.Close,138 input.ActionCloseTab: d.tabCoord.Close,
M internal/ui/input/floating_shortcuts_test.go +35 -21
@@ -4,15 +4,29 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/domain/entity"
88 "github.com/jwijenbergh/puregotk/v4/gdk"
99 "github.com/stretchr/testify/assert"
1010 "github.com/stretchr/testify/require"
1111 )
1212
13+// defaultWorkspaceConfig returns a WorkspaceConfig with the default shortcuts,
14+// mirroring the relevant portion of config.DefaultConfig().
15+func defaultWorkspaceConfig() *entity.WorkspaceConfig {
16+ return &entity.WorkspaceConfig{
17+ Shortcuts: entity.GlobalShortcutsConfig{
18+ Actions: map[string]entity.ActionBinding{
19+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
20+ "consume_or_expel_left": {Keys: []string{"alt+["}},
21+ "consume_or_expel_right": {Keys: []string{"alt+]"}},
22+ },
23+ },
24+ }
25+}
26+
1327 func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
14- cfg := config.DefaultConfig()
15- set := NewShortcutSet(context.Background(), cfg)
28+ ws := defaultWorkspaceConfig()
29+ set := NewShortcutSet(context.Background(), ws, nil)
1630
1731 action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)
1832 require.True(t, ok)
@@ -20,8 +34,8 @@ func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
2034 }
2135
2236 func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
23- cfg := config.DefaultConfig()
24- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
37+ ws := defaultWorkspaceConfig()
38+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
2539 "google": {
2640 Keys: []string{"alt+g"},
2741 URL: "https://google.com",
@@ -32,7 +46,7 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
3246 },
3347 }
3448
35- set := NewShortcutSet(context.Background(), cfg)
49+ set := NewShortcutSet(context.Background(), ws, nil)
3650
3751 googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
3852 require.True(t, ok)
@@ -50,8 +64,8 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
5064 }
5165
5266 func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
53- cfg := config.DefaultConfig()
54- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
67+ ws := defaultWorkspaceConfig()
68+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
5569 "tab-conflict": {
5670 Keys: []string{"alt+1"},
5771 URL: "https://example.com/tab",
@@ -71,7 +85,7 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
7185 {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,
7286 }
7387
74- shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied)
88+ shortcuts := collectFloatingProfileShortcutsFromWorkspace(context.Background(), ws, occupied)
7589 require.Len(t, shortcuts, 1)
7690 assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)
7791 target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)
@@ -81,15 +95,15 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
8195 }
8296
8397 func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {
84- cfg := config.DefaultConfig()
85- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
98+ ws := defaultWorkspaceConfig()
99+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
86100 "conflict": {
87101 Keys: []string{"alt+["},
88102 URL: "https://example.com/override-attempt",
89103 },
90104 }
91105
92- set := NewShortcutSet(context.Background(), cfg)
106+ set := NewShortcutSet(context.Background(), ws, nil)
93107
94108 tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)
95109 require.True(t, ok)
@@ -101,8 +115,8 @@ func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.
101115 }
102116
103117 func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
104- cfg := config.DefaultConfig()
105- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
118+ ws := defaultWorkspaceConfig()
119+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
106120 "alt-one-conflict": {
107121 Keys: []string{"alt+1"},
108122 URL: "https://example.com/one",
@@ -117,7 +131,7 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
117131 },
118132 }
119133
120- set := NewShortcutSet(context.Background(), cfg)
134+ set := NewShortcutSet(context.Background(), ws, nil)
121135
122136 _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)
123137 assert.False(t, ok)
@@ -134,8 +148,8 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
134148 }
135149
136150 func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {
137- cfg := config.DefaultConfig()
138- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
151+ ws := defaultWorkspaceConfig()
152+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
139153 "work-mail": {
140154 Keys: []string{"alt+w"},
141155 URL: "https://mail.google.com",
@@ -146,7 +160,7 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing
146160 },
147161 }
148162
149- set := NewShortcutSet(context.Background(), cfg)
163+ set := NewShortcutSet(context.Background(), ws, nil)
150164
151165 workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)
152166 require.True(t, ok)
@@ -164,8 +178,8 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing
164178 }
165179
166180 func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
167- cfg := config.DefaultConfig()
168- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
181+ ws := defaultWorkspaceConfig()
182+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
169183 "mail": {
170184 Keys: []string{"ctrl+alt+m"},
171185 URL: "https://mail.example.com",
@@ -176,7 +190,7 @@ func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
176190 },
177191 }
178192
179- set := NewShortcutSet(context.Background(), cfg)
193+ set := NewShortcutSet(context.Background(), ws, nil)
180194
181195 mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal)
182196 require.True(t, ok)
@@ -4,15 +4,29 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/dumber/internal/infrastructure/config"7+ "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/jwijenbergh/puregotk/v4/gdk"8 "github.com/jwijenbergh/puregotk/v4/gdk"
9 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
13+// defaultWorkspaceConfig returns a WorkspaceConfig with the default shortcuts,
14+// mirroring the relevant portion of config.DefaultConfig().
15+func defaultWorkspaceConfig() *entity.WorkspaceConfig {
16+ return &entity.WorkspaceConfig{
17+ Shortcuts: entity.GlobalShortcutsConfig{
18+ Actions: map[string]entity.ActionBinding{
19+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
20+ "consume_or_expel_left": {Keys: []string{"alt+["}},
21+ "consume_or_expel_right": {Keys: []string{"alt+]"}},
22+ },
23+ },
24+ }
25+}
26+
13 func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {27 func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
14- cfg := config.DefaultConfig()28+ ws := defaultWorkspaceConfig()
15- set := NewShortcutSet(context.Background(), cfg)29+ set := NewShortcutSet(context.Background(), ws, nil)
16 30
17 action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)31 action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)
18 require.True(t, ok)32 require.True(t, ok)
@@ -20,8 +34,8 @@ func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
20 }34 }
21 35
22 func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {36 func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
23- cfg := config.DefaultConfig()37+ ws := defaultWorkspaceConfig()
24- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{38+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
25 "google": {39 "google": {
26 Keys: []string{"alt+g"},40 Keys: []string{"alt+g"},
27 URL: "https://google.com",41 URL: "https://google.com",
@@ -32,7 +46,7 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
32 },46 },
33 }47 }
34 48
35- set := NewShortcutSet(context.Background(), cfg)49+ set := NewShortcutSet(context.Background(), ws, nil)
36 50
37 googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)51 googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
38 require.True(t, ok)52 require.True(t, ok)
@@ -50,8 +64,8 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
50 }64 }
51 65
52 func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {66 func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
53- cfg := config.DefaultConfig()67+ ws := defaultWorkspaceConfig()
54- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{68+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
55 "tab-conflict": {69 "tab-conflict": {
56 Keys: []string{"alt+1"},70 Keys: []string{"alt+1"},
57 URL: "https://example.com/tab",71 URL: "https://example.com/tab",
@@ -71,7 +85,7 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
71 {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,85 {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,
72 }86 }
73 87
74- shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied)88+ shortcuts := collectFloatingProfileShortcutsFromWorkspace(context.Background(), ws, occupied)
75 require.Len(t, shortcuts, 1)89 require.Len(t, shortcuts, 1)
76 assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)90 assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)
77 target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)91 target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)
@@ -81,15 +95,15 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
81 }95 }
82 96
83 func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {97 func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {
84- cfg := config.DefaultConfig()98+ ws := defaultWorkspaceConfig()
85- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{99+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
86 "conflict": {100 "conflict": {
87 Keys: []string{"alt+["},101 Keys: []string{"alt+["},
88 URL: "https://example.com/override-attempt",102 URL: "https://example.com/override-attempt",
89 },103 },
90 }104 }
91 105
92- set := NewShortcutSet(context.Background(), cfg)106+ set := NewShortcutSet(context.Background(), ws, nil)
93 107
94 tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)108 tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)
95 require.True(t, ok)109 require.True(t, ok)
@@ -101,8 +115,8 @@ func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.
101 }115 }
102 116
103 func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {117 func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
104- cfg := config.DefaultConfig()118+ ws := defaultWorkspaceConfig()
105- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{119+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
106 "alt-one-conflict": {120 "alt-one-conflict": {
107 Keys: []string{"alt+1"},121 Keys: []string{"alt+1"},
108 URL: "https://example.com/one",122 URL: "https://example.com/one",
@@ -117,7 +131,7 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
117 },131 },
118 }132 }
119 133
120- set := NewShortcutSet(context.Background(), cfg)134+ set := NewShortcutSet(context.Background(), ws, nil)
121 135
122 _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)136 _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)
123 assert.False(t, ok)137 assert.False(t, ok)
@@ -134,8 +148,8 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
134 }148 }
135 149
136 func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {150 func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {
137- cfg := config.DefaultConfig()151+ ws := defaultWorkspaceConfig()
138- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{152+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
139 "work-mail": {153 "work-mail": {
140 Keys: []string{"alt+w"},154 Keys: []string{"alt+w"},
141 URL: "https://mail.google.com",155 URL: "https://mail.google.com",
@@ -146,7 +160,7 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing
146 },160 },
147 }161 }
148 162
149- set := NewShortcutSet(context.Background(), cfg)163+ set := NewShortcutSet(context.Background(), ws, nil)
150 164
151 workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)165 workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)
152 require.True(t, ok)166 require.True(t, ok)
@@ -164,8 +178,8 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing
164 }178 }
165 179
166 func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {180 func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
167- cfg := config.DefaultConfig()181+ ws := defaultWorkspaceConfig()
168- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{182+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
169 "mail": {183 "mail": {
170 Keys: []string{"ctrl+alt+m"},184 Keys: []string{"ctrl+alt+m"},
171 URL: "https://mail.example.com",185 URL: "https://mail.example.com",
@@ -176,7 +190,7 @@ func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
176 },190 },
177 }191 }
178 192
179- set := NewShortcutSet(context.Background(), cfg)193+ set := NewShortcutSet(context.Background(), ws, nil)
180 194
181 mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal)195 mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal)
182 require.True(t, ok)196 require.True(t, ok)
M internal/ui/input/global_shortcuts.go +12 -11
@@ -6,7 +6,7 @@ import (
66 "fmt"
77 "strings"
88
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/jwijenbergh/puregotk/v4/gdk"
1212 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -33,7 +33,8 @@ type GlobalShortcutHandler struct {
3333 func NewGlobalShortcutHandler(
3434 ctx context.Context,
3535 window *gtk.ApplicationWindow,
36- cfg *config.Config,
36+ workspace *entity.WorkspaceConfig,
37+ session *entity.SessionConfig,
3738 kbHandler *KeyboardHandler,
3839 onAction ActionHandler,
3940 ) *GlobalShortcutHandler {
@@ -102,7 +103,7 @@ func NewGlobalShortcutHandler(
102103 Str("action", string(ActionOpenSessionManager)).
103104 Msg("registered global shortcut")
104105
105- if cfg != nil {
106+ if workspace != nil {
106107 // Map action names to action constants for global shortcuts
107108 actionMap := map[string]Action{
108109 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -113,7 +114,7 @@ func NewGlobalShortcutHandler(
113114 "consume_or_expel_down": ActionConsumeOrExpelDown,
114115 }
115116
116- for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
117+ for actionName, actionBinding := range workspace.Shortcuts.Actions {
117118 action, ok := actionMap[actionName]
118119 if !ok {
119120 continue
@@ -134,7 +135,7 @@ func NewGlobalShortcutHandler(
134135 for binding, action := range h.registered {
135136 occupied[binding] = action
136137 }
137- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
138+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
138139 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
139140 continue
140141 }
@@ -148,7 +149,7 @@ func NewGlobalShortcutHandler(
148149
149150 // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)
150151 // so they work even when WebView has focus.
151- shortcuts := NewShortcutSet(ctx, cfg)
152+ shortcuts := NewShortcutSet(ctx, workspace, session)
152153 for binding, action := range shortcuts.Global {
153154 if _, exists := h.registered[binding]; exists {
154155 continue
@@ -249,7 +250,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
249250 // ReloadShortcuts rebuilds global shortcuts from a new config.
250251 // It replaces the GTK shortcut controller to ensure stale shortcuts
251252 // are removed.
252-func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {
253+func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
253254 log := logging.FromContext(ctx)
254255
255256 if h.window == nil || h.controller == nil {
@@ -283,7 +284,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
283284 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
284285
285286 // Re-register config-driven shortcuts
286- if cfg != nil {
287+ if workspace != nil {
287288 actionMap := map[string]Action{
288289 "toggle_floating_pane": ActionToggleFloatingPane,
289290 "toggle-floating-pane": ActionToggleFloatingPane,
@@ -293,7 +294,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
293294 "consume_or_expel_down": ActionConsumeOrExpelDown,
294295 }
295296
296- for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
297+ for actionName, actionBinding := range workspace.Shortcuts.Actions {
297298 action, ok := actionMap[actionName]
298299 if !ok {
299300 continue
@@ -311,7 +312,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
311312 for binding, action := range h.registered {
312313 occupied[binding] = action
313314 }
314- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
315+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
315316 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
316317 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
317318 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
@@ -319,7 +320,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
319320 }
320321 }
321322
322- shortcuts := NewShortcutSet(ctx, cfg)
323+ shortcuts := NewShortcutSet(ctx, workspace, session)
323324 for binding, action := range shortcuts.Global {
324325 if _, exists := h.registered[binding]; exists {
325326 continue
@@ -6,7 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8 8
9- "github.com/bnema/dumber/internal/infrastructure/config"9+ "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/jwijenbergh/puregotk/v4/gdk"11 "github.com/jwijenbergh/puregotk/v4/gdk"
12 "github.com/jwijenbergh/puregotk/v4/glib"12 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -33,7 +33,8 @@ type GlobalShortcutHandler struct {
33 func NewGlobalShortcutHandler(33 func NewGlobalShortcutHandler(
34 ctx context.Context,34 ctx context.Context,
35 window *gtk.ApplicationWindow,35 window *gtk.ApplicationWindow,
36- cfg *config.Config,36+ workspace *entity.WorkspaceConfig,
37+ session *entity.SessionConfig,
37 kbHandler *KeyboardHandler,38 kbHandler *KeyboardHandler,
38 onAction ActionHandler,39 onAction ActionHandler,
39 ) *GlobalShortcutHandler {40 ) *GlobalShortcutHandler {
@@ -102,7 +103,7 @@ func NewGlobalShortcutHandler(
102 Str("action", string(ActionOpenSessionManager)).103 Str("action", string(ActionOpenSessionManager)).
103 Msg("registered global shortcut")104 Msg("registered global shortcut")
104 105
105- if cfg != nil {106+ if workspace != nil {
106 // Map action names to action constants for global shortcuts107 // Map action names to action constants for global shortcuts
107 actionMap := map[string]Action{108 actionMap := map[string]Action{
108 "toggle_floating_pane": ActionToggleFloatingPane,109 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -113,7 +114,7 @@ func NewGlobalShortcutHandler(
113 "consume_or_expel_down": ActionConsumeOrExpelDown,114 "consume_or_expel_down": ActionConsumeOrExpelDown,
114 }115 }
115 116
116- for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {117+ for actionName, actionBinding := range workspace.Shortcuts.Actions {
117 action, ok := actionMap[actionName]118 action, ok := actionMap[actionName]
118 if !ok {119 if !ok {
119 continue120 continue
@@ -134,7 +135,7 @@ func NewGlobalShortcutHandler(
134 for binding, action := range h.registered {135 for binding, action := range h.registered {
135 occupied[binding] = action136 occupied[binding] = action
136 }137 }
137- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {138+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
138 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {139 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
139 continue140 continue
140 }141 }
@@ -148,7 +149,7 @@ func NewGlobalShortcutHandler(
148 149
149 // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)150 // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)
150 // so they work even when WebView has focus.151 // so they work even when WebView has focus.
151- shortcuts := NewShortcutSet(ctx, cfg)152+ shortcuts := NewShortcutSet(ctx, workspace, session)
152 for binding, action := range shortcuts.Global {153 for binding, action := range shortcuts.Global {
153 if _, exists := h.registered[binding]; exists {154 if _, exists := h.registered[binding]; exists {
154 continue155 continue
@@ -249,7 +250,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
249 // ReloadShortcuts rebuilds global shortcuts from a new config.250 // ReloadShortcuts rebuilds global shortcuts from a new config.
250 // It replaces the GTK shortcut controller to ensure stale shortcuts251 // It replaces the GTK shortcut controller to ensure stale shortcuts
251 // are removed.252 // are removed.
252-func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {253+func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
253 log := logging.FromContext(ctx)254 log := logging.FromContext(ctx)
254 255
255 if h.window == nil || h.controller == nil {256 if h.window == nil || h.controller == nil {
@@ -283,7 +284,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
283 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)284 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
284 285
285 // Re-register config-driven shortcuts286 // Re-register config-driven shortcuts
286- if cfg != nil {287+ if workspace != nil {
287 actionMap := map[string]Action{288 actionMap := map[string]Action{
288 "toggle_floating_pane": ActionToggleFloatingPane,289 "toggle_floating_pane": ActionToggleFloatingPane,
289 "toggle-floating-pane": ActionToggleFloatingPane,290 "toggle-floating-pane": ActionToggleFloatingPane,
@@ -293,7 +294,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
293 "consume_or_expel_down": ActionConsumeOrExpelDown,294 "consume_or_expel_down": ActionConsumeOrExpelDown,
294 }295 }
295 296
296- for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {297+ for actionName, actionBinding := range workspace.Shortcuts.Actions {
297 action, ok := actionMap[actionName]298 action, ok := actionMap[actionName]
298 if !ok {299 if !ok {
299 continue300 continue
@@ -311,7 +312,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
311 for binding, action := range h.registered {312 for binding, action := range h.registered {
312 occupied[binding] = action313 occupied[binding] = action
313 }314 }
314- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {315+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
315 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {316 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
316 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {317 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
317 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")318 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
@@ -319,7 +320,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
319 }320 }
320 }321 }
321 322
322- shortcuts := NewShortcutSet(ctx, cfg)323+ shortcuts := NewShortcutSet(ctx, workspace, session)
323 for binding, action := range shortcuts.Global {324 for binding, action := range shortcuts.Global {
324 if _, exists := h.registered[binding]; exists {325 if _, exists := h.registered[binding]; exists {
325 continue326 continue
M internal/ui/input/keyboard.go +52 -27
@@ -23,7 +23,7 @@ import (
2323 "sync"
2424 "time"
2525
26- "github.com/bnema/dumber/internal/infrastructure/config"
26+ "github.com/bnema/dumber/internal/domain/entity"
2727 "github.com/bnema/dumber/internal/logging"
2828 "github.com/jwijenbergh/puregotk/v4/gdk"
2929 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -55,7 +55,8 @@ type AccentHandler interface {
5555 type KeyboardHandler struct {
5656 shortcuts *ShortcutSet
5757 modal *ModalState
58- cfg *config.Config
58+ workspace *entity.WorkspaceConfig
59+ session *entity.SessionConfig
5960
6061 // Action handler callback
6162 onAction ActionHandler
@@ -80,15 +81,16 @@ type KeyboardHandler struct {
8081 }
8182
8283 // NewKeyboardHandler creates a new keyboard handler.
83-func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
84+func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
8485 log := logging.FromContext(ctx)
8586
8687 log.Debug().Msg("creating keyboard handler")
8788
8889 h := &KeyboardHandler{
89- shortcuts: NewShortcutSet(ctx, cfg),
90+ shortcuts: NewShortcutSet(ctx, workspace, session),
9091 modal: NewModalState(ctx),
91- cfg: cfg,
92+ workspace: workspace,
93+ session: session,
9294 ctx: ctx,
9395 }
9496
@@ -102,17 +104,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
102104 h.onAction = fn
103105 }
104106
105-// ReloadShortcuts rebuilds the shortcut set from the new config.
107+// ReloadShortcuts rebuilds the shortcut set from new config values.
106108 // This enables hot-reloading of keybindings without restarting.
107-func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {
109+func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
108110 h.mu.Lock()
109111 defer h.mu.Unlock()
110112
111113 log := logging.FromContext(ctx)
112114 log.Debug().Msg("reloading shortcuts from config")
113115
114- h.shortcuts = NewShortcutSet(ctx, cfg)
115- h.cfg = cfg
116+ h.shortcuts = NewShortcutSet(ctx, workspace, session)
117+ h.workspace = workspace
118+ h.session = session
116119 }
117120
118121 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -433,29 +436,42 @@ func isResizeAction(action Action) bool {
433436
434437 func (h *KeyboardHandler) handleModeAction(action Action) bool {
435438 h.mu.RLock()
436- cfg := h.cfg
439+ workspace := h.workspace
440+ session := h.session
437441 h.mu.RUnlock()
438442
439443 switch action {
440444 case ActionEnterTabMode:
441- timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
442- h.modal.EnterTabMode(h.ctx, timeout)
445+ var ms int
446+ if workspace != nil {
447+ ms = workspace.TabMode.TimeoutMilliseconds
448+ }
449+ h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
443450 return true
444451 case ActionEnterPaneMode:
445- timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
446- h.modal.EnterPaneMode(h.ctx, timeout)
452+ var ms int
453+ if workspace != nil {
454+ ms = workspace.PaneMode.TimeoutMilliseconds
455+ }
456+ h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
447457 return true
448458 case ActionEnterSessionMode:
449- timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
450- h.modal.EnterSessionMode(h.ctx, timeout)
459+ var ms int
460+ if session != nil {
461+ ms = session.SessionMode.TimeoutMilliseconds
462+ }
463+ h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
451464 return true
452465 case ActionEnterResizeMode:
453466 if h.modal.Mode() == ModeResize {
454467 h.modal.ExitMode(h.ctx)
455468 return true
456469 }
457- timeout := time.Duration(cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
458- h.modal.EnterResizeMode(h.ctx, timeout)
470+ var ms int
471+ if workspace != nil {
472+ ms = workspace.ResizeMode.TimeoutMilliseconds
473+ }
474+ h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
459475 return true
460476 case ActionExitMode:
461477 h.modal.ExitMode(h.ctx)
@@ -469,30 +485,39 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
469485 // Useful for testing or programmatic mode changes.
470486 func (h *KeyboardHandler) EnterTabMode() {
471487 h.mu.RLock()
472- cfg := h.cfg
488+ workspace := h.workspace
473489 h.mu.RUnlock()
474- timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
475- h.modal.EnterTabMode(h.ctx, timeout)
490+ var ms int
491+ if workspace != nil {
492+ ms = workspace.TabMode.TimeoutMilliseconds
493+ }
494+ h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
476495 }
477496
478497 // EnterPaneMode programmatically enters pane mode.
479498 // Useful for testing or programmatic mode changes.
480499 func (h *KeyboardHandler) EnterPaneMode() {
481500 h.mu.RLock()
482- cfg := h.cfg
501+ workspace := h.workspace
483502 h.mu.RUnlock()
484- timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
485- h.modal.EnterPaneMode(h.ctx, timeout)
503+ var ms int
504+ if workspace != nil {
505+ ms = workspace.PaneMode.TimeoutMilliseconds
506+ }
507+ h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
486508 }
487509
488510 // EnterSessionMode programmatically enters session mode.
489511 // Useful for testing or programmatic mode changes.
490512 func (h *KeyboardHandler) EnterSessionMode() {
491513 h.mu.RLock()
492- cfg := h.cfg
514+ session := h.session
493515 h.mu.RUnlock()
494- timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
495- h.modal.EnterSessionMode(h.ctx, timeout)
516+ var ms int
517+ if session != nil {
518+ ms = session.SessionMode.TimeoutMilliseconds
519+ }
520+ h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
496521 }
497522
498523 // ExitMode programmatically exits modal mode.
@@ -23,7 +23,7 @@ import (
23 "sync"23 "sync"
24 "time"24 "time"
25 25
26- "github.com/bnema/dumber/internal/infrastructure/config"26+ "github.com/bnema/dumber/internal/domain/entity"
27 "github.com/bnema/dumber/internal/logging"27 "github.com/bnema/dumber/internal/logging"
28 "github.com/jwijenbergh/puregotk/v4/gdk"28 "github.com/jwijenbergh/puregotk/v4/gdk"
29 "github.com/jwijenbergh/puregotk/v4/glib"29 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -55,7 +55,8 @@ type AccentHandler interface {
55 type KeyboardHandler struct {55 type KeyboardHandler struct {
56 shortcuts *ShortcutSet56 shortcuts *ShortcutSet
57 modal *ModalState57 modal *ModalState
58- cfg *config.Config58+ workspace *entity.WorkspaceConfig
59+ session *entity.SessionConfig
59 60
60 // Action handler callback61 // Action handler callback
61 onAction ActionHandler62 onAction ActionHandler
@@ -80,15 +81,16 @@ type KeyboardHandler struct {
80 }81 }
81 82
82 // NewKeyboardHandler creates a new keyboard handler.83 // NewKeyboardHandler creates a new keyboard handler.
83-func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {84+func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
84 log := logging.FromContext(ctx)85 log := logging.FromContext(ctx)
85 86
86 log.Debug().Msg("creating keyboard handler")87 log.Debug().Msg("creating keyboard handler")
87 88
88 h := &KeyboardHandler{89 h := &KeyboardHandler{
89- shortcuts: NewShortcutSet(ctx, cfg),90+ shortcuts: NewShortcutSet(ctx, workspace, session),
90 modal: NewModalState(ctx),91 modal: NewModalState(ctx),
91- cfg: cfg,92+ workspace: workspace,
93+ session: session,
92 ctx: ctx,94 ctx: ctx,
93 }95 }
94 96
@@ -102,17 +104,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
102 h.onAction = fn104 h.onAction = fn
103 }105 }
104 106
105-// ReloadShortcuts rebuilds the shortcut set from the new config.107+// ReloadShortcuts rebuilds the shortcut set from new config values.
106 // This enables hot-reloading of keybindings without restarting.108 // This enables hot-reloading of keybindings without restarting.
107-func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {109+func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
108 h.mu.Lock()110 h.mu.Lock()
109 defer h.mu.Unlock()111 defer h.mu.Unlock()
110 112
111 log := logging.FromContext(ctx)113 log := logging.FromContext(ctx)
112 log.Debug().Msg("reloading shortcuts from config")114 log.Debug().Msg("reloading shortcuts from config")
113 115
114- h.shortcuts = NewShortcutSet(ctx, cfg)116+ h.shortcuts = NewShortcutSet(ctx, workspace, session)
115- h.cfg = cfg117+ h.workspace = workspace
118+ h.session = session
116 }119 }
117 120
118 // SetOnModeChange sets the callback for mode changes (for UI updates).121 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -433,29 +436,42 @@ func isResizeAction(action Action) bool {
433 436
434 func (h *KeyboardHandler) handleModeAction(action Action) bool {437 func (h *KeyboardHandler) handleModeAction(action Action) bool {
435 h.mu.RLock()438 h.mu.RLock()
436- cfg := h.cfg439+ workspace := h.workspace
440+ session := h.session
437 h.mu.RUnlock()441 h.mu.RUnlock()
438 442
439 switch action {443 switch action {
440 case ActionEnterTabMode:444 case ActionEnterTabMode:
441- timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond445+ var ms int
442- h.modal.EnterTabMode(h.ctx, timeout)446+ if workspace != nil {
447+ ms = workspace.TabMode.TimeoutMilliseconds
448+ }
449+ h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
443 return true450 return true
444 case ActionEnterPaneMode:451 case ActionEnterPaneMode:
445- timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond452+ var ms int
446- h.modal.EnterPaneMode(h.ctx, timeout)453+ if workspace != nil {
454+ ms = workspace.PaneMode.TimeoutMilliseconds
455+ }
456+ h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
447 return true457 return true
448 case ActionEnterSessionMode:458 case ActionEnterSessionMode:
449- timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond459+ var ms int
450- h.modal.EnterSessionMode(h.ctx, timeout)460+ if session != nil {
461+ ms = session.SessionMode.TimeoutMilliseconds
462+ }
463+ h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
451 return true464 return true
452 case ActionEnterResizeMode:465 case ActionEnterResizeMode:
453 if h.modal.Mode() == ModeResize {466 if h.modal.Mode() == ModeResize {
454 h.modal.ExitMode(h.ctx)467 h.modal.ExitMode(h.ctx)
455 return true468 return true
456 }469 }
457- timeout := time.Duration(cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond470+ var ms int
458- h.modal.EnterResizeMode(h.ctx, timeout)471+ if workspace != nil {
472+ ms = workspace.ResizeMode.TimeoutMilliseconds
473+ }
474+ h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
459 return true475 return true
460 case ActionExitMode:476 case ActionExitMode:
461 h.modal.ExitMode(h.ctx)477 h.modal.ExitMode(h.ctx)
@@ -469,30 +485,39 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
469 // Useful for testing or programmatic mode changes.485 // Useful for testing or programmatic mode changes.
470 func (h *KeyboardHandler) EnterTabMode() {486 func (h *KeyboardHandler) EnterTabMode() {
471 h.mu.RLock()487 h.mu.RLock()
472- cfg := h.cfg488+ workspace := h.workspace
473 h.mu.RUnlock()489 h.mu.RUnlock()
474- timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond490+ var ms int
475- h.modal.EnterTabMode(h.ctx, timeout)491+ if workspace != nil {
492+ ms = workspace.TabMode.TimeoutMilliseconds
493+ }
494+ h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
476 }495 }
477 496
478 // EnterPaneMode programmatically enters pane mode.497 // EnterPaneMode programmatically enters pane mode.
479 // Useful for testing or programmatic mode changes.498 // Useful for testing or programmatic mode changes.
480 func (h *KeyboardHandler) EnterPaneMode() {499 func (h *KeyboardHandler) EnterPaneMode() {
481 h.mu.RLock()500 h.mu.RLock()
482- cfg := h.cfg501+ workspace := h.workspace
483 h.mu.RUnlock()502 h.mu.RUnlock()
484- timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond503+ var ms int
485- h.modal.EnterPaneMode(h.ctx, timeout)504+ if workspace != nil {
505+ ms = workspace.PaneMode.TimeoutMilliseconds
506+ }
507+ h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
486 }508 }
487 509
488 // EnterSessionMode programmatically enters session mode.510 // EnterSessionMode programmatically enters session mode.
489 // Useful for testing or programmatic mode changes.511 // Useful for testing or programmatic mode changes.
490 func (h *KeyboardHandler) EnterSessionMode() {512 func (h *KeyboardHandler) EnterSessionMode() {
491 h.mu.RLock()513 h.mu.RLock()
492- cfg := h.cfg514+ session := h.session
493 h.mu.RUnlock()515 h.mu.RUnlock()
494- timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond516+ var ms int
495- h.modal.EnterSessionMode(h.ctx, timeout)517+ if session != nil {
518+ ms = session.SessionMode.TimeoutMilliseconds
519+ }
520+ h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
496 }521 }
497 522
498 // ExitMode programmatically exits modal mode.523 // ExitMode programmatically exits modal mode.
M internal/ui/input/keyboard_test.go +21 -21
@@ -4,20 +4,26 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/domain/entity"
88 "github.com/jwijenbergh/puregotk/v4/gdk"
99 "github.com/stretchr/testify/assert"
1010 )
1111
12-// newTestConfig creates a minimal Config with activation shortcuts set.
12+// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
1313 // Used by tests that construct a KeyboardHandler.
14-func newTestConfig() *config.Config {
15- cfg := &config.Config{}
16- cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t"
17- cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p"
18- cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r"
19- cfg.Session.SessionMode.ActivationShortcut = "ctrl+s"
20- return cfg
14+func newTestWorkspace() *entity.WorkspaceConfig {
15+ return &entity.WorkspaceConfig{
16+ TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
17+ PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
18+ ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
19+ }
20+}
21+
22+// newTestSession creates a minimal SessionConfig with activation shortcuts set.
23+func newTestSession() *entity.SessionConfig {
24+ return &entity.SessionConfig{
25+ SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
26+ }
2127 }
2228
2329 // stubAccentHandler is a test stub for the AccentHandler interface.
@@ -108,9 +114,8 @@ func TestIsTextInputKey(t *testing.T) {
108114
109115 func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
110116 ctx := context.Background()
111- cfg := newTestConfig()
112117
113- h := NewKeyboardHandler(ctx, cfg)
118+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
114119
115120 // Route all non-shortcut keys to widget (simulates WebView text input)
116121 h.SetRouteKey(func(kc KeyContext) KeyRoute {
@@ -139,9 +144,8 @@ func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
139144
140145 func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
141146 ctx := context.Background()
142- cfg := newTestConfig()
143147
144- h := NewKeyboardHandler(ctx, cfg)
148+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
145149
146150 // Set a stub accent handler that always returns false (simulates "no accents for this key")
147151 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
@@ -158,9 +162,8 @@ func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
158162
159163 func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
160164 ctx := context.Background()
161- cfg := newTestConfig()
162165
163- h := NewKeyboardHandler(ctx, cfg)
166+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
164167
165168 h.SetOnAction(func(ctx context.Context, action Action) error {
166169 return nil
@@ -181,9 +184,8 @@ func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
181184
182185 func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
183186 ctx := context.Background()
184- cfg := newTestConfig()
185187
186- h := NewKeyboardHandler(ctx, cfg)
188+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
187189 h.EnterTabMode()
188190
189191 // Even if routeKey says pass to widget, modal mode ignores it
@@ -198,8 +200,7 @@ func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
198200
199201 func TestKeyboardHandler_SetRouteKey(t *testing.T) {
200202 ctx := context.Background()
201- cfg := &config.Config{}
202- h := NewKeyboardHandler(ctx, cfg)
203+ h := NewKeyboardHandler(ctx, nil, nil)
203204
204205 // Default: no callback set
205206 h.mu.RLock()
@@ -221,9 +222,8 @@ func TestKeyboardHandler_SetRouteKey(t *testing.T) {
221222
222223 func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
223224 ctx := context.Background()
224- cfg := newTestConfig()
225225
226- h := NewKeyboardHandler(ctx, cfg)
226+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
227227
228228 // No routeKey set -- should default to shortcut handling
229229 // 'z' is not a registered shortcut in ModeNormal, so returns false
@@ -4,20 +4,26 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/dumber/internal/infrastructure/config"7+ "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/jwijenbergh/puregotk/v4/gdk"8 "github.com/jwijenbergh/puregotk/v4/gdk"
9 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10 )10 )
11 11
12-// newTestConfig creates a minimal Config with activation shortcuts set.12+// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
13 // Used by tests that construct a KeyboardHandler.13 // Used by tests that construct a KeyboardHandler.
14-func newTestConfig() *config.Config {14+func newTestWorkspace() *entity.WorkspaceConfig {
15- cfg := &config.Config{}15+ return &entity.WorkspaceConfig{
16- cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t"16+ TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
17- cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p"17+ PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
18- cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r"18+ ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
19- cfg.Session.SessionMode.ActivationShortcut = "ctrl+s"19+ }
20- return cfg20+}
21+
22+// newTestSession creates a minimal SessionConfig with activation shortcuts set.
23+func newTestSession() *entity.SessionConfig {
24+ return &entity.SessionConfig{
25+ SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
26+ }
21 }27 }
22 28
23 // stubAccentHandler is a test stub for the AccentHandler interface.29 // stubAccentHandler is a test stub for the AccentHandler interface.
@@ -108,9 +114,8 @@ func TestIsTextInputKey(t *testing.T) {
108 114
109 func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {115 func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
110 ctx := context.Background()116 ctx := context.Background()
111- cfg := newTestConfig()
112 117
113- h := NewKeyboardHandler(ctx, cfg)118+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
114 119
115 // Route all non-shortcut keys to widget (simulates WebView text input)120 // Route all non-shortcut keys to widget (simulates WebView text input)
116 h.SetRouteKey(func(kc KeyContext) KeyRoute {121 h.SetRouteKey(func(kc KeyContext) KeyRoute {
@@ -139,9 +144,8 @@ func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
139 144
140 func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {145 func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
141 ctx := context.Background()146 ctx := context.Background()
142- cfg := newTestConfig()
143 147
144- h := NewKeyboardHandler(ctx, cfg)148+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
145 149
146 // Set a stub accent handler that always returns false (simulates "no accents for this key")150 // Set a stub accent handler that always returns false (simulates "no accents for this key")
147 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})151 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
@@ -158,9 +162,8 @@ func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
158 162
159 func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {163 func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
160 ctx := context.Background()164 ctx := context.Background()
161- cfg := newTestConfig()
162 165
163- h := NewKeyboardHandler(ctx, cfg)166+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
164 167
165 h.SetOnAction(func(ctx context.Context, action Action) error {168 h.SetOnAction(func(ctx context.Context, action Action) error {
166 return nil169 return nil
@@ -181,9 +184,8 @@ func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
181 184
182 func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {185 func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
183 ctx := context.Background()186 ctx := context.Background()
184- cfg := newTestConfig()
185 187
186- h := NewKeyboardHandler(ctx, cfg)188+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
187 h.EnterTabMode()189 h.EnterTabMode()
188 190
189 // Even if routeKey says pass to widget, modal mode ignores it191 // Even if routeKey says pass to widget, modal mode ignores it
@@ -198,8 +200,7 @@ func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
198 200
199 func TestKeyboardHandler_SetRouteKey(t *testing.T) {201 func TestKeyboardHandler_SetRouteKey(t *testing.T) {
200 ctx := context.Background()202 ctx := context.Background()
201- cfg := &config.Config{}203+ h := NewKeyboardHandler(ctx, nil, nil)
202- h := NewKeyboardHandler(ctx, cfg)
203 204
204 // Default: no callback set205 // Default: no callback set
205 h.mu.RLock()206 h.mu.RLock()
@@ -221,9 +222,8 @@ func TestKeyboardHandler_SetRouteKey(t *testing.T) {
221 222
222 func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {223 func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
223 ctx := context.Background()224 ctx := context.Background()
224- cfg := newTestConfig()
225 225
226- h := NewKeyboardHandler(ctx, cfg)226+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
227 227
228 // No routeKey set -- should default to shortcut handling228 // No routeKey set -- should default to shortcut handling
229 // 'z' is not a registered shortcut in ModeNormal, so returns false229 // 'z' is not a registered shortcut in ModeNormal, so returns false
M internal/ui/input/shortcuts.go +60 -46
@@ -6,7 +6,7 @@ import (
66 "sort"
77 "strings"
88
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/jwijenbergh/puregotk/v4/gdk"
1212 )
@@ -218,8 +218,9 @@ type ShortcutSet struct {
218218 ResizeMode ShortcutTable
219219 }
220220
221-// NewShortcutSet creates a ShortcutSet from the configuration.
222-func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
221+// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
222+// workspace must not be nil. session may be nil (session mode shortcuts will be empty).
223+func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
223224 log := logging.FromContext(ctx)
224225 set := &ShortcutSet{
225226 Global: make(ShortcutTable),
@@ -229,11 +230,15 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
229230 ResizeMode: make(ShortcutTable),
230231 }
231232
232- set.buildGlobalShortcuts(ctx, cfg)
233- set.buildTabModeShortcuts(ctx, &cfg.Workspace)
234- set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
235- set.buildResizeModeShortcuts(ctx, &cfg.Workspace)
236- set.buildSessionModeShortcuts(ctx, &cfg.Session)
233+ set.buildGlobalShortcutsFromParts(ctx, workspace, session)
234+ if workspace != nil {
235+ set.buildTabModeShortcuts(ctx, workspace)
236+ set.buildPaneModeShortcuts(ctx, workspace)
237+ set.buildResizeModeShortcuts(ctx, workspace)
238+ }
239+ if session != nil {
240+ set.buildSessionModeShortcuts(ctx, session)
241+ }
237242
238243 log.Debug().
239244 Int("global", len(set.Global)).
@@ -246,81 +251,89 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
246251 return set
247252 }
248253
249-// buildGlobalShortcuts populates global shortcuts from config.
250-func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
251- s.registerActivationShortcuts(ctx, cfg)
252- s.registerConfiguredShortcuts(&cfg.Workspace)
254+// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
255+func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
256+ s.registerActivationShortcutsFromParts(ctx, workspace, session)
257+ s.registerConfiguredShortcuts(workspace)
253258 s.registerStandardShortcuts()
254259 s.registerPaneNavigationShortcuts()
255260 s.registerTabSwitchShortcuts()
256- s.registerFloatingProfileShortcuts(ctx, cfg)
261+ s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
257262 }
258263
259264 // buildTabModeShortcuts populates tab mode shortcuts from config.
260-func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
265+func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
261266 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
262267 }
263268
264269 // buildPaneModeShortcuts populates pane mode shortcuts from config.
265-func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
270+func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
266271 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
267272 }
268273
269274 // buildSessionModeShortcuts populates session mode shortcuts from config.
270-func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
275+func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
271276 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
272277 }
273278
274279 // buildResizeModeShortcuts populates resize mode shortcuts from config.
275-func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
280+func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
276281 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
277282 }
278283
279-func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
284+func (s *ShortcutSet) registerActivationShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
280285 log := logging.FromContext(ctx)
281- if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
286+ if workspace == nil {
287+ return
288+ }
289+ if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
282290 s.Global[binding] = ActionEnterTabMode
283291 log.Trace().
284- Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
292+ Str("shortcut", workspace.TabMode.ActivationShortcut).
285293 Uint("keyval", binding.Keyval).
286294 Uint("mod", uint(binding.Modifiers)).
287295 Msg("tab mode activation registered")
288296 } else {
289- log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
297+ log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
290298 }
291- if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
299+ if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {
292300 s.Global[binding] = ActionEnterPaneMode
293301 log.Trace().
294- Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
302+ Str("shortcut", workspace.PaneMode.ActivationShortcut).
295303 Uint("keyval", binding.Keyval).
296304 Uint("mod", uint(binding.Modifiers)).
297305 Msg("pane mode activation registered")
298306 } else {
299- log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
300- }
301- if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
302- s.Global[binding] = ActionEnterSessionMode
303- log.Trace().
304- Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
305- Uint("keyval", binding.Keyval).
306- Uint("mod", uint(binding.Modifiers)).
307- Msg("session mode activation registered")
308- } else {
309- log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
307+ log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
308+ }
309+ if session != nil {
310+ if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
311+ s.Global[binding] = ActionEnterSessionMode
312+ log.Trace().
313+ Str("shortcut", session.SessionMode.ActivationShortcut).
314+ Uint("keyval", binding.Keyval).
315+ Uint("mod", uint(binding.Modifiers)).
316+ Msg("session mode activation registered")
317+ } else {
318+ log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
319+ }
310320 }
311- if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {
321+ if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
312322 s.Global[binding] = ActionEnterResizeMode
313323 log.Trace().
314- Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).
324+ Str("shortcut", workspace.ResizeMode.ActivationShortcut).
315325 Uint("keyval", binding.Keyval).
316326 Uint("mod", uint(binding.Modifiers)).
317327 Msg("resize mode activation registered")
318328 } else {
319- log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
329+ log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
320330 }
321331 }
322332
323-func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
333+func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
334+ if cfg == nil {
335+ return
336+ }
324337 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
325338 // In tab mode, use:
326339 // n = new tab
@@ -357,13 +370,13 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
357370 }
358371 }
359372
360-func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {
373+func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
361374 occupied := make(map[KeyBinding]Action, len(s.Global))
362375 for binding, action := range s.Global {
363376 occupied[binding] = action
364377 }
365378
366- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
379+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
367380 s.Global[shortcut.Binding] = shortcut.Action
368381 }
369382 }
@@ -373,26 +386,27 @@ type floatingProfileShortcut struct {
373386 Action Action
374387 }
375388
376-func collectFloatingProfileShortcuts(
389+// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
390+func collectFloatingProfileShortcutsFromWorkspace(
377391 ctx context.Context,
378- cfg *config.Config,
392+ workspace *entity.WorkspaceConfig,
379393 occupied map[KeyBinding]Action,
380394 ) []floatingProfileShortcut {
381- if cfg == nil {
395+ if workspace == nil {
382396 return nil
383397 }
384398
385399 log := logging.FromContext(ctx)
386400 reserveGlobalOnlyShortcutBindings(occupied)
387401 result := make([]floatingProfileShortcut, 0)
388- profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))
389- for profileName := range cfg.Workspace.FloatingPane.Profiles {
402+ profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
403+ for profileName := range workspace.FloatingPane.Profiles {
390404 profileNames = append(profileNames, profileName)
391405 }
392406 sort.Strings(profileNames)
393407
394408 for _, profileName := range profileNames {
395- profile := cfg.Workspace.FloatingPane.Profiles[profileName]
409+ profile := workspace.FloatingPane.Profiles[profileName]
396410 url := strings.TrimSpace(profile.URL)
397411 if url == "" {
398412 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
@@ -6,7 +6,7 @@ import (
6 "sort"6 "sort"
7 "strings"7 "strings"
8 8
9- "github.com/bnema/dumber/internal/infrastructure/config"9+ "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/jwijenbergh/puregotk/v4/gdk"11 "github.com/jwijenbergh/puregotk/v4/gdk"
12 )12 )
@@ -218,8 +218,9 @@ type ShortcutSet struct {
218 ResizeMode ShortcutTable218 ResizeMode ShortcutTable
219 }219 }
220 220
221-// NewShortcutSet creates a ShortcutSet from the configuration.221+// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
222-func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {222+// workspace must not be nil. session may be nil (session mode shortcuts will be empty).
223+func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
223 log := logging.FromContext(ctx)224 log := logging.FromContext(ctx)
224 set := &ShortcutSet{225 set := &ShortcutSet{
225 Global: make(ShortcutTable),226 Global: make(ShortcutTable),
@@ -229,11 +230,15 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
229 ResizeMode: make(ShortcutTable),230 ResizeMode: make(ShortcutTable),
230 }231 }
231 232
232- set.buildGlobalShortcuts(ctx, cfg)233+ set.buildGlobalShortcutsFromParts(ctx, workspace, session)
233- set.buildTabModeShortcuts(ctx, &cfg.Workspace)234+ if workspace != nil {
234- set.buildPaneModeShortcuts(ctx, &cfg.Workspace)235+ set.buildTabModeShortcuts(ctx, workspace)
235- set.buildResizeModeShortcuts(ctx, &cfg.Workspace)236+ set.buildPaneModeShortcuts(ctx, workspace)
236- set.buildSessionModeShortcuts(ctx, &cfg.Session)237+ set.buildResizeModeShortcuts(ctx, workspace)
238+ }
239+ if session != nil {
240+ set.buildSessionModeShortcuts(ctx, session)
241+ }
237 242
238 log.Debug().243 log.Debug().
239 Int("global", len(set.Global)).244 Int("global", len(set.Global)).
@@ -246,81 +251,89 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
246 return set251 return set
247 }252 }
248 253
249-// buildGlobalShortcuts populates global shortcuts from config.254+// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
250-func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {255+func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
251- s.registerActivationShortcuts(ctx, cfg)256+ s.registerActivationShortcutsFromParts(ctx, workspace, session)
252- s.registerConfiguredShortcuts(&cfg.Workspace)257+ s.registerConfiguredShortcuts(workspace)
253 s.registerStandardShortcuts()258 s.registerStandardShortcuts()
254 s.registerPaneNavigationShortcuts()259 s.registerPaneNavigationShortcuts()
255 s.registerTabSwitchShortcuts()260 s.registerTabSwitchShortcuts()
256- s.registerFloatingProfileShortcuts(ctx, cfg)261+ s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
257 }262 }
258 263
259 // buildTabModeShortcuts populates tab mode shortcuts from config.264 // buildTabModeShortcuts populates tab mode shortcuts from config.
260-func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {265+func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
261 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")266 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
262 }267 }
263 268
264 // buildPaneModeShortcuts populates pane mode shortcuts from config.269 // buildPaneModeShortcuts populates pane mode shortcuts from config.
265-func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {270+func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
266 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")271 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
267 }272 }
268 273
269 // buildSessionModeShortcuts populates session mode shortcuts from config.274 // buildSessionModeShortcuts populates session mode shortcuts from config.
270-func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {275+func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
271 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")276 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
272 }277 }
273 278
274 // buildResizeModeShortcuts populates resize mode shortcuts from config.279 // buildResizeModeShortcuts populates resize mode shortcuts from config.
275-func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {280+func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
276 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")281 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
277 }282 }
278 283
279-func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {284+func (s *ShortcutSet) registerActivationShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
280 log := logging.FromContext(ctx)285 log := logging.FromContext(ctx)
281- if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {286+ if workspace == nil {
287+ return
288+ }
289+ if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
282 s.Global[binding] = ActionEnterTabMode290 s.Global[binding] = ActionEnterTabMode
283 log.Trace().291 log.Trace().
284- Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).292+ Str("shortcut", workspace.TabMode.ActivationShortcut).
285 Uint("keyval", binding.Keyval).293 Uint("keyval", binding.Keyval).
286 Uint("mod", uint(binding.Modifiers)).294 Uint("mod", uint(binding.Modifiers)).
287 Msg("tab mode activation registered")295 Msg("tab mode activation registered")
288 } else {296 } else {
289- log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")297+ log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
290 }298 }
291- if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {299+ if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {
292 s.Global[binding] = ActionEnterPaneMode300 s.Global[binding] = ActionEnterPaneMode
293 log.Trace().301 log.Trace().
294- Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).302+ Str("shortcut", workspace.PaneMode.ActivationShortcut).
295 Uint("keyval", binding.Keyval).303 Uint("keyval", binding.Keyval).
296 Uint("mod", uint(binding.Modifiers)).304 Uint("mod", uint(binding.Modifiers)).
297 Msg("pane mode activation registered")305 Msg("pane mode activation registered")
298 } else {306 } else {
299- log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")307+ log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
300- }308+ }
301- if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {309+ if session != nil {
302- s.Global[binding] = ActionEnterSessionMode310+ if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
303- log.Trace().311+ s.Global[binding] = ActionEnterSessionMode
304- Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).312+ log.Trace().
305- Uint("keyval", binding.Keyval).313+ Str("shortcut", session.SessionMode.ActivationShortcut).
306- Uint("mod", uint(binding.Modifiers)).314+ Uint("keyval", binding.Keyval).
307- Msg("session mode activation registered")315+ Uint("mod", uint(binding.Modifiers)).
308- } else {316+ Msg("session mode activation registered")
309- log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")317+ } else {
318+ log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
319+ }
310 }320 }
311- if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {321+ if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
312 s.Global[binding] = ActionEnterResizeMode322 s.Global[binding] = ActionEnterResizeMode
313 log.Trace().323 log.Trace().
314- Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).324+ Str("shortcut", workspace.ResizeMode.ActivationShortcut).
315 Uint("keyval", binding.Keyval).325 Uint("keyval", binding.Keyval).
316 Uint("mod", uint(binding.Modifiers)).326 Uint("mod", uint(binding.Modifiers)).
317 Msg("resize mode activation registered")327 Msg("resize mode activation registered")
318 } else {328 } else {
319- log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")329+ log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
320 }330 }
321 }331 }
322 332
323-func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {333+func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
334+ if cfg == nil {
335+ return
336+ }
324 // Note: Ctrl+T is NOT registered globally - it enters tab mode.337 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
325 // In tab mode, use:338 // In tab mode, use:
326 // n = new tab339 // n = new tab
@@ -357,13 +370,13 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
357 }370 }
358 }371 }
359 372
360-func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {373+func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
361 occupied := make(map[KeyBinding]Action, len(s.Global))374 occupied := make(map[KeyBinding]Action, len(s.Global))
362 for binding, action := range s.Global {375 for binding, action := range s.Global {
363 occupied[binding] = action376 occupied[binding] = action
364 }377 }
365 378
366- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {379+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
367 s.Global[shortcut.Binding] = shortcut.Action380 s.Global[shortcut.Binding] = shortcut.Action
368 }381 }
369 }382 }
@@ -373,26 +386,27 @@ type floatingProfileShortcut struct {
373 Action Action386 Action Action
374 }387 }
375 388
376-func collectFloatingProfileShortcuts(389+// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
390+func collectFloatingProfileShortcutsFromWorkspace(
377 ctx context.Context,391 ctx context.Context,
378- cfg *config.Config,392+ workspace *entity.WorkspaceConfig,
379 occupied map[KeyBinding]Action,393 occupied map[KeyBinding]Action,
380 ) []floatingProfileShortcut {394 ) []floatingProfileShortcut {
381- if cfg == nil {395+ if workspace == nil {
382 return nil396 return nil
383 }397 }
384 398
385 log := logging.FromContext(ctx)399 log := logging.FromContext(ctx)
386 reserveGlobalOnlyShortcutBindings(occupied)400 reserveGlobalOnlyShortcutBindings(occupied)
387 result := make([]floatingProfileShortcut, 0)401 result := make([]floatingProfileShortcut, 0)
388- profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))402+ profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
389- for profileName := range cfg.Workspace.FloatingPane.Profiles {403+ for profileName := range workspace.FloatingPane.Profiles {
390 profileNames = append(profileNames, profileName)404 profileNames = append(profileNames, profileName)
391 }405 }
392 sort.Strings(profileNames)406 sort.Strings(profileNames)
393 407
394 for _, profileName := range profileNames {408 for _, profileName := range profileNames {
395- profile := cfg.Workspace.FloatingPane.Profiles[profileName]409+ profile := workspace.FloatingPane.Profiles[profileName]
396 url := strings.TrimSpace(profile.URL)410 url := strings.TrimSpace(profile.URL)
397 if url == "" {411 if url == "" {
398 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")412 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
M internal/ui/theme/manager.go +50 -30
@@ -5,7 +5,7 @@ import (
55 "fmt"
66
77 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
1010 "github.com/jwijenbergh/puregotk/v4/gdk"
1111 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -26,13 +26,23 @@ type Manager struct {
2626
2727 // NewManager creates a new theme manager from configuration.
2828 // The ColorSchemeResolver is required for proper color scheme detection.
29-func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
29+//
30+// appearance controls fonts, palette overrides and color scheme.
31+// uiScale is the GTK UI scale factor (0 or negative means use default 1.0).
32+// styling controls mode indicator colors; may be nil to use defaults.
33+func NewManager(
34+ ctx context.Context,
35+ appearance *entity.AppearanceConfig,
36+ uiScale float64,
37+ styling *entity.WorkspaceStylingConfig,
38+ resolver port.ColorSchemeResolver,
39+) *Manager {
3040 log := logging.FromContext(ctx)
3141
3242 // Determine color scheme preference
3343 scheme := "system"
34- if cfg != nil && cfg.Appearance.ColorScheme != "" {
35- scheme = cfg.Appearance.ColorScheme
44+ if appearance != nil && appearance.ColorScheme != "" {
45+ scheme = appearance.ColorScheme
3646 }
3747
3848 // Resolve whether we should use dark mode via resolver
@@ -41,28 +51,27 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche
4151
4252 // Build palettes from config or defaults
4353 var lightPalette, darkPalette Palette
44- if cfg != nil {
45- lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
46- darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
54+ if appearance != nil {
55+ lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
56+ darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
4757 } else {
4858 lightPalette = DefaultLightPalette()
4959 darkPalette = DefaultDarkPalette()
5060 }
5161
5262 // Get UI scale factor (default to 1.0 if not set)
53- uiScale := 1.0
54- if cfg != nil && cfg.DefaultUIScale > 0 {
55- uiScale = cfg.DefaultUIScale
63+ if uiScale <= 0 {
64+ uiScale = 1.0
5665 }
5766
5867 fonts := DefaultFontConfig()
59- if cfg != nil {
60- fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)
61- fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
68+ if appearance != nil {
69+ fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont)
70+ fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont)
6271 }
6372
6473 // Build mode colors from config
65- modeColors := modeColorsFromConfig(cfg)
74+ modeColors := modeColorsFromStyling(styling)
6675
6776 log.Debug().
6877 Str("scheme", scheme).
@@ -84,13 +93,12 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche
8493 }
8594 }
8695
87-// modeColorsFromConfig extracts mode colors from config, using defaults for missing values.
88-func modeColorsFromConfig(cfg *config.Config) ModeColors {
96+// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.
97+func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {
8998 defaults := DefaultModeColors()
90- if cfg == nil {
99+ if styling == nil {
91100 return defaults
92101 }
93- styling := &cfg.Workspace.Styling
94102 return ModeColors{
95103 PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
96104 TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
@@ -198,40 +206,52 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
198206 }
199207 }
200208
201-// UpdateFromConfig updates the theme manager state from a new config.
202-func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {
209+// UpdateFromConfig updates the theme manager state from new config values.
210+//
211+// appearance controls fonts, palette overrides and color scheme.
212+// uiScale is the GTK UI scale factor (0 or negative means keep existing scale).
213+// styling controls mode indicator colors; may be nil to keep existing colors.
214+func (m *Manager) UpdateFromConfig(
215+ ctx context.Context,
216+ appearance *entity.AppearanceConfig,
217+ uiScale float64,
218+ styling *entity.WorkspaceStylingConfig,
219+ display *gdk.Display,
220+) {
203221 log := logging.FromContext(ctx)
204222
205- if cfg == nil {
223+ if appearance == nil {
206224 return
207225 }
208226
209227 // Update scheme and resolve prefersDark via resolver
210228 scheme := "system"
211- if cfg.Appearance.ColorScheme != "" {
212- scheme = cfg.Appearance.ColorScheme
229+ if appearance.ColorScheme != "" {
230+ scheme = appearance.ColorScheme
213231 }
214232 m.scheme = scheme
215233 pref := m.colorResolver.Refresh()
216234 m.prefersDark = pref.PrefersDark
217235
218236 // Update palettes
219- m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
220- m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
237+ m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
238+ m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
221239
222240 // Update UI scale
223- if cfg.DefaultUIScale > 0 {
224- m.uiScale = cfg.DefaultUIScale
241+ if uiScale > 0 {
242+ m.uiScale = uiScale
225243 }
226244
227245 defaults := DefaultFontConfig()
228246 m.fonts = FontConfig{
229- SansFont: Coalesce(cfg.Appearance.SansFont, defaults.SansFont),
230- MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
247+ SansFont: Coalesce(appearance.SansFont, defaults.SansFont),
248+ MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont),
231249 }
232250
233251 // Update mode colors
234- m.modeColors = modeColorsFromConfig(cfg)
252+ if styling != nil {
253+ m.modeColors = modeColorsFromStyling(styling)
254+ }
235255
236256 log.Info().
237257 Str("scheme", m.scheme).
@@ -5,7 +5,7 @@ import (
5 "fmt"5 "fmt"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/infrastructure/config"8+ "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 "github.com/jwijenbergh/puregotk/v4/gdk"10 "github.com/jwijenbergh/puregotk/v4/gdk"
11 "github.com/jwijenbergh/puregotk/v4/gtk"11 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -26,13 +26,23 @@ type Manager struct {
26 26
27 // NewManager creates a new theme manager from configuration.27 // NewManager creates a new theme manager from configuration.
28 // The ColorSchemeResolver is required for proper color scheme detection.28 // The ColorSchemeResolver is required for proper color scheme detection.
29-func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {29+//
30+// appearance controls fonts, palette overrides and color scheme.
31+// uiScale is the GTK UI scale factor (0 or negative means use default 1.0).
32+// styling controls mode indicator colors; may be nil to use defaults.
33+func NewManager(
34+ ctx context.Context,
35+ appearance *entity.AppearanceConfig,
36+ uiScale float64,
37+ styling *entity.WorkspaceStylingConfig,
38+ resolver port.ColorSchemeResolver,
39+) *Manager {
30 log := logging.FromContext(ctx)40 log := logging.FromContext(ctx)
31 41
32 // Determine color scheme preference42 // Determine color scheme preference
33 scheme := "system"43 scheme := "system"
34- if cfg != nil && cfg.Appearance.ColorScheme != "" {44+ if appearance != nil && appearance.ColorScheme != "" {
35- scheme = cfg.Appearance.ColorScheme45+ scheme = appearance.ColorScheme
36 }46 }
37 47
38 // Resolve whether we should use dark mode via resolver48 // Resolve whether we should use dark mode via resolver
@@ -41,28 +51,27 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche
41 51
42 // Build palettes from config or defaults52 // Build palettes from config or defaults
43 var lightPalette, darkPalette Palette53 var lightPalette, darkPalette Palette
44- if cfg != nil {54+ if appearance != nil {
45- lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)55+ lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
46- darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)56+ darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
47 } else {57 } else {
48 lightPalette = DefaultLightPalette()58 lightPalette = DefaultLightPalette()
49 darkPalette = DefaultDarkPalette()59 darkPalette = DefaultDarkPalette()
50 }60 }
51 61
52 // Get UI scale factor (default to 1.0 if not set)62 // Get UI scale factor (default to 1.0 if not set)
53- uiScale := 1.063+ if uiScale <= 0 {
54- if cfg != nil && cfg.DefaultUIScale > 0 {64+ uiScale = 1.0
55- uiScale = cfg.DefaultUIScale
56 }65 }
57 66
58 fonts := DefaultFontConfig()67 fonts := DefaultFontConfig()
59- if cfg != nil {68+ if appearance != nil {
60- fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)69+ fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont)
61- fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)70+ fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont)
62 }71 }
63 72
64 // Build mode colors from config73 // Build mode colors from config
65- modeColors := modeColorsFromConfig(cfg)74+ modeColors := modeColorsFromStyling(styling)
66 75
67 log.Debug().76 log.Debug().
68 Str("scheme", scheme).77 Str("scheme", scheme).
@@ -84,13 +93,12 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche
84 }93 }
85 }94 }
86 95
87-// modeColorsFromConfig extracts mode colors from config, using defaults for missing values.96+// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.
88-func modeColorsFromConfig(cfg *config.Config) ModeColors {97+func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {
89 defaults := DefaultModeColors()98 defaults := DefaultModeColors()
90- if cfg == nil {99+ if styling == nil {
91 return defaults100 return defaults
92 }101 }
93- styling := &cfg.Workspace.Styling
94 return ModeColors{102 return ModeColors{
95 PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),103 PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
96 TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),104 TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
@@ -198,40 +206,52 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
198 }206 }
199 }207 }
200 208
201-// UpdateFromConfig updates the theme manager state from a new config.209+// UpdateFromConfig updates the theme manager state from new config values.
202-func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {210+//
211+// appearance controls fonts, palette overrides and color scheme.
212+// uiScale is the GTK UI scale factor (0 or negative means keep existing scale).
213+// styling controls mode indicator colors; may be nil to keep existing colors.
214+func (m *Manager) UpdateFromConfig(
215+ ctx context.Context,
216+ appearance *entity.AppearanceConfig,
217+ uiScale float64,
218+ styling *entity.WorkspaceStylingConfig,
219+ display *gdk.Display,
220+) {
203 log := logging.FromContext(ctx)221 log := logging.FromContext(ctx)
204 222
205- if cfg == nil {223+ if appearance == nil {
206 return224 return
207 }225 }
208 226
209 // Update scheme and resolve prefersDark via resolver227 // Update scheme and resolve prefersDark via resolver
210 scheme := "system"228 scheme := "system"
211- if cfg.Appearance.ColorScheme != "" {229+ if appearance.ColorScheme != "" {
212- scheme = cfg.Appearance.ColorScheme230+ scheme = appearance.ColorScheme
213 }231 }
214 m.scheme = scheme232 m.scheme = scheme
215 pref := m.colorResolver.Refresh()233 pref := m.colorResolver.Refresh()
216 m.prefersDark = pref.PrefersDark234 m.prefersDark = pref.PrefersDark
217 235
218 // Update palettes236 // Update palettes
219- m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)237+ m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
220- m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)238+ m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
221 239
222 // Update UI scale240 // Update UI scale
223- if cfg.DefaultUIScale > 0 {241+ if uiScale > 0 {
224- m.uiScale = cfg.DefaultUIScale242+ m.uiScale = uiScale
225 }243 }
226 244
227 defaults := DefaultFontConfig()245 defaults := DefaultFontConfig()
228 m.fonts = FontConfig{246 m.fonts = FontConfig{
229- SansFont: Coalesce(cfg.Appearance.SansFont, defaults.SansFont),247+ SansFont: Coalesce(appearance.SansFont, defaults.SansFont),
230- MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),248+ MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont),
231 }249 }
232 250
233 // Update mode colors251 // Update mode colors
234- m.modeColors = modeColorsFromConfig(cfg)252+ if styling != nil {
253+ m.modeColors = modeColorsFromStyling(styling)
254+ }
235 255
236 log.Info().256 log.Info().
237 Str("scheme", m.scheme).257 Str("scheme", m.scheme).
M internal/ui/theme/manager_test.go +45 -72
@@ -6,17 +6,15 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/application/port/mocks"
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212 )
1313
1414 func TestNewManager_DarkMode(t *testing.T) {
1515 ctx := context.Background()
16- cfg := &config.Config{
17- Appearance: config.AppearanceConfig{
18- ColorScheme: "default",
19- },
16+ appearance := &entity.AppearanceConfig{
17+ ColorScheme: "default",
2018 }
2119
2220 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -25,7 +23,7 @@ func TestNewManager_DarkMode(t *testing.T) {
2523 Source: "test",
2624 })
2725
28- manager := NewManager(ctx, cfg, mockResolver)
26+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
2927
3028 assert.True(t, manager.PrefersDark())
3129 assert.Equal(t, "default", manager.scheme)
@@ -33,10 +31,8 @@ func TestNewManager_DarkMode(t *testing.T) {
3331
3432 func TestNewManager_LightMode(t *testing.T) {
3533 ctx := context.Background()
36- cfg := &config.Config{
37- Appearance: config.AppearanceConfig{
38- ColorScheme: "default",
39- },
34+ appearance := &entity.AppearanceConfig{
35+ ColorScheme: "default",
4036 }
4137
4238 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -45,7 +41,7 @@ func TestNewManager_LightMode(t *testing.T) {
4541 Source: "test",
4642 })
4743
48- manager := NewManager(ctx, cfg, mockResolver)
44+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
4945
5046 assert.False(t, manager.PrefersDark())
5147 }
@@ -59,7 +55,7 @@ func TestNewManager_WithNilConfig(t *testing.T) {
5955 Source: "fallback",
6056 })
6157
62- manager := NewManager(ctx, nil, mockResolver)
58+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
6359
6460 require.NotNil(t, manager)
6561 assert.True(t, manager.PrefersDark())
@@ -68,10 +64,8 @@ func TestNewManager_WithNilConfig(t *testing.T) {
6864
6965 func TestNewManager_UsesConfigScheme(t *testing.T) {
7066 ctx := context.Background()
71- cfg := &config.Config{
72- Appearance: config.AppearanceConfig{
73- ColorScheme: "prefer-dark",
74- },
67+ appearance := &entity.AppearanceConfig{
68+ ColorScheme: "prefer-dark",
7569 }
7670
7771 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -80,14 +74,13 @@ func TestNewManager_UsesConfigScheme(t *testing.T) {
8074 Source: "config",
8175 })
8276
83- manager := NewManager(ctx, cfg, mockResolver)
77+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
8478
8579 assert.Equal(t, "prefer-dark", manager.scheme)
8680 }
8781
8882 func TestManager_GetCurrentPalette_Dark(t *testing.T) {
8983 ctx := context.Background()
90- cfg := &config.Config{}
9184
9285 mockResolver := mocks.NewMockColorSchemeResolver(t)
9386 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -95,7 +88,7 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) {
9588 Source: "test",
9689 })
9790
98- manager := NewManager(ctx, cfg, mockResolver)
91+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
9992 palette := manager.GetCurrentPalette()
10093
10194 // Dark palette should be returned
@@ -104,7 +97,6 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) {
10497
10598 func TestManager_GetCurrentPalette_Light(t *testing.T) {
10699 ctx := context.Background()
107- cfg := &config.Config{}
108100
109101 mockResolver := mocks.NewMockColorSchemeResolver(t)
110102 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -112,7 +104,7 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) {
112104 Source: "test",
113105 })
114106
115- manager := NewManager(ctx, cfg, mockResolver)
107+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
116108 palette := manager.GetCurrentPalette()
117109
118110 // Light palette should be returned
@@ -121,7 +113,6 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) {
121113
122114 func TestManager_SetColorScheme(t *testing.T) {
123115 ctx := context.Background()
124- cfg := &config.Config{}
125116
126117 mockResolver := mocks.NewMockColorSchemeResolver(t)
127118 // Initial resolve call
@@ -135,7 +126,7 @@ func TestManager_SetColorScheme(t *testing.T) {
135126 Source: "config",
136127 })
137128
138- manager := NewManager(ctx, cfg, mockResolver)
129+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
139130 assert.True(t, manager.PrefersDark())
140131
141132 // Change scheme - this calls Refresh on resolver
@@ -147,10 +138,8 @@ func TestManager_SetColorScheme(t *testing.T) {
147138
148139 func TestManager_UpdateFromConfig(t *testing.T) {
149140 ctx := context.Background()
150- cfg := &config.Config{
151- Appearance: config.AppearanceConfig{
152- ColorScheme: "default",
153- },
141+ appearance := &entity.AppearanceConfig{
142+ ColorScheme: "default",
154143 }
155144
156145 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -165,16 +154,14 @@ func TestManager_UpdateFromConfig(t *testing.T) {
165154 Source: "config",
166155 })
167156
168- manager := NewManager(ctx, cfg, mockResolver)
157+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
169158 assert.True(t, manager.PrefersDark())
170159
171160 // Update with new config
172- newCfg := &config.Config{
173- Appearance: config.AppearanceConfig{
174- ColorScheme: "prefer-light",
175- },
161+ newAppearance := &entity.AppearanceConfig{
162+ ColorScheme: "prefer-light",
176163 }
177- manager.UpdateFromConfig(ctx, newCfg, nil)
164+ manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil)
178165
179166 assert.False(t, manager.PrefersDark())
180167 assert.Equal(t, "prefer-light", manager.scheme)
@@ -182,7 +169,6 @@ func TestManager_UpdateFromConfig(t *testing.T) {
182169
183170 func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
184171 ctx := context.Background()
185- cfg := &config.Config{}
186172
187173 mockResolver := mocks.NewMockColorSchemeResolver(t)
188174 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -190,18 +176,17 @@ func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
190176 Source: "test",
191177 })
192178
193- manager := NewManager(ctx, cfg, mockResolver)
179+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
194180 initialScheme := manager.scheme
195181
196- // UpdateFromConfig with nil should be a no-op
197- manager.UpdateFromConfig(ctx, nil, nil)
182+ // UpdateFromConfig with nil appearance should be a no-op
183+ manager.UpdateFromConfig(ctx, nil, 0, nil, nil)
198184
199185 assert.Equal(t, initialScheme, manager.scheme)
200186 }
201187
202188 func TestManager_GetWebUIThemeCSS(t *testing.T) {
203189 ctx := context.Background()
204- cfg := &config.Config{}
205190
206191 mockResolver := mocks.NewMockColorSchemeResolver(t)
207192 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -209,7 +194,7 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) {
209194 Source: "test",
210195 })
211196
212- manager := NewManager(ctx, cfg, mockResolver)
197+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
213198 css := manager.GetWebUIThemeCSS()
214199
215200 // Should contain both light and dark variables
@@ -219,7 +204,6 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) {
219204
220205 func TestManager_GetBackgroundRGBA(t *testing.T) {
221206 ctx := context.Background()
222- cfg := &config.Config{}
223207
224208 mockResolver := mocks.NewMockColorSchemeResolver(t)
225209 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -227,7 +211,7 @@ func TestManager_GetBackgroundRGBA(t *testing.T) {
227211 Source: "test",
228212 })
229213
230- manager := NewManager(ctx, cfg, mockResolver)
214+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
231215 r, g, b, a := manager.GetBackgroundRGBA()
232216
233217 // Should return valid RGBA values (0-1 range)
@@ -243,16 +227,14 @@ func TestManager_GetBackgroundRGBA(t *testing.T) {
243227
244228 func TestManager_CustomPalettes(t *testing.T) {
245229 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- },
230+ appearance := &entity.AppearanceConfig{
231+ LightPalette: entity.ColorPalette{
232+ Background: "#ffffff",
233+ Text: "#000000",
234+ },
235+ DarkPalette: entity.ColorPalette{
236+ Background: "#000000",
237+ Text: "#ffffff",
256238 },
257239 }
258240
@@ -262,7 +244,7 @@ func TestManager_CustomPalettes(t *testing.T) {
262244 Source: "test",
263245 })
264246
265- manager := NewManager(ctx, cfg, mockResolver)
247+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
266248
267249 lightPalette := manager.GetLightPalette()
268250 darkPalette := manager.GetDarkPalette()
@@ -275,9 +257,6 @@ func TestManager_CustomPalettes(t *testing.T) {
275257
276258 func TestManager_UIScale(t *testing.T) {
277259 ctx := context.Background()
278- cfg := &config.Config{
279- DefaultUIScale: 1.5,
280- }
281260
282261 mockResolver := mocks.NewMockColorSchemeResolver(t)
283262 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -285,7 +264,7 @@ func TestManager_UIScale(t *testing.T) {
285264 Source: "test",
286265 })
287266
288- manager := NewManager(ctx, cfg, mockResolver)
267+ manager := NewManager(ctx, nil, 1.5, nil, mockResolver)
289268
290269 // UI scale should be stored (we can't directly access it, but we can verify
291270 // the manager was created without error)
@@ -294,11 +273,9 @@ func TestManager_UIScale(t *testing.T) {
294273
295274 func TestManager_Fonts(t *testing.T) {
296275 ctx := context.Background()
297- cfg := &config.Config{
298- Appearance: config.AppearanceConfig{
299- SansFont: "Inter",
300- MonospaceFont: "JetBrains Mono",
301- },
276+ appearance := &entity.AppearanceConfig{
277+ SansFont: "Inter",
278+ MonospaceFont: "JetBrains Mono",
302279 }
303280
304281 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -307,7 +284,7 @@ func TestManager_Fonts(t *testing.T) {
307284 Source: "test",
308285 })
309286
310- manager := NewManager(ctx, cfg, mockResolver)
287+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
311288
312289 // Fonts should be stored (manager creation should succeed)
313290 require.NotNil(t, manager)
@@ -315,15 +292,11 @@ func TestManager_Fonts(t *testing.T) {
315292
316293 func TestManager_ModeColors(t *testing.T) {
317294 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- },
295+ styling := &entity.WorkspaceStylingConfig{
296+ PaneModeColor: "#ff0000",
297+ TabModeColor: "#00ff00",
298+ SessionModeColor: "#0000ff",
299+ ResizeModeColor: "#ffff00",
327300 }
328301
329302 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -332,7 +305,7 @@ func TestManager_ModeColors(t *testing.T) {
332305 Source: "test",
333306 })
334307
335- manager := NewManager(ctx, cfg, mockResolver)
308+ manager := NewManager(ctx, nil, 1.0, styling, mockResolver)
336309 modeColors := manager.GetModeColors()
337310
338311 assert.Equal(t, "#ff0000", modeColors.PaneMode)
@@ -6,17 +6,15 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/port/mocks"8 "github.com/bnema/dumber/internal/application/port/mocks"
9- "github.com/bnema/dumber/internal/infrastructure/config"9+ "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 )12 )
13 13
14 func TestNewManager_DarkMode(t *testing.T) {14 func TestNewManager_DarkMode(t *testing.T) {
15 ctx := context.Background()15 ctx := context.Background()
16- cfg := &config.Config{16+ appearance := &entity.AppearanceConfig{
17- Appearance: config.AppearanceConfig{17+ ColorScheme: "default",
18- ColorScheme: "default",
19- },
20 }18 }
21 19
22 mockResolver := mocks.NewMockColorSchemeResolver(t)20 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -25,7 +23,7 @@ func TestNewManager_DarkMode(t *testing.T) {
25 Source: "test",23 Source: "test",
26 })24 })
27 25
28- manager := NewManager(ctx, cfg, mockResolver)26+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
29 27
30 assert.True(t, manager.PrefersDark())28 assert.True(t, manager.PrefersDark())
31 assert.Equal(t, "default", manager.scheme)29 assert.Equal(t, "default", manager.scheme)
@@ -33,10 +31,8 @@ func TestNewManager_DarkMode(t *testing.T) {
33 31
34 func TestNewManager_LightMode(t *testing.T) {32 func TestNewManager_LightMode(t *testing.T) {
35 ctx := context.Background()33 ctx := context.Background()
36- cfg := &config.Config{34+ appearance := &entity.AppearanceConfig{
37- Appearance: config.AppearanceConfig{35+ ColorScheme: "default",
38- ColorScheme: "default",
39- },
40 }36 }
41 37
42 mockResolver := mocks.NewMockColorSchemeResolver(t)38 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -45,7 +41,7 @@ func TestNewManager_LightMode(t *testing.T) {
45 Source: "test",41 Source: "test",
46 })42 })
47 43
48- manager := NewManager(ctx, cfg, mockResolver)44+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
49 45
50 assert.False(t, manager.PrefersDark())46 assert.False(t, manager.PrefersDark())
51 }47 }
@@ -59,7 +55,7 @@ func TestNewManager_WithNilConfig(t *testing.T) {
59 Source: "fallback",55 Source: "fallback",
60 })56 })
61 57
62- manager := NewManager(ctx, nil, mockResolver)58+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
63 59
64 require.NotNil(t, manager)60 require.NotNil(t, manager)
65 assert.True(t, manager.PrefersDark())61 assert.True(t, manager.PrefersDark())
@@ -68,10 +64,8 @@ func TestNewManager_WithNilConfig(t *testing.T) {
68 64
69 func TestNewManager_UsesConfigScheme(t *testing.T) {65 func TestNewManager_UsesConfigScheme(t *testing.T) {
70 ctx := context.Background()66 ctx := context.Background()
71- cfg := &config.Config{67+ appearance := &entity.AppearanceConfig{
72- Appearance: config.AppearanceConfig{68+ ColorScheme: "prefer-dark",
73- ColorScheme: "prefer-dark",
74- },
75 }69 }
76 70
77 mockResolver := mocks.NewMockColorSchemeResolver(t)71 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -80,14 +74,13 @@ func TestNewManager_UsesConfigScheme(t *testing.T) {
80 Source: "config",74 Source: "config",
81 })75 })
82 76
83- manager := NewManager(ctx, cfg, mockResolver)77+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
84 78
85 assert.Equal(t, "prefer-dark", manager.scheme)79 assert.Equal(t, "prefer-dark", manager.scheme)
86 }80 }
87 81
88 func TestManager_GetCurrentPalette_Dark(t *testing.T) {82 func TestManager_GetCurrentPalette_Dark(t *testing.T) {
89 ctx := context.Background()83 ctx := context.Background()
90- cfg := &config.Config{}
91 84
92 mockResolver := mocks.NewMockColorSchemeResolver(t)85 mockResolver := mocks.NewMockColorSchemeResolver(t)
93 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{86 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -95,7 +88,7 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) {
95 Source: "test",88 Source: "test",
96 })89 })
97 90
98- manager := NewManager(ctx, cfg, mockResolver)91+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
99 palette := manager.GetCurrentPalette()92 palette := manager.GetCurrentPalette()
100 93
101 // Dark palette should be returned94 // Dark palette should be returned
@@ -104,7 +97,6 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) {
104 97
105 func TestManager_GetCurrentPalette_Light(t *testing.T) {98 func TestManager_GetCurrentPalette_Light(t *testing.T) {
106 ctx := context.Background()99 ctx := context.Background()
107- cfg := &config.Config{}
108 100
109 mockResolver := mocks.NewMockColorSchemeResolver(t)101 mockResolver := mocks.NewMockColorSchemeResolver(t)
110 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{102 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -112,7 +104,7 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) {
112 Source: "test",104 Source: "test",
113 })105 })
114 106
115- manager := NewManager(ctx, cfg, mockResolver)107+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
116 palette := manager.GetCurrentPalette()108 palette := manager.GetCurrentPalette()
117 109
118 // Light palette should be returned110 // Light palette should be returned
@@ -121,7 +113,6 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) {
121 113
122 func TestManager_SetColorScheme(t *testing.T) {114 func TestManager_SetColorScheme(t *testing.T) {
123 ctx := context.Background()115 ctx := context.Background()
124- cfg := &config.Config{}
125 116
126 mockResolver := mocks.NewMockColorSchemeResolver(t)117 mockResolver := mocks.NewMockColorSchemeResolver(t)
127 // Initial resolve call118 // Initial resolve call
@@ -135,7 +126,7 @@ func TestManager_SetColorScheme(t *testing.T) {
135 Source: "config",126 Source: "config",
136 })127 })
137 128
138- manager := NewManager(ctx, cfg, mockResolver)129+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
139 assert.True(t, manager.PrefersDark())130 assert.True(t, manager.PrefersDark())
140 131
141 // Change scheme - this calls Refresh on resolver132 // Change scheme - this calls Refresh on resolver
@@ -147,10 +138,8 @@ func TestManager_SetColorScheme(t *testing.T) {
147 138
148 func TestManager_UpdateFromConfig(t *testing.T) {139 func TestManager_UpdateFromConfig(t *testing.T) {
149 ctx := context.Background()140 ctx := context.Background()
150- cfg := &config.Config{141+ appearance := &entity.AppearanceConfig{
151- Appearance: config.AppearanceConfig{142+ ColorScheme: "default",
152- ColorScheme: "default",
153- },
154 }143 }
155 144
156 mockResolver := mocks.NewMockColorSchemeResolver(t)145 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -165,16 +154,14 @@ func TestManager_UpdateFromConfig(t *testing.T) {
165 Source: "config",154 Source: "config",
166 })155 })
167 156
168- manager := NewManager(ctx, cfg, mockResolver)157+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
169 assert.True(t, manager.PrefersDark())158 assert.True(t, manager.PrefersDark())
170 159
171 // Update with new config160 // Update with new config
172- newCfg := &config.Config{161+ newAppearance := &entity.AppearanceConfig{
173- Appearance: config.AppearanceConfig{162+ ColorScheme: "prefer-light",
174- ColorScheme: "prefer-light",
175- },
176 }163 }
177- manager.UpdateFromConfig(ctx, newCfg, nil)164+ manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil)
178 165
179 assert.False(t, manager.PrefersDark())166 assert.False(t, manager.PrefersDark())
180 assert.Equal(t, "prefer-light", manager.scheme)167 assert.Equal(t, "prefer-light", manager.scheme)
@@ -182,7 +169,6 @@ func TestManager_UpdateFromConfig(t *testing.T) {
182 169
183 func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {170 func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
184 ctx := context.Background()171 ctx := context.Background()
185- cfg := &config.Config{}
186 172
187 mockResolver := mocks.NewMockColorSchemeResolver(t)173 mockResolver := mocks.NewMockColorSchemeResolver(t)
188 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{174 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -190,18 +176,17 @@ func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
190 Source: "test",176 Source: "test",
191 })177 })
192 178
193- manager := NewManager(ctx, cfg, mockResolver)179+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
194 initialScheme := manager.scheme180 initialScheme := manager.scheme
195 181
196- // UpdateFromConfig with nil should be a no-op182+ // UpdateFromConfig with nil appearance should be a no-op
197- manager.UpdateFromConfig(ctx, nil, nil)183+ manager.UpdateFromConfig(ctx, nil, 0, nil, nil)
198 184
199 assert.Equal(t, initialScheme, manager.scheme)185 assert.Equal(t, initialScheme, manager.scheme)
200 }186 }
201 187
202 func TestManager_GetWebUIThemeCSS(t *testing.T) {188 func TestManager_GetWebUIThemeCSS(t *testing.T) {
203 ctx := context.Background()189 ctx := context.Background()
204- cfg := &config.Config{}
205 190
206 mockResolver := mocks.NewMockColorSchemeResolver(t)191 mockResolver := mocks.NewMockColorSchemeResolver(t)
207 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{192 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -209,7 +194,7 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) {
209 Source: "test",194 Source: "test",
210 })195 })
211 196
212- manager := NewManager(ctx, cfg, mockResolver)197+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
213 css := manager.GetWebUIThemeCSS()198 css := manager.GetWebUIThemeCSS()
214 199
215 // Should contain both light and dark variables200 // Should contain both light and dark variables
@@ -219,7 +204,6 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) {
219 204
220 func TestManager_GetBackgroundRGBA(t *testing.T) {205 func TestManager_GetBackgroundRGBA(t *testing.T) {
221 ctx := context.Background()206 ctx := context.Background()
222- cfg := &config.Config{}
223 207
224 mockResolver := mocks.NewMockColorSchemeResolver(t)208 mockResolver := mocks.NewMockColorSchemeResolver(t)
225 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{209 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -227,7 +211,7 @@ func TestManager_GetBackgroundRGBA(t *testing.T) {
227 Source: "test",211 Source: "test",
228 })212 })
229 213
230- manager := NewManager(ctx, cfg, mockResolver)214+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
231 r, g, b, a := manager.GetBackgroundRGBA()215 r, g, b, a := manager.GetBackgroundRGBA()
232 216
233 // Should return valid RGBA values (0-1 range)217 // Should return valid RGBA values (0-1 range)
@@ -243,16 +227,14 @@ func TestManager_GetBackgroundRGBA(t *testing.T) {
243 227
244 func TestManager_CustomPalettes(t *testing.T) {228 func TestManager_CustomPalettes(t *testing.T) {
245 ctx := context.Background()229 ctx := context.Background()
246- cfg := &config.Config{230+ appearance := &entity.AppearanceConfig{
247- Appearance: config.AppearanceConfig{231+ LightPalette: entity.ColorPalette{
248- LightPalette: config.ColorPalette{232+ Background: "#ffffff",
249- Background: "#ffffff",233+ Text: "#000000",
250- Text: "#000000",234+ },
251- },235+ DarkPalette: entity.ColorPalette{
252- DarkPalette: config.ColorPalette{236+ Background: "#000000",
253- Background: "#000000",237+ Text: "#ffffff",
254- Text: "#ffffff",
255- },
256 },238 },
257 }239 }
258 240
@@ -262,7 +244,7 @@ func TestManager_CustomPalettes(t *testing.T) {
262 Source: "test",244 Source: "test",
263 })245 })
264 246
265- manager := NewManager(ctx, cfg, mockResolver)247+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
266 248
267 lightPalette := manager.GetLightPalette()249 lightPalette := manager.GetLightPalette()
268 darkPalette := manager.GetDarkPalette()250 darkPalette := manager.GetDarkPalette()
@@ -275,9 +257,6 @@ func TestManager_CustomPalettes(t *testing.T) {
275 257
276 func TestManager_UIScale(t *testing.T) {258 func TestManager_UIScale(t *testing.T) {
277 ctx := context.Background()259 ctx := context.Background()
278- cfg := &config.Config{
279- DefaultUIScale: 1.5,
280- }
281 260
282 mockResolver := mocks.NewMockColorSchemeResolver(t)261 mockResolver := mocks.NewMockColorSchemeResolver(t)
283 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{262 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -285,7 +264,7 @@ func TestManager_UIScale(t *testing.T) {
285 Source: "test",264 Source: "test",
286 })265 })
287 266
288- manager := NewManager(ctx, cfg, mockResolver)267+ manager := NewManager(ctx, nil, 1.5, nil, mockResolver)
289 268
290 // UI scale should be stored (we can't directly access it, but we can verify269 // UI scale should be stored (we can't directly access it, but we can verify
291 // the manager was created without error)270 // the manager was created without error)
@@ -294,11 +273,9 @@ func TestManager_UIScale(t *testing.T) {
294 273
295 func TestManager_Fonts(t *testing.T) {274 func TestManager_Fonts(t *testing.T) {
296 ctx := context.Background()275 ctx := context.Background()
297- cfg := &config.Config{276+ appearance := &entity.AppearanceConfig{
298- Appearance: config.AppearanceConfig{277+ SansFont: "Inter",
299- SansFont: "Inter",278+ MonospaceFont: "JetBrains Mono",
300- MonospaceFont: "JetBrains Mono",
301- },
302 }279 }
303 280
304 mockResolver := mocks.NewMockColorSchemeResolver(t)281 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -307,7 +284,7 @@ func TestManager_Fonts(t *testing.T) {
307 Source: "test",284 Source: "test",
308 })285 })
309 286
310- manager := NewManager(ctx, cfg, mockResolver)287+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
311 288
312 // Fonts should be stored (manager creation should succeed)289 // Fonts should be stored (manager creation should succeed)
313 require.NotNil(t, manager)290 require.NotNil(t, manager)
@@ -315,15 +292,11 @@ func TestManager_Fonts(t *testing.T) {
315 292
316 func TestManager_ModeColors(t *testing.T) {293 func TestManager_ModeColors(t *testing.T) {
317 ctx := context.Background()294 ctx := context.Background()
318- cfg := &config.Config{295+ styling := &entity.WorkspaceStylingConfig{
319- Workspace: config.WorkspaceConfig{296+ PaneModeColor: "#ff0000",
320- Styling: config.WorkspaceStylingConfig{297+ TabModeColor: "#00ff00",
321- PaneModeColor: "#ff0000",298+ SessionModeColor: "#0000ff",
322- TabModeColor: "#00ff00",299+ ResizeModeColor: "#ffff00",
323- SessionModeColor: "#0000ff",
324- ResizeModeColor: "#ffff00",
325- },
326- },
327 }300 }
328 301
329 mockResolver := mocks.NewMockColorSchemeResolver(t)302 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -332,7 +305,7 @@ func TestManager_ModeColors(t *testing.T) {
332 Source: "test",305 Source: "test",
333 })306 })
334 307
335- manager := NewManager(ctx, cfg, mockResolver)308+ manager := NewManager(ctx, nil, 1.0, styling, mockResolver)
336 modeColors := manager.GetModeColors()309 modeColors := manager.GetModeColors()
337 310
338 assert.Equal(t, "#ff0000", modeColors.PaneMode)311 assert.Equal(t, "#ff0000", modeColors.PaneMode)
M internal/ui/theme/palette.go +2 -2
@@ -6,7 +6,7 @@ import (
66 "regexp"
77 "strings"
88
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 )
1111
1212 // Palette holds semantic color tokens for theming.
@@ -85,7 +85,7 @@ func DefaultLightPalette() Palette {
8585 }
8686
8787 // PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
88-func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
88+func PaletteFromConfig(cfg *entity.ColorPalette, isDark bool) Palette {
8989 var defaults Palette
9090 if isDark {
9191 defaults = DefaultDarkPalette()
@@ -6,7 +6,7 @@ import (
6 "regexp"6 "regexp"
7 "strings"7 "strings"
8 8
9- "github.com/bnema/dumber/internal/infrastructure/config"9+ "github.com/bnema/dumber/internal/domain/entity"
10 )10 )
11 11
12 // Palette holds semantic color tokens for theming.12 // Palette holds semantic color tokens for theming.
@@ -85,7 +85,7 @@ func DefaultLightPalette() Palette {
85 }85 }
86 86
87 // PaletteFromConfig creates a Palette from config values, filling missing values with defaults.87 // PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
88-func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {88+func PaletteFromConfig(cfg *entity.ColorPalette, isDark bool) Palette {
89 var defaults Palette89 var defaults Palette
90 if isDark {90 if isDark {
91 defaults = DefaultDarkPalette()91 defaults = DefaultDarkPalette()
M internal/ui/window/main_window.go +21 -39
@@ -4,7 +4,6 @@ package window
44 import (
55 "context"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
87 "github.com/bnema/dumber/internal/logging"
98 "github.com/bnema/dumber/internal/ui/component"
109 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -26,31 +25,29 @@ type MainWindow struct {
2625 contentArea *gtk.Box // Container for workspace content
2726 currentContent *gtk.Widget // Track current content for removal on tab switch
2827
29- cfg *config.Config
30- logger zerolog.Logger
28+ tabBarPosition string // "top" or "bottom"
29+ logger zerolog.Logger
3130 }
3231
3332 // New creates a new main browser window.
34-func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWindow, error) {
33+// tabBarPosition controls whether the tab bar is at the "top" or "bottom" (default: "top").
34+func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*MainWindow, error) {
3535 log := logging.FromContext(ctx)
3636
3737 mw := &MainWindow{
38- cfg: cfg,
39- logger: log.With().Str("component", "main-window").Logger(),
38+ tabBarPosition: tabBarPosition,
39+ logger: log.With().Str("component", "main-window").Logger(),
4040 }
4141
42- // Create the application window
4342 mw.window = gtk.NewApplicationWindow(app)
4443 if mw.window == nil {
4544 return nil, ErrWindowCreationFailed
4645 }
4746
48- // Configure window
4947 title := windowTitle
5048 mw.window.SetTitle(&title)
5149 mw.window.SetDefaultSize(defaultWidth, defaultHeight)
5250
53- // Create root container (vertical box)
5451 mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
5552 if mw.rootBox == nil {
5653 mw.window.Unref()
@@ -60,7 +57,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
6057 mw.rootBox.SetVexpand(true)
6158 mw.rootBox.SetVisible(true)
6259
63- // Create tab bar
6460 mw.tabBar = component.NewTabBar()
6561 if mw.tabBar == nil {
6662 mw.rootBox.Unref()
@@ -68,7 +64,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
6864 return nil, ErrWidgetCreationFailed("tabBar")
6965 }
7066
71- // Create content overlay (for omnibox and other overlays)
7267 mw.contentOverlay = gtk.NewOverlay()
7368 if mw.contentOverlay == nil {
7469 mw.tabBar.Destroy()
@@ -80,7 +75,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
8075 mw.contentOverlay.SetVexpand(true)
8176 mw.contentOverlay.SetVisible(true)
8277
83- // Create content area
8478 mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
8579 if mw.contentArea == nil {
8680 mw.contentOverlay.Unref()
@@ -94,31 +88,25 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
9488 mw.contentArea.SetVisible(true)
9589 mw.contentArea.AddCssClass("content-area")
9690
97- // Set content area as the main child of the overlay
9891 mw.contentOverlay.SetChild(&mw.contentArea.Widget)
9992
100- // Assemble layout based on tab bar position
10193 mw.assembleLayout()
10294
103- // Set root box as window content
10495 mw.window.SetChild(&mw.rootBox.Widget)
10596
10697 return mw, nil
10798 }
10899
109-// assembleLayout arranges widgets based on configuration.
110100 func (mw *MainWindow) assembleLayout() {
111101 tabBarPos := "top" // default
112- if mw.cfg != nil {
113- tabBarPos = mw.cfg.Workspace.TabBarPosition
102+ if mw.tabBarPosition != "" {
103+ tabBarPos = mw.tabBarPosition
114104 }
115105
116106 if tabBarPos == "bottom" {
117- // Content first, then tab bar
118107 mw.rootBox.Append(&mw.contentOverlay.Widget)
119108 mw.rootBox.Append(mw.tabBar.Widget())
120109 } else {
121- // Tab bar first, then content (default: top)
122110 mw.rootBox.Append(mw.tabBar.Widget())
123111 mw.rootBox.Append(&mw.contentOverlay.Widget)
124112 }
@@ -128,7 +116,7 @@ func (mw *MainWindow) assembleLayout() {
128116 Msg("window layout assembled")
129117 }
130118
131-// Show makes the window visible.
119+// Show presents the window.
132120 func (mw *MainWindow) Show() {
133121 mw.window.Present()
134122 }
@@ -138,26 +126,23 @@ func (mw *MainWindow) Close() {
138126 mw.window.Close()
139127 }
140128
141-// TabBar returns the tab bar component.
129+// TabBar returns the window's tab bar component.
142130 func (mw *MainWindow) TabBar() *component.TabBar {
143131 return mw.tabBar
144132 }
145133
146-// ContentArea returns the content area box.
134+// ContentArea returns the main content container widget.
147135 func (mw *MainWindow) ContentArea() *gtk.Box {
148136 return mw.contentArea
149137 }
150138
151-// SetContent sets the content of the content area.
152-// This removes any existing content first to properly swap workspace views on tab switch.
139+// SetContent replaces the current content widget with the given widget.
153140 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
154- // Remove existing content first
155141 if mw.currentContent != nil {
156142 mw.contentArea.Remove(mw.currentContent)
157143 mw.currentContent = nil
158144 }
159145
160- // Add new content
161146 if widget != nil {
162147 widget.SetVisible(true)
163148 mw.contentArea.Append(widget)
@@ -165,18 +150,16 @@ func (mw *MainWindow) SetContent(widget *gtk.Widget) {
165150 }
166151 }
167152
168-// Window returns the underlying GTK window.
153+// Window returns the underlying GTK application window.
169154 func (mw *MainWindow) Window() *gtk.ApplicationWindow {
170155 return mw.window
171156 }
172157
173-// SetTitle updates the window title with proper formatting.
174-// The title is capped at 255 characters for display.
158+// SetTitle sets the window title.
175159 func (mw *MainWindow) SetTitle(title string) {
176160 if mw.window == nil {
177161 return
178162 }
179- // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8)
180163 const maxTitleLen = 255
181164 runes := []rune(title)
182165 if len(runes) > maxTitleLen {
@@ -185,21 +168,19 @@ func (mw *MainWindow) SetTitle(title string) {
185168 mw.window.SetTitle(&title)
186169 }
187170
188-// ContentOverlay returns the content overlay widget.
189-// Used by components that need the overlay dimensions for sizing calculations.
171+// ContentOverlay returns the overlay container for the content area.
190172 func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191173 return mw.contentOverlay
192174 }
193175
194-// AddOverlay adds a widget to the content overlay.
195-// The widget will be displayed on top of the workspace content.
176+// AddOverlay adds a widget as an overlay above the content area.
196177 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
197178 if mw.contentOverlay != nil && widget != nil {
198179 mw.contentOverlay.AddOverlay(widget)
199180 }
200181 }
201182
202-// Destroy cleans up window resources.
183+// Destroy releases all GTK resources held by the window.
203184 func (mw *MainWindow) Destroy() {
204185 if mw.tabBar != nil {
205186 mw.tabBar.Destroy()
@@ -219,21 +200,22 @@ func (mw *MainWindow) Destroy() {
219200 }
220201 }
221202
222-// WindowError represents a window-related error.
203+// WindowError is a window-related error.
223204 type WindowError struct {
224205 Message string
225206 }
226207
208+// Error implements the error interface.
227209 func (e WindowError) Error() string {
228210 return e.Message
229211 }
230212
231-// Error constants.
232213 var (
214+ // ErrWindowCreationFailed is returned when the GTK application window cannot be created.
233215 ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
234216 )
235217
236-// ErrWidgetCreationFailed creates an error for widget creation failure.
218+// ErrWidgetCreationFailed returns an error for a failed widget creation.
237219 func ErrWidgetCreationFailed(name string) error {
238220 return WindowError{Message: "failed to create widget: " + name}
239221 }
@@ -4,7 +4,6 @@ package window
4 import (4 import (
5 "context"5 "context"
6 6
7- "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/bnema/dumber/internal/logging"7 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/dumber/internal/ui/component"8 "github.com/bnema/dumber/internal/ui/component"
10 "github.com/jwijenbergh/puregotk/v4/gtk"9 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -26,31 +25,29 @@ type MainWindow struct {
26 contentArea *gtk.Box // Container for workspace content25 contentArea *gtk.Box // Container for workspace content
27 currentContent *gtk.Widget // Track current content for removal on tab switch26 currentContent *gtk.Widget // Track current content for removal on tab switch
28 27
29- cfg *config.Config28+ tabBarPosition string // "top" or "bottom"
30- logger zerolog.Logger29+ logger zerolog.Logger
31 }30 }
32 31
33 // New creates a new main browser window.32 // New creates a new main browser window.
34-func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWindow, error) {33+// tabBarPosition controls whether the tab bar is at the "top" or "bottom" (default: "top").
34+func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*MainWindow, error) {
35 log := logging.FromContext(ctx)35 log := logging.FromContext(ctx)
36 36
37 mw := &MainWindow{37 mw := &MainWindow{
38- cfg: cfg,38+ tabBarPosition: tabBarPosition,
39- logger: log.With().Str("component", "main-window").Logger(),39+ logger: log.With().Str("component", "main-window").Logger(),
40 }40 }
41 41
42- // Create the application window
43 mw.window = gtk.NewApplicationWindow(app)42 mw.window = gtk.NewApplicationWindow(app)
44 if mw.window == nil {43 if mw.window == nil {
45 return nil, ErrWindowCreationFailed44 return nil, ErrWindowCreationFailed
46 }45 }
47 46
48- // Configure window
49 title := windowTitle47 title := windowTitle
50 mw.window.SetTitle(&title)48 mw.window.SetTitle(&title)
51 mw.window.SetDefaultSize(defaultWidth, defaultHeight)49 mw.window.SetDefaultSize(defaultWidth, defaultHeight)
52 50
53- // Create root container (vertical box)
54 mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)51 mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
55 if mw.rootBox == nil {52 if mw.rootBox == nil {
56 mw.window.Unref()53 mw.window.Unref()
@@ -60,7 +57,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
60 mw.rootBox.SetVexpand(true)57 mw.rootBox.SetVexpand(true)
61 mw.rootBox.SetVisible(true)58 mw.rootBox.SetVisible(true)
62 59
63- // Create tab bar
64 mw.tabBar = component.NewTabBar()60 mw.tabBar = component.NewTabBar()
65 if mw.tabBar == nil {61 if mw.tabBar == nil {
66 mw.rootBox.Unref()62 mw.rootBox.Unref()
@@ -68,7 +64,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
68 return nil, ErrWidgetCreationFailed("tabBar")64 return nil, ErrWidgetCreationFailed("tabBar")
69 }65 }
70 66
71- // Create content overlay (for omnibox and other overlays)
72 mw.contentOverlay = gtk.NewOverlay()67 mw.contentOverlay = gtk.NewOverlay()
73 if mw.contentOverlay == nil {68 if mw.contentOverlay == nil {
74 mw.tabBar.Destroy()69 mw.tabBar.Destroy()
@@ -80,7 +75,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
80 mw.contentOverlay.SetVexpand(true)75 mw.contentOverlay.SetVexpand(true)
81 mw.contentOverlay.SetVisible(true)76 mw.contentOverlay.SetVisible(true)
82 77
83- // Create content area
84 mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)78 mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
85 if mw.contentArea == nil {79 if mw.contentArea == nil {
86 mw.contentOverlay.Unref()80 mw.contentOverlay.Unref()
@@ -94,31 +88,25 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
94 mw.contentArea.SetVisible(true)88 mw.contentArea.SetVisible(true)
95 mw.contentArea.AddCssClass("content-area")89 mw.contentArea.AddCssClass("content-area")
96 90
97- // Set content area as the main child of the overlay
98 mw.contentOverlay.SetChild(&mw.contentArea.Widget)91 mw.contentOverlay.SetChild(&mw.contentArea.Widget)
99 92
100- // Assemble layout based on tab bar position
101 mw.assembleLayout()93 mw.assembleLayout()
102 94
103- // Set root box as window content
104 mw.window.SetChild(&mw.rootBox.Widget)95 mw.window.SetChild(&mw.rootBox.Widget)
105 96
106 return mw, nil97 return mw, nil
107 }98 }
108 99
109-// assembleLayout arranges widgets based on configuration.
110 func (mw *MainWindow) assembleLayout() {100 func (mw *MainWindow) assembleLayout() {
111 tabBarPos := "top" // default101 tabBarPos := "top" // default
112- if mw.cfg != nil {102+ if mw.tabBarPosition != "" {
113- tabBarPos = mw.cfg.Workspace.TabBarPosition103+ tabBarPos = mw.tabBarPosition
114 }104 }
115 105
116 if tabBarPos == "bottom" {106 if tabBarPos == "bottom" {
117- // Content first, then tab bar
118 mw.rootBox.Append(&mw.contentOverlay.Widget)107 mw.rootBox.Append(&mw.contentOverlay.Widget)
119 mw.rootBox.Append(mw.tabBar.Widget())108 mw.rootBox.Append(mw.tabBar.Widget())
120 } else {109 } else {
121- // Tab bar first, then content (default: top)
122 mw.rootBox.Append(mw.tabBar.Widget())110 mw.rootBox.Append(mw.tabBar.Widget())
123 mw.rootBox.Append(&mw.contentOverlay.Widget)111 mw.rootBox.Append(&mw.contentOverlay.Widget)
124 }112 }
@@ -128,7 +116,7 @@ func (mw *MainWindow) assembleLayout() {
128 Msg("window layout assembled")116 Msg("window layout assembled")
129 }117 }
130 118
131-// Show makes the window visible.119+// Show presents the window.
132 func (mw *MainWindow) Show() {120 func (mw *MainWindow) Show() {
133 mw.window.Present()121 mw.window.Present()
134 }122 }
@@ -138,26 +126,23 @@ func (mw *MainWindow) Close() {
138 mw.window.Close()126 mw.window.Close()
139 }127 }
140 128
141-// TabBar returns the tab bar component.129+// TabBar returns the window's tab bar component.
142 func (mw *MainWindow) TabBar() *component.TabBar {130 func (mw *MainWindow) TabBar() *component.TabBar {
143 return mw.tabBar131 return mw.tabBar
144 }132 }
145 133
146-// ContentArea returns the content area box.134+// ContentArea returns the main content container widget.
147 func (mw *MainWindow) ContentArea() *gtk.Box {135 func (mw *MainWindow) ContentArea() *gtk.Box {
148 return mw.contentArea136 return mw.contentArea
149 }137 }
150 138
151-// SetContent sets the content of the content area.139+// SetContent replaces the current content widget with the given widget.
152-// This removes any existing content first to properly swap workspace views on tab switch.
153 func (mw *MainWindow) SetContent(widget *gtk.Widget) {140 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
154- // Remove existing content first
155 if mw.currentContent != nil {141 if mw.currentContent != nil {
156 mw.contentArea.Remove(mw.currentContent)142 mw.contentArea.Remove(mw.currentContent)
157 mw.currentContent = nil143 mw.currentContent = nil
158 }144 }
159 145
160- // Add new content
161 if widget != nil {146 if widget != nil {
162 widget.SetVisible(true)147 widget.SetVisible(true)
163 mw.contentArea.Append(widget)148 mw.contentArea.Append(widget)
@@ -165,18 +150,16 @@ func (mw *MainWindow) SetContent(widget *gtk.Widget) {
165 }150 }
166 }151 }
167 152
168-// Window returns the underlying GTK window.153+// Window returns the underlying GTK application window.
169 func (mw *MainWindow) Window() *gtk.ApplicationWindow {154 func (mw *MainWindow) Window() *gtk.ApplicationWindow {
170 return mw.window155 return mw.window
171 }156 }
172 157
173-// SetTitle updates the window title with proper formatting.158+// SetTitle sets the window title.
174-// The title is capped at 255 characters for display.
175 func (mw *MainWindow) SetTitle(title string) {159 func (mw *MainWindow) SetTitle(title string) {
176 if mw.window == nil {160 if mw.window == nil {
177 return161 return
178 }162 }
179- // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8)
180 const maxTitleLen = 255163 const maxTitleLen = 255
181 runes := []rune(title)164 runes := []rune(title)
182 if len(runes) > maxTitleLen {165 if len(runes) > maxTitleLen {
@@ -185,21 +168,19 @@ func (mw *MainWindow) SetTitle(title string) {
185 mw.window.SetTitle(&title)168 mw.window.SetTitle(&title)
186 }169 }
187 170
188-// ContentOverlay returns the content overlay widget.171+// ContentOverlay returns the overlay container for the content area.
189-// Used by components that need the overlay dimensions for sizing calculations.
190 func (mw *MainWindow) ContentOverlay() *gtk.Overlay {172 func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191 return mw.contentOverlay173 return mw.contentOverlay
192 }174 }
193 175
194-// AddOverlay adds a widget to the content overlay.176+// AddOverlay adds a widget as an overlay above the content area.
195-// The widget will be displayed on top of the workspace content.
196 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {177 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
197 if mw.contentOverlay != nil && widget != nil {178 if mw.contentOverlay != nil && widget != nil {
198 mw.contentOverlay.AddOverlay(widget)179 mw.contentOverlay.AddOverlay(widget)
199 }180 }
200 }181 }
201 182
202-// Destroy cleans up window resources.183+// Destroy releases all GTK resources held by the window.
203 func (mw *MainWindow) Destroy() {184 func (mw *MainWindow) Destroy() {
204 if mw.tabBar != nil {185 if mw.tabBar != nil {
205 mw.tabBar.Destroy()186 mw.tabBar.Destroy()
@@ -219,21 +200,22 @@ func (mw *MainWindow) Destroy() {
219 }200 }
220 }201 }
221 202
222-// WindowError represents a window-related error.203+// WindowError is a window-related error.
223 type WindowError struct {204 type WindowError struct {
224 Message string205 Message string
225 }206 }
226 207
208+// Error implements the error interface.
227 func (e WindowError) Error() string {209 func (e WindowError) Error() string {
228 return e.Message210 return e.Message
229 }211 }
230 212
231-// Error constants.
232 var (213 var (
214+ // ErrWindowCreationFailed is returned when the GTK application window cannot be created.
233 ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}215 ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
234 )216 )
235 217
236-// ErrWidgetCreationFailed creates an error for widget creation failure.218+// ErrWidgetCreationFailed returns an error for a failed widget creation.
237 func ErrWidgetCreationFailed(name string) error {219 func ErrWidgetCreationFailed(name string) error {
238 return WindowError{Message: "failed to create widget: " + name}220 return WindowError{Message: "failed to create widget: " + name}
239 }221 }

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