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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
25 files · +793 -691
M cmd/dumber/main.go +14 -5
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | 31 | "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 32 | 32 | "github.com/bnema/dumber/internal/logging" | |
| 33 | 33 | "github.com/bnema/dumber/internal/ui" | |
| 34 | + | "github.com/bnema/dumber/internal/ui/adapter" | |
| 34 | 35 | "github.com/bnema/dumber/internal/ui/component" | |
| 35 | 36 | "github.com/bnema/dumber/internal/ui/theme" | |
| 36 | 37 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| @@ -543,11 +544,17 @@ func buildUIDependencies( | |||
| 543 | 544 | CopyURLUC: uc.copyURL, | |
| 544 | 545 | Clipboard: uc.clipboard, | |
| 545 | 546 | 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, | |
| 551 | 558 | SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService { | |
| 552 | 559 | return snapshot.NewService(uc.snapshot, provider, intervalMs) | |
| 553 | 560 | }, | |
| @@ -570,6 +577,8 @@ func buildUIDependencies( | |||
| 570 | 577 | WatchConfig: func() error { | |
| 571 | 578 | return config.GetManager().Watch() | |
| 572 | 579 | }, | |
| 580 | + | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 581 | + | ConfigMigrator: config.NewMigrator(), | |
| 573 | 582 | } | |
| 574 | 583 | ||
| 575 | 584 | 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 uiDeps | 584 | return uiDeps | ||
M internal/bootstrap/gui.go +1 -1
| @@ -125,7 +125,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 125 | 125 | var themeManager *theme.Manager | |
| 126 | 126 | go func() { | |
| 127 | 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 | 131 | wg.Wait() | |
| @@ -125,7 +125,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 125 | var themeManager *theme.Manager | 125 | 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 @@ | |||
| 1 | 1 | 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 | 68 | // Config represents the complete configuration for dumber. | |
| 4 | 69 | type Config struct { | |
| 5 | 70 | Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"` | |
| @@ -256,31 +321,6 @@ type LoggingConfig struct { | |||
| 256 | 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 | 324 | // ContentFilteringConfig holds content filtering and ad blocking preferences | |
| 285 | 325 | type ContentFilteringConfig struct { | |
| 286 | 326 | // Enabled controls whether ad blocking is active (default: true) | |
| @@ -316,256 +356,9 @@ type DebugConfig struct { | |||
| 316 | 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 | 359 | // DownloadsConfig holds file download preferences. | |
| 334 | 360 | type DownloadsConfig struct { | |
| 335 | 361 | // Path is the directory where downloads are saved. | |
| 336 | 362 | // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads. | |
| 337 | 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 | - | } | |
| @@ -1,5 +1,70 @@ | |||||
| 1 | package config | 1 | 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 preferences | 324 | // 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 ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 13 | 12 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -23,18 +22,41 @@ type FaviconAdapter struct { | |||
| 23 | 22 | mu sync.RWMutex | |
| 24 | 23 | warnMu sync.Mutex | |
| 25 | 24 | 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 | 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 | 48 | // NewFaviconAdapter creates a new FaviconAdapter. | |
| 31 | 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 | 51 | 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, | |
| 38 | 60 | } | |
| 39 | 61 | } | |
| 40 | 62 | ||
| @@ -54,8 +76,8 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture { | |||
| 54 | 76 | // For internal dumb:// URLs, returns the app logo texture. | |
| 55 | 77 | func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture { | |
| 56 | 78 | // 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) | |
| 59 | 81 | } | |
| 60 | 82 | domain := domainurl.ExtractDomain(pageURL) | |
| 61 | 83 | return a.GetTexture(domain) | |
| @@ -72,7 +94,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 72 | 94 | log := logging.FromContext(ctx) | |
| 73 | 95 | ||
| 74 | 96 | // Handle internal dumb:// scheme URLs - use app logo | |
| 75 | - | if favicon.IsInternalURL(pageURL) { | |
| 97 | + | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | |
| 76 | 98 | texture := a.getOrCreateLogoTexture() | |
| 77 | 99 | callback(texture) | |
| 78 | 100 | return | |
| @@ -226,7 +248,7 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||
| 226 | 248 | ||
| 227 | 249 | // Create normalized sized copy for dmenu/fuzzel (async) | |
| 228 | 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 | 252 | a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) { | |
| 231 | 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 | 338 | log := logging.FromContext(ctx) | |
| 317 | 339 | ||
| 318 | 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 | 343 | log.Debug(). | |
| 322 | 344 | Str("domain", domain). | |
| @@ -327,7 +349,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) { | |||
| 327 | 349 | // Only create if original PNG exists but sized version doesn't | |
| 328 | 350 | if hasPNG && !hasSized { | |
| 329 | 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 | 353 | a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) { | |
| 332 | 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 | 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 | 403 | func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |
| 404 | + | if a.internalDomain == "" || a.getLogoBytes == nil { | |
| 405 | + | return nil | |
| 406 | + | } | |
| 407 | + | ||
| 382 | 408 | // Check cache first | |
| 383 | - | if texture := a.GetTexture(favicon.InternalDomain); texture != nil { | |
| 409 | + | if texture := a.GetTexture(a.internalDomain); texture != nil { | |
| 384 | 410 | return texture | |
| 385 | 411 | } | |
| 386 | 412 | ||
| 387 | 413 | // Create texture from logo bytes | |
| 388 | - | logoBytes := favicon.GetLogoBytes() | |
| 414 | + | logoBytes := a.getLogoBytes() | |
| 389 | 415 | texture := bytesToTexture(logoBytes) | |
| 390 | 416 | if texture != nil { | |
| 391 | - | a.setTexture(favicon.InternalDomain, texture) | |
| 417 | + | a.setTexture(a.internalDomain, texture) | |
| 392 | 418 | } | |
| 393 | 419 | return texture | |
| 394 | 420 | } | |
| @@ -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.RWMutex | 22 | mu sync.RWMutex | ||
| 24 | warnMu sync.Mutex | 23 | warnMu sync.Mutex | ||
| 25 | warnCounts map[string]int | 24 | 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 URLs | 78 | // 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 logo | 96 | // 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 | return | 100 | 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't | 349 | // 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 first | 408 | // Check cache first | ||
| 383 | - | if texture := a.GetTexture(favicon.InternalDomain); texture != nil { | 409 | + | if texture := a.GetTexture(a.internalDomain); texture != nil { |
| 384 | return texture | 410 | return texture | ||
| 385 | } | 411 | } | ||
| 386 | 412 | ||||
| 387 | // Create texture from logo bytes | 413 | // 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 texture | 419 | return texture | ||
| 394 | } | 420 | } | ||
M internal/ui/adapter/favicon_test.go +3 -3
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) { | |
| 11 | - | adapter := NewFaviconAdapter(nil, nil) | |
| 11 | + | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 12 | 12 | ||
| 13 | 13 | first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com") | |
| 14 | 14 | if !first { | |
| @@ -36,7 +36,7 @@ func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | func TestFaviconWarningDedup_ClearResetsState(t *testing.T) { | |
| 39 | - | adapter := NewFaviconAdapter(nil, nil) | |
| 39 | + | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 40 | 40 | key := "sized-png:example.com" | |
| 41 | 41 | ||
| 42 | 42 | first, _ := adapter.shouldLogWarningDedup(key) | |
| @@ -56,7 +56,7 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) { | |
| 59 | - | adapter := NewFaviconAdapter(nil, nil) | |
| 59 | + | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 60 | 60 | key := "save-png:example.com" | |
| 61 | 61 | var calls atomic.Int32 | |
| 62 | 62 | ||
| @@ -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.Int32 | 61 | var calls atomic.Int32 | ||
| 62 | 62 | ||||
M internal/ui/app.go +30 -19
| @@ -371,7 +371,7 @@ func (a *App) prewarmWebViewPoolAsync(_ context.Context) { | |||
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | 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 | 375 | if err != nil { | |
| 376 | 376 | return err | |
| 377 | 377 | } | |
| @@ -637,7 +637,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 637 | 637 | } | |
| 638 | 638 | ||
| 639 | 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 | 641 | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | |
| 642 | 642 | if action == input.ActionClosePane { | |
| 643 | 643 | if a.closeAndReleaseActiveFloatingPane(ctx) { | |
| @@ -712,7 +712,8 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 712 | 712 | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | |
| 713 | 713 | ctx, | |
| 714 | 714 | a.mainWindow.Window(), | |
| 715 | - | a.deps.Config, | |
| 715 | + | &a.deps.Config.Workspace, | |
| 716 | + | &a.deps.Config.Session, | |
| 716 | 717 | a.keyboardHandler, | |
| 717 | 718 | func(ctx context.Context, action input.Action) error { | |
| 718 | 719 | return a.kbDispatcher.Dispatch(ctx, action) | |
| @@ -1271,7 +1272,8 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 1271 | 1272 | a.deps.CheckUpdateUC, | |
| 1272 | 1273 | a.deps.ApplyUpdateUC, | |
| 1273 | 1274 | a.appToaster, | |
| 1274 | - | a.deps.Config, | |
| 1275 | + | a.deps.Config.Update.EnableOnStartup, | |
| 1276 | + | a.deps.Config.Update.AutoDownload, | |
| 1275 | 1277 | ) | |
| 1276 | 1278 | ||
| 1277 | 1279 | // Start async update check | |
| @@ -1520,7 +1522,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1520 | 1522 | } | |
| 1521 | 1523 | ||
| 1522 | 1524 | // 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 | 1527 | // 1. Content Coordinator (no dependencies on other coordinators) | |
| 1526 | 1528 | a.contentCoord = content.NewCoordinator( | |
| @@ -1539,6 +1541,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1539 | 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 | 1549 | // Wire deferred init trigger - runs after first navigation starts | |
| 1543 | 1550 | a.contentCoord.SetOnFirstLoadStarted(func() { | |
| 1544 | 1551 | a.triggerDeferredInit(ctx) | |
| @@ -1546,10 +1553,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1546 | 1553 | ||
| 1547 | 1554 | // 2. Tab Coordinator | |
| 1548 | 1555 | 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, | |
| 1553 | 1560 | }) | |
| 1554 | 1561 | a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) { | |
| 1555 | 1562 | a.createWorkspaceView(ctx, tab) | |
| @@ -1589,13 +1596,16 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1589 | 1596 | ||
| 1590 | 1597 | // 3. Workspace Coordinator | |
| 1591 | 1598 | 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, | |
| 1599 | 1609 | }) | |
| 1600 | 1610 | a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error { | |
| 1601 | 1611 | return a.tabCoord.Close(ctx) | |
| @@ -1699,6 +1709,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1699 | 1709 | a.navCoord, | |
| 1700 | 1710 | a.deps.ZoomUC, | |
| 1701 | 1711 | a.deps.CopyURLUC, | |
| 1712 | + | a.deps.Config.Workspace.NewPaneURL, | |
| 1702 | 1713 | ) | |
| 1703 | 1714 | a.wireKeyboardActions() | |
| 1704 | 1715 | ||
| @@ -3067,11 +3078,11 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 3067 | 3078 | a.applyAppearanceConfig(ctx) | |
| 3068 | 3079 | // Reload keyboard shortcuts | |
| 3069 | 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 | 3083 | // Reload global shortcuts (removes stale, re-registers from config) | |
| 3073 | 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 | 3087 | return false | |
| 3077 | 3088 | }) | |
| @@ -3103,7 +3114,7 @@ func (a *App) applyAppearanceConfig(ctx context.Context) { | |||
| 3103 | 3114 | if a.mainWindow != nil && a.mainWindow.Window() != nil { | |
| 3104 | 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 | 3119 | var inj port.ContentInjector | |
| 3109 | 3120 | 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 err | 376 | 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 check | 1279 | // 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 FaviconDatabase | 1524 | // 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 starts | 1549 | // 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 Coordinator | 1554 | // 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 Coordinator | 1597 | // 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 shortcuts | 3079 | // 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 false | 3087 | 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.ContentInjector | 3119 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 8 | "github.com/bnema/dumber/internal/ui/component" | |
| 10 | 9 | ) | |
| @@ -12,10 +11,12 @@ import ( | |||
| 12 | 11 | // checkConfigMigration checks if user config needs migration and shows a toast. | |
| 13 | 12 | func (a *App) checkConfigMigration(ctx context.Context) { | |
| 14 | 13 | log := logging.FromContext(ctx) | |
| 15 | - | cfg := config.Get() | |
| 16 | 14 | ||
| 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() | |
| 19 | 20 | if err != nil { | |
| 20 | 21 | log.Debug().Err(err).Msg("config migration check failed") | |
| 21 | 22 | return | |
| @@ -31,7 +32,7 @@ func (a *App) checkConfigMigration(ctx context.Context) { | |||
| 31 | 32 | Msg("config migration available") | |
| 32 | 33 | ||
| 33 | 34 | // Show toast notification if enabled | |
| 34 | - | if cfg.Update.NotifyOnNewSettings && a.appToaster != nil { | |
| 35 | + | if a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil { | |
| 35 | 36 | msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys)) | |
| 36 | 37 | a.appToaster.Show(ctx, msg, component.ToastInfo) | |
| 37 | 38 | } | |
| @@ -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 | return | 22 | 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 enabled | 34 | // 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 ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | ||
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 13 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 14 | 14 | "github.com/bnema/dumber/internal/ui/component" | |
| @@ -79,7 +79,7 @@ type Coordinator struct { | |||
| 79 | 79 | ||
| 80 | 80 | // Popup handling | |
| 81 | 81 | factory port.WebViewFactory | |
| 82 | - | popupConfig *config.PopupBehaviorConfig | |
| 82 | + | popupConfig *entity.PopupBehaviorConfig | |
| 83 | 83 | pendingPopups map[port.WebViewID]*PendingPopup | |
| 84 | 84 | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 85 | 85 | popupRefresh map[entity.PaneID]*time.Timer | |
| @@ -106,6 +106,10 @@ type Coordinator struct { | |||
| 106 | 106 | // Callback for first load_started event (triggers deferred initialization) | |
| 107 | 107 | onFirstLoadStarted func() | |
| 108 | 108 | 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 | 115 | type pendingThemeUpdate struct { | |
| @@ -228,6 +232,12 @@ func (c *Coordinator) SetOnFirstLoadStarted(fn func()) { | |||
| 228 | 232 | 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 | 241 | // ActivePaneID returns the currently active pane ID used by navigation. | |
| 232 | 242 | func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID { | |
| 233 | 243 | 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 handling | 80 | // Popup handling | ||
| 81 | factory port.WebViewFactory | 81 | factory port.WebViewFactory | ||
| 82 | - | popupConfig *config.PopupBehaviorConfig | 82 | + | popupConfig *entity.PopupBehaviorConfig |
| 83 | pendingPopups map[port.WebViewID]*PendingPopup | 83 | pendingPopups map[port.WebViewID]*PendingPopup | ||
| 84 | popupOAuth map[port.WebViewID]*popupOAuthState | 84 | popupOAuth map[port.WebViewID]*popupOAuthState | ||
| 85 | popupRefresh map[entity.PaneID]*time.Timer | 85 | 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.Once | 108 | 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 = fn | 232 | 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 ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 12 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 12 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 14 | 13 | ) | |
| @@ -324,8 +323,10 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID | |||
| 324 | 323 | if isExternal { | |
| 325 | 324 | log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally") | |
| 326 | 325 | ||
| 327 | - | // Launch externally | |
| 328 | - | desktop.LaunchExternalURL(uri) | |
| 326 | + | // Launch externally via injected callback | |
| 327 | + | if c.onLaunchExternalURL != nil { | |
| 328 | + | c.onLaunchExternalURL(uri) | |
| 329 | + | } | |
| 329 | 330 | ||
| 330 | 331 | // Stop loading to prevent WebKit from showing an error page | |
| 331 | 332 | // 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 externally | 326 | + | // 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 page | 331 | // Stop loading to prevent WebKit from showing an error page | ||
| 331 | // The page stays on the previous URL before the JS redirect | 332 | // 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 ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| @@ -93,7 +92,7 @@ type InsertPopupInput struct { | |||
| 93 | 92 | WebView port.WebView | |
| 94 | 93 | ||
| 95 | 94 | // Behavior determines how the popup is inserted (split/stacked/tabbed). | |
| 96 | - | Behavior config.PopupBehavior | |
| 95 | + | Behavior entity.PopupBehavior | |
| 97 | 96 | ||
| 98 | 97 | // Placement specifies direction for split behavior (right/left/top/bottom). | |
| 99 | 98 | Placement string | |
| @@ -106,9 +105,9 @@ type InsertPopupInput struct { | |||
| 106 | 105 | } | |
| 107 | 106 | ||
| 108 | 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 | 109 | if cfg == nil { | |
| 111 | - | return config.PopupBehaviorSplit // Default | |
| 110 | + | return entity.PopupBehaviorSplit // Default | |
| 112 | 111 | } | |
| 113 | 112 | ||
| 114 | 113 | switch popupType { | |
| @@ -116,13 +115,13 @@ func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.Po | |||
| 116 | 115 | // Tab-like popups (_blank) use blank_target_behavior | |
| 117 | 116 | switch cfg.BlankTargetBehavior { | |
| 118 | 117 | case "split": | |
| 119 | - | return config.PopupBehaviorSplit | |
| 118 | + | return entity.PopupBehaviorSplit | |
| 120 | 119 | case "stacked": | |
| 121 | - | return config.PopupBehaviorStacked | |
| 120 | + | return entity.PopupBehaviorStacked | |
| 122 | 121 | case "tabbed": | |
| 123 | - | return config.PopupBehaviorTabbed | |
| 122 | + | return entity.PopupBehaviorTabbed | |
| 124 | 123 | default: | |
| 125 | - | return config.PopupBehaviorStacked // Default for _blank | |
| 124 | + | return entity.PopupBehaviorStacked // Default for _blank | |
| 126 | 125 | } | |
| 127 | 126 | case PopupTypePopup: | |
| 128 | 127 | // JS popups use the main behavior setting | |
| @@ -135,7 +134,7 @@ func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.Po | |||
| 135 | 134 | // SetPopupConfig configures popup handling. | |
| 136 | 135 | func (c *Coordinator) SetPopupConfig( | |
| 137 | 136 | factory port.WebViewFactory, | |
| 138 | - | popupConfig *config.PopupBehaviorConfig, | |
| 137 | + | popupConfig *entity.PopupBehaviorConfig, | |
| 139 | 138 | generateID func() string, | |
| 140 | 139 | ) { | |
| 141 | 140 | c.factory = factory | |
| @@ -476,9 +475,9 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en | |||
| 476 | 475 | c.setupWebViewCallbacks(ctx, paneID, newWV) | |
| 477 | 476 | ||
| 478 | 477 | // Get behavior from config (same as _blank links) | |
| 479 | - | behavior := config.PopupBehaviorStacked // default | |
| 478 | + | behavior := entity.PopupBehaviorStacked // default | |
| 480 | 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 | 482 | placement := "right" | |
| 484 | 483 | 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.WebView | 92 | 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.PopupBehavior | 95 | + | 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 string | 98 | 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 // Default | 110 | + | 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_behavior | 115 | // 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.PopupBehaviorSplit | 118 | + | return entity.PopupBehaviorSplit |
| 120 | case "stacked": | 119 | case "stacked": | ||
| 121 | - | return config.PopupBehaviorStacked | 120 | + | return entity.PopupBehaviorStacked |
| 122 | case "tabbed": | 121 | case "tabbed": | ||
| 123 | - | return config.PopupBehaviorTabbed | 122 | + | return entity.PopupBehaviorTabbed |
| 124 | default: | 123 | default: | ||
| 125 | - | return config.PopupBehaviorStacked // Default for _blank | 124 | + | return entity.PopupBehaviorStacked // Default for _blank |
| 126 | } | 125 | } | ||
| 127 | case PopupTypePopup: | 126 | case PopupTypePopup: | ||
| 128 | // JS popups use the main behavior setting | 127 | // 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 = factory | 140 | 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 // default | 478 | + | 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 ( | |||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 10 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 11 | "github.com/bnema/dumber/internal/ui/window" | |
| @@ -14,10 +13,10 @@ import ( | |||
| 14 | 13 | ||
| 15 | 14 | // TabCoordinator manages tab lifecycle operations. | |
| 16 | 15 | 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 | |
| 21 | 20 | ||
| 22 | 21 | // Callbacks to avoid circular dependencies | |
| 23 | 22 | onTabCreated func(ctx context.Context, tab *entity.Tab) | |
| @@ -29,10 +28,10 @@ type TabCoordinator struct { | |||
| 29 | 28 | ||
| 30 | 29 | // TabCoordinatorConfig holds configuration for TabCoordinator. | |
| 31 | 30 | 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 | |
| 36 | 35 | } | |
| 37 | 36 | ||
| 38 | 37 | // NewTabCoordinator creates a new TabCoordinator. | |
| @@ -41,10 +40,10 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi | |||
| 41 | 40 | log.Debug().Msg("creating tab coordinator") | |
| 42 | 41 | ||
| 43 | 42 | 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, | |
| 48 | 47 | } | |
| 49 | 48 | } | |
| 50 | 49 | ||
| @@ -304,10 +303,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) { | |||
| 304 | 303 | log := logging.FromContext(ctx) | |
| 305 | 304 | ||
| 306 | 305 | // Check if feature is enabled | |
| 307 | - | hideEnabled := true | |
| 308 | - | if c.config != nil { | |
| 309 | - | hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab | |
| 310 | - | } | |
| 306 | + | hideEnabled := c.hideTabBarWhenSingleTab | |
| 311 | 307 | ||
| 312 | 308 | if !hideEnabled { | |
| 313 | 309 | 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.ManageTabsUseCase | 16 | + | tabsUC *usecase.ManageTabsUseCase |
| 18 | - | tabs *entity.TabList | 17 | + | tabs *entity.TabList |
| 19 | - | mainWindow *window.MainWindow | 18 | + | mainWindow *window.MainWindow |
| 20 | - | config *config.Config | 19 | + | hideTabBarWhenSingleTab bool |
| 21 | 20 | ||||
| 22 | // Callbacks to avoid circular dependencies | 21 | // 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.ManageTabsUseCase | 31 | + | TabsUC *usecase.ManageTabsUseCase |
| 33 | - | Tabs *entity.TabList | 32 | + | Tabs *entity.TabList |
| 34 | - | MainWindow *window.MainWindow | 33 | + | MainWindow *window.MainWindow |
| 35 | - | Config *config.Config | 34 | + | 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 enabled | 305 | // Check if feature is enabled | ||
| 307 | - | hideEnabled := true | 306 | + | 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 13 | "github.com/bnema/dumber/internal/ui/component" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | 16 | // UpdateCoordinator handles update checking and notification. | |
| 18 | 17 | 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 | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 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 | 31 | func NewUpdateCoordinator( | |
| 30 | 32 | checkUC *usecase.CheckUpdateUseCase, | |
| 31 | 33 | applyUC *usecase.ApplyUpdateUseCase, | |
| 32 | 34 | toaster *component.Toaster, | |
| 33 | - | cfg *config.Config, | |
| 35 | + | enableOnStartup bool, | |
| 36 | + | autoDownload bool, | |
| 34 | 37 | ) *UpdateCoordinator { | |
| 35 | 38 | 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, | |
| 41 | 45 | } | |
| 42 | 46 | } | |
| 43 | 47 | ||
| 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. | |
| 46 | 49 | func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) { | |
| 47 | 50 | log := logging.FromContext(ctx) | |
| 48 | 51 | ||
| 49 | - | if !c.config.Update.EnableOnStartup { | |
| 52 | + | if !c.enableOnStartup { | |
| 50 | 53 | log.Debug().Msg("update check on startup disabled") | |
| 51 | 54 | return | |
| 52 | 55 | } | |
| @@ -54,7 +57,6 @@ func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) { | |||
| 54 | 57 | go c.checkAsync(ctx) | |
| 55 | 58 | } | |
| 56 | 59 | ||
| 57 | - | // checkAsync performs the update check in a goroutine. | |
| 58 | 60 | func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |
| 59 | 61 | log := logging.FromContext(ctx) | |
| 60 | 62 | ||
| @@ -67,7 +69,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |||
| 67 | 69 | return | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | - | // Update both lastInfo and status atomically to avoid inconsistent reads. | |
| 71 | 72 | c.mu.Lock() | |
| 72 | 73 | c.lastInfo = result | |
| 73 | 74 | if !result.UpdateAvailable { | |
| @@ -86,27 +87,22 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |||
| 86 | 87 | Bool("can_auto_update", result.CanAutoUpdate). | |
| 87 | 88 | Msg("update available") | |
| 88 | 89 | ||
| 89 | - | // Show notification on GTK main thread. | |
| 90 | 90 | c.showUpdateNotification(ctx, result) | |
| 91 | 91 | ||
| 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 { | |
| 95 | 93 | go c.downloadAsync(ctx, result.DownloadURL) | |
| 96 | 94 | } | |
| 97 | 95 | } | |
| 98 | 96 | ||
| 99 | - | // showUpdateNotification displays a toast notification about the update. | |
| 100 | 97 | func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) { | |
| 101 | 98 | var msg string | |
| 102 | 99 | switch { | |
| 103 | - | case result.CanAutoUpdate && c.config.Update.AutoDownload: | |
| 100 | + | case result.CanAutoUpdate && c.autoDownload: | |
| 104 | 101 | msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion) | |
| 105 | 102 | default: | |
| 106 | 103 | msg = fmt.Sprintf("Update %s available", result.LatestVersion) | |
| 107 | 104 | } | |
| 108 | 105 | ||
| 109 | - | // Dispatch to GTK main thread. | |
| 110 | 106 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 111 | 107 | c.toaster.Show(ctx, msg, component.ToastInfo) | |
| 112 | 108 | return false | |
| @@ -114,7 +110,6 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result * | |||
| 114 | 110 | glib.IdleAdd(&cb, 0) | |
| 115 | 111 | } | |
| 116 | 112 | ||
| 117 | - | // downloadAsync downloads and stages the update in the background. | |
| 118 | 113 | func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) { | |
| 119 | 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 | 146 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | |
| 153 | 147 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 154 | 148 | c.toaster.Show(ctx, msg, level) | |
| @@ -157,8 +151,7 @@ func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level com | |||
| 157 | 151 | glib.IdleAdd(&cb, 0) | |
| 158 | 152 | } | |
| 159 | 153 | ||
| 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. | |
| 162 | 155 | func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error { | |
| 163 | 156 | if c.applyUC == nil { | |
| 164 | 157 | return nil | |
| @@ -173,14 +166,14 @@ func (c *UpdateCoordinator) Status() entity.UpdateStatus { | |||
| 173 | 166 | 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 | 170 | func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput { | |
| 178 | 171 | c.mu.RLock() | |
| 179 | 172 | defer c.mu.RUnlock() | |
| 180 | 173 | 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 | 177 | func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool { | |
| 185 | 178 | if c.applyUC == nil { | |
| 186 | 179 | 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.CheckUpdateUseCase | 18 | + | checkUC *usecase.CheckUpdateUseCase |
| 20 | - | applyUC *usecase.ApplyUpdateUseCase | 19 | + | applyUC *usecase.ApplyUpdateUseCase |
| 21 | - | toaster *component.Toaster | 20 | + | toaster *component.Toaster |
| 22 | - | config *config.Config | 21 | + | enableOnStartup bool |
| 23 | - | mu sync.RWMutex // Protects status and lastInfo | 22 | + | autoDownload bool |
| 24 | - | status entity.UpdateStatus | 23 | + | mu sync.RWMutex // Protects status and lastInfo |
| 25 | - | lastInfo *usecase.CheckUpdateOutput | 24 | + | 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 | return | 54 | 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 | return | 69 | 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 = result | 73 | 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 string | 98 | 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 false | 108 | 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 nil | 157 | return nil | ||
| @@ -173,14 +166,14 @@ func (c *UpdateCoordinator) Status() entity.UpdateStatus { | |||||
| 173 | return c.status | 166 | 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.lastInfo | 173 | 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 false | 179 | return false | ||
M internal/ui/coordinator/workspace.go +32 -24
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 12 | "github.com/bnema/dumber/internal/ui/component" | |
| 14 | 13 | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| @@ -30,6 +29,11 @@ type WorkspaceCoordinator struct { | |||
| 30 | 29 | widgetFactory layout.WidgetFactory | |
| 31 | 30 | 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 | 37 | // Callbacks to avoid circular dependencies | |
| 34 | 38 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | |
| 35 | 39 | generateID func() string | |
| @@ -41,13 +45,16 @@ type WorkspaceCoordinator struct { | |||
| 41 | 45 | ||
| 42 | 46 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | |
| 43 | 47 | 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 | |
| 51 | 58 | } | |
| 52 | 59 | ||
| 53 | 60 | type splitContext struct { | |
| @@ -82,13 +89,16 @@ func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig | |||
| 82 | 89 | log.Debug().Msg("creating workspace coordinator") | |
| 83 | 90 | ||
| 84 | 91 | 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, | |
| 92 | 102 | } | |
| 93 | 103 | } | |
| 94 | 104 | ||
| @@ -144,7 +154,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli | |||
| 144 | 154 | Workspace: splitCtx.ws, | |
| 145 | 155 | TargetPane: splitCtx.activePane, | |
| 146 | 156 | Direction: direction, | |
| 147 | - | InitialURL: domainurl.Normalize(config.Get().Workspace.NewPaneURL), | |
| 157 | + | InitialURL: domainurl.Normalize(c.newPaneURL), | |
| 148 | 158 | }) | |
| 149 | 159 | if err != nil { | |
| 150 | 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 | 1360 | // Create new pane entity | |
| 1351 | 1361 | newPaneID := entity.PaneID(c.generateID()) | |
| 1352 | 1362 | newPane := entity.NewPane(newPaneID) | |
| 1353 | - | newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL) | |
| 1363 | + | newPane.URI = domainurl.Normalize(c.newPaneURL) | |
| 1354 | 1364 | newPane.Title = defaultPaneTitle | |
| 1355 | 1365 | ||
| 1356 | 1366 | // 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 | 1785 | Msg("inserting popup into workspace") | |
| 1776 | 1786 | ||
| 1777 | 1787 | switch input.Behavior { | |
| 1778 | - | case config.PopupBehaviorSplit: | |
| 1788 | + | case entity.PopupBehaviorSplit: | |
| 1779 | 1789 | return c.insertPopupSplit(ctx, input) | |
| 1780 | - | case config.PopupBehaviorStacked: | |
| 1790 | + | case entity.PopupBehaviorStacked: | |
| 1781 | 1791 | return c.insertPopupStacked(ctx, input) | |
| 1782 | - | case config.PopupBehaviorTabbed: | |
| 1792 | + | case entity.PopupBehaviorTabbed: | |
| 1783 | 1793 | return c.insertPopupTabbed(ctx, input) | |
| 1784 | 1794 | default: | |
| 1785 | 1795 | // Default to split right | |
| @@ -2198,8 +2208,7 @@ func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDir | |||
| 2198 | 2208 | return nil | |
| 2199 | 2209 | } | |
| 2200 | 2210 | ||
| 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) | |
| 2203 | 2212 | if errors.Is(err, usecase.ErrNothingToResize) { | |
| 2204 | 2213 | c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo) | |
| 2205 | 2214 | return nil | |
| @@ -2230,12 +2239,11 @@ func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID st | |||
| 2230 | 2239 | return nil | |
| 2231 | 2240 | } | |
| 2232 | 2241 | ||
| 2233 | - | cfg := config.Get() | |
| 2234 | 2242 | err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{ | |
| 2235 | 2243 | Workspace: ws, | |
| 2236 | 2244 | SplitNodeID: splitNodeID, | |
| 2237 | 2245 | Ratio: ratio, | |
| 2238 | - | MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent, | |
| 2246 | + | MinPanePercent: c.resizeMinPanePercent, | |
| 2239 | 2247 | }) | |
| 2240 | 2248 | if err != nil { | |
| 2241 | 2249 | 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.WidgetFactory | 29 | widgetFactory layout.WidgetFactory | ||
| 31 | contentCoord *content.Coordinator | 30 | 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 dependencies | 37 | // Callbacks to avoid circular dependencies | ||
| 34 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | 38 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | ||
| 35 | generateID func() string | 39 | 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.ManagePanesUseCase | 48 | + | PanesUC *usecase.ManagePanesUseCase |
| 45 | - | FocusMgr *focus.Manager | 49 | + | FocusMgr *focus.Manager |
| 46 | - | StackedPaneMgr *component.StackedPaneManager | 50 | + | StackedPaneMgr *component.StackedPaneManager |
| 47 | - | WidgetFactory layout.WidgetFactory | 51 | + | WidgetFactory layout.WidgetFactory |
| 48 | - | ContentCoord *content.Coordinator | 52 | + | ContentCoord *content.Coordinator |
| 49 | - | GetActiveWS func() (*entity.Workspace, *component.WorkspaceView) | 53 | + | GetActiveWS func() (*entity.Workspace, *component.WorkspaceView) |
| 50 | - | GenerateID func() string | 54 | + | 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 entity | 1360 | // 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 = defaultPaneTitle | 1364 | newPane.Title = defaultPaneTitle | ||
| 1355 | 1365 | ||||
| 1356 | // Determine if we need to create a new stack or add to existing | 1366 | // 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 right | 1795 | // Default to split right | ||
| @@ -2198,8 +2208,7 @@ func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDir | |||||
| 2198 | return nil | 2208 | 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 nil | 2214 | return nil | ||
| @@ -2230,12 +2239,11 @@ func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID st | |||||
| 2230 | return nil | 2239 | 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 err | 2249 | return err | ||
M internal/ui/deps.go +14 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/repository" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/adapter" | |
| 12 | 13 | "github.com/bnema/dumber/internal/ui/theme" | |
| 13 | 14 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 14 | 15 | ) | |
| @@ -55,10 +56,11 @@ type Dependencies struct { | |||
| 55 | 56 | CopyURLUC *usecase.CopyURLUseCase | |
| 56 | 57 | ||
| 57 | 58 | // 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 | |
| 62 | 64 | ||
| 63 | 65 | // Accent picker for dead keys support | |
| 64 | 66 | InsertAccentUC *usecase.InsertAccentUseCase | |
| @@ -88,6 +90,14 @@ type Dependencies struct { | |||
| 88 | 90 | // Update management | |
| 89 | 91 | CheckUpdateUC *usecase.CheckUpdateUseCase | |
| 90 | 92 | 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 | 103 | // 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.CopyURLUseCase | 56 | CopyURLUC *usecase.CopyURLUseCase | ||
| 56 | 57 | ||||
| 57 | // Infrastructure Adapters | 58 | // Infrastructure Adapters | ||
| 58 | - | Clipboard port.Clipboard | 59 | + | Clipboard port.Clipboard |
| 59 | - | FaviconService port.FaviconService | 60 | + | FaviconService port.FaviconService |
| 60 | - | FilterManager port.FilterManager | 61 | + | FaviconAdapterConfig adapter.FaviconAdapterConfig |
| 61 | - | IdleInhibitor port.IdleInhibitor | 62 | + | FilterManager port.FilterManager |
| 63 | + | IdleInhibitor port.IdleInhibitor | |||
| 62 | 64 | ||||
| 63 | // Accent picker for dead keys support | 65 | // Accent picker for dead keys support | ||
| 64 | InsertAccentUC *usecase.InsertAccentUseCase | 66 | InsertAccentUC *usecase.InsertAccentUseCase | ||
| @@ -88,6 +90,14 @@ type Dependencies struct { | |||||
| 88 | // Update management | 90 | // Update management | ||
| 89 | CheckUpdateUC *usecase.CheckUpdateUseCase | 91 | CheckUpdateUC *usecase.CheckUpdateUseCase | ||
| 90 | ApplyUpdateUC *usecase.ApplyUpdateUseCase | 92 | 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 ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 13 | 12 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| @@ -22,6 +21,7 @@ type KeyboardDispatcher struct { | |||
| 22 | 21 | navCoord *coordinator.NavigationCoordinator | |
| 23 | 22 | zoomUC *usecase.ManageZoomUseCase | |
| 24 | 23 | copyURLUC *usecase.CopyURLUseCase | |
| 24 | + | newPaneURL string | |
| 25 | 25 | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 26 | 26 | onQuit func() | |
| 27 | 27 | onFindOpen func(ctx context.Context) error | |
| @@ -43,16 +43,18 @@ func NewKeyboardDispatcher( | |||
| 43 | 43 | navCoord *coordinator.NavigationCoordinator, | |
| 44 | 44 | zoomUC *usecase.ManageZoomUseCase, | |
| 45 | 45 | copyURLUC *usecase.CopyURLUseCase, | |
| 46 | + | newPaneURL string, | |
| 46 | 47 | ) *KeyboardDispatcher { | |
| 47 | 48 | log := logging.FromContext(ctx) | |
| 48 | 49 | log.Debug().Msg("creating keyboard dispatcher") | |
| 49 | 50 | ||
| 50 | 51 | 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, | |
| 56 | 58 | } | |
| 57 | 59 | dispatcher.initActionHandlers() | |
| 58 | 60 | return dispatcher | |
| @@ -130,8 +132,7 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 130 | 132 | d.actionHandlers = map[input.Action]func(ctx context.Context) error{ | |
| 131 | 133 | // Tab actions | |
| 132 | 134 | 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)) | |
| 135 | 136 | return err | |
| 136 | 137 | }, | |
| 137 | 138 | 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.NavigationCoordinator | 21 | navCoord *coordinator.NavigationCoordinator | ||
| 23 | zoomUC *usecase.ManageZoomUseCase | 22 | zoomUC *usecase.ManageZoomUseCase | ||
| 24 | copyURLUC *usecase.CopyURLUseCase | 23 | copyURLUC *usecase.CopyURLUseCase | ||
| 24 | + | newPaneURL string | |||
| 25 | actionHandlers map[input.Action]func(ctx context.Context) error | 25 | actionHandlers map[input.Action]func(ctx context.Context) error | ||
| 26 | onQuit func() | 26 | onQuit func() | ||
| 27 | onFindOpen func(ctx context.Context) error | 27 | 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 dispatcher | 60 | 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 actions | 133 | // 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 err | 136 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | 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 | 27 | 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) | |
| 16 | 30 | ||
| 17 | 31 | action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal) | |
| 18 | 32 | require.True(t, ok) | |
| @@ -20,8 +34,8 @@ func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) { | |||
| 20 | 34 | } | |
| 21 | 35 | ||
| 22 | 36 | 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{ | |
| 25 | 39 | "google": { | |
| 26 | 40 | Keys: []string{"alt+g"}, | |
| 27 | 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 | 51 | googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal) | |
| 38 | 52 | require.True(t, ok) | |
| @@ -50,8 +64,8 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) { | |||
| 50 | 64 | } | |
| 51 | 65 | ||
| 52 | 66 | 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{ | |
| 55 | 69 | "tab-conflict": { | |
| 56 | 70 | Keys: []string{"alt+1"}, | |
| 57 | 71 | URL: "https://example.com/tab", | |
| @@ -71,7 +85,7 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) { | |||
| 71 | 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 | 89 | require.Len(t, shortcuts, 1) | |
| 76 | 90 | assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding) | |
| 77 | 91 | target, ok := ParseFloatingProfileTarget(shortcuts[0].Action) | |
| @@ -81,15 +95,15 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) { | |||
| 81 | 95 | } | |
| 82 | 96 | ||
| 83 | 97 | 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{ | |
| 86 | 100 | "conflict": { | |
| 87 | 101 | Keys: []string{"alt+["}, | |
| 88 | 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 | 108 | tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal) | |
| 95 | 109 | require.True(t, ok) | |
| @@ -101,8 +115,8 @@ func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing. | |||
| 101 | 115 | } | |
| 102 | 116 | ||
| 103 | 117 | 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{ | |
| 106 | 120 | "alt-one-conflict": { | |
| 107 | 121 | Keys: []string{"alt+1"}, | |
| 108 | 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 | 136 | _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal) | |
| 123 | 137 | assert.False(t, ok) | |
| @@ -134,8 +148,8 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) { | |||
| 134 | 148 | } | |
| 135 | 149 | ||
| 136 | 150 | 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{ | |
| 139 | 153 | "work-mail": { | |
| 140 | 154 | Keys: []string{"alt+w"}, | |
| 141 | 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 | 165 | workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal) | |
| 152 | 166 | require.True(t, ok) | |
| @@ -164,8 +178,8 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing | |||
| 164 | 178 | } | |
| 165 | 179 | ||
| 166 | 180 | 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{ | |
| 169 | 183 | "mail": { | |
| 170 | 184 | Keys: []string{"ctrl+alt+m"}, | |
| 171 | 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 | 195 | mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal) | |
| 182 | 196 | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 12 | 12 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -33,7 +33,8 @@ type GlobalShortcutHandler struct { | |||
| 33 | 33 | func NewGlobalShortcutHandler( | |
| 34 | 34 | ctx context.Context, | |
| 35 | 35 | window *gtk.ApplicationWindow, | |
| 36 | - | cfg *config.Config, | |
| 36 | + | workspace *entity.WorkspaceConfig, | |
| 37 | + | session *entity.SessionConfig, | |
| 37 | 38 | kbHandler *KeyboardHandler, | |
| 38 | 39 | onAction ActionHandler, | |
| 39 | 40 | ) *GlobalShortcutHandler { | |
| @@ -102,7 +103,7 @@ func NewGlobalShortcutHandler( | |||
| 102 | 103 | Str("action", string(ActionOpenSessionManager)). | |
| 103 | 104 | Msg("registered global shortcut") | |
| 104 | 105 | ||
| 105 | - | if cfg != nil { | |
| 106 | + | if workspace != nil { | |
| 106 | 107 | // Map action names to action constants for global shortcuts | |
| 107 | 108 | actionMap := map[string]Action{ | |
| 108 | 109 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| @@ -113,7 +114,7 @@ func NewGlobalShortcutHandler( | |||
| 113 | 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 | 118 | action, ok := actionMap[actionName] | |
| 118 | 119 | if !ok { | |
| 119 | 120 | continue | |
| @@ -134,7 +135,7 @@ func NewGlobalShortcutHandler( | |||
| 134 | 135 | for binding, action := range h.registered { | |
| 135 | 136 | occupied[binding] = action | |
| 136 | 137 | } | |
| 137 | - | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 138 | + | for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) { | |
| 138 | 139 | if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) { | |
| 139 | 140 | continue | |
| 140 | 141 | } | |
| @@ -148,7 +149,7 @@ func NewGlobalShortcutHandler( | |||
| 148 | 149 | ||
| 149 | 150 | // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.) | |
| 150 | 151 | // so they work even when WebView has focus. | |
| 151 | - | shortcuts := NewShortcutSet(ctx, cfg) | |
| 152 | + | shortcuts := NewShortcutSet(ctx, workspace, session) | |
| 152 | 153 | for binding, action := range shortcuts.Global { | |
| 153 | 154 | if _, exists := h.registered[binding]; exists { | |
| 154 | 155 | continue | |
| @@ -249,7 +250,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 249 | 250 | // ReloadShortcuts rebuilds global shortcuts from a new config. | |
| 250 | 251 | // It replaces the GTK shortcut controller to ensure stale shortcuts | |
| 251 | 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 | 254 | log := logging.FromContext(ctx) | |
| 254 | 255 | ||
| 255 | 256 | if h.window == nil || h.controller == nil { | |
| @@ -283,7 +284,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config | |||
| 283 | 284 | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |
| 284 | 285 | ||
| 285 | 286 | // Re-register config-driven shortcuts | |
| 286 | - | if cfg != nil { | |
| 287 | + | if workspace != nil { | |
| 287 | 288 | actionMap := map[string]Action{ | |
| 288 | 289 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 289 | 290 | "toggle-floating-pane": ActionToggleFloatingPane, | |
| @@ -293,7 +294,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config | |||
| 293 | 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 | 298 | action, ok := actionMap[actionName] | |
| 298 | 299 | if !ok { | |
| 299 | 300 | continue | |
| @@ -311,7 +312,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config | |||
| 311 | 312 | for binding, action := range h.registered { | |
| 312 | 313 | occupied[binding] = action | |
| 313 | 314 | } | |
| 314 | - | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 315 | + | for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) { | |
| 315 | 316 | if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) { | |
| 316 | 317 | if url, ok := ParseFloatingProfileAction(shortcut.Action); ok { | |
| 317 | 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 | 324 | for binding, action := range shortcuts.Global { | |
| 324 | 325 | if _, exists := h.registered[binding]; exists { | |
| 325 | 326 | 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 shortcuts | 107 | // 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 | continue | 120 | continue | ||
| @@ -134,7 +135,7 @@ func NewGlobalShortcutHandler( | |||||
| 134 | for binding, action := range h.registered { | 135 | for binding, action := range h.registered { | ||
| 135 | occupied[binding] = action | 136 | 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 | continue | 140 | 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 | continue | 155 | 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 shortcuts | 251 | // 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 shortcuts | 286 | // 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 | continue | 300 | 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] = action | 313 | 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 | continue | 326 | continue | ||
M internal/ui/input/keyboard.go +52 -27
| @@ -23,7 +23,7 @@ import ( | |||
| 23 | 23 | "sync" | |
| 24 | 24 | "time" | |
| 25 | 25 | ||
| 26 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 26 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 27 | 27 | "github.com/bnema/dumber/internal/logging" | |
| 28 | 28 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 29 | 29 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -55,7 +55,8 @@ type AccentHandler interface { | |||
| 55 | 55 | type KeyboardHandler struct { | |
| 56 | 56 | shortcuts *ShortcutSet | |
| 57 | 57 | modal *ModalState | |
| 58 | - | cfg *config.Config | |
| 58 | + | workspace *entity.WorkspaceConfig | |
| 59 | + | session *entity.SessionConfig | |
| 59 | 60 | ||
| 60 | 61 | // Action handler callback | |
| 61 | 62 | onAction ActionHandler | |
| @@ -80,15 +81,16 @@ type KeyboardHandler struct { | |||
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | 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 | 85 | log := logging.FromContext(ctx) | |
| 85 | 86 | ||
| 86 | 87 | log.Debug().Msg("creating keyboard handler") | |
| 87 | 88 | ||
| 88 | 89 | h := &KeyboardHandler{ | |
| 89 | - | shortcuts: NewShortcutSet(ctx, cfg), | |
| 90 | + | shortcuts: NewShortcutSet(ctx, workspace, session), | |
| 90 | 91 | modal: NewModalState(ctx), | |
| 91 | - | cfg: cfg, | |
| 92 | + | workspace: workspace, | |
| 93 | + | session: session, | |
| 92 | 94 | ctx: ctx, | |
| 93 | 95 | } | |
| 94 | 96 | ||
| @@ -102,17 +104,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||
| 102 | 104 | 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 | 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 | 110 | h.mu.Lock() | |
| 109 | 111 | defer h.mu.Unlock() | |
| 110 | 112 | ||
| 111 | 113 | log := logging.FromContext(ctx) | |
| 112 | 114 | log.Debug().Msg("reloading shortcuts from config") | |
| 113 | 115 | ||
| 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 | |
| 116 | 119 | } | |
| 117 | 120 | ||
| 118 | 121 | // SetOnModeChange sets the callback for mode changes (for UI updates). | |
| @@ -433,29 +436,42 @@ func isResizeAction(action Action) bool { | |||
| 433 | 436 | ||
| 434 | 437 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |
| 435 | 438 | h.mu.RLock() | |
| 436 | - | cfg := h.cfg | |
| 439 | + | workspace := h.workspace | |
| 440 | + | session := h.session | |
| 437 | 441 | h.mu.RUnlock() | |
| 438 | 442 | ||
| 439 | 443 | switch action { | |
| 440 | 444 | 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) | |
| 443 | 450 | return true | |
| 444 | 451 | 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) | |
| 447 | 457 | return true | |
| 448 | 458 | 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) | |
| 451 | 464 | return true | |
| 452 | 465 | case ActionEnterResizeMode: | |
| 453 | 466 | if h.modal.Mode() == ModeResize { | |
| 454 | 467 | h.modal.ExitMode(h.ctx) | |
| 455 | 468 | return true | |
| 456 | 469 | } | |
| 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) | |
| 459 | 475 | return true | |
| 460 | 476 | case ActionExitMode: | |
| 461 | 477 | h.modal.ExitMode(h.ctx) | |
| @@ -469,30 +485,39 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 469 | 485 | // Useful for testing or programmatic mode changes. | |
| 470 | 486 | func (h *KeyboardHandler) EnterTabMode() { | |
| 471 | 487 | h.mu.RLock() | |
| 472 | - | cfg := h.cfg | |
| 488 | + | workspace := h.workspace | |
| 473 | 489 | 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) | |
| 476 | 495 | } | |
| 477 | 496 | ||
| 478 | 497 | // EnterPaneMode programmatically enters pane mode. | |
| 479 | 498 | // Useful for testing or programmatic mode changes. | |
| 480 | 499 | func (h *KeyboardHandler) EnterPaneMode() { | |
| 481 | 500 | h.mu.RLock() | |
| 482 | - | cfg := h.cfg | |
| 501 | + | workspace := h.workspace | |
| 483 | 502 | 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) | |
| 486 | 508 | } | |
| 487 | 509 | ||
| 488 | 510 | // EnterSessionMode programmatically enters session mode. | |
| 489 | 511 | // Useful for testing or programmatic mode changes. | |
| 490 | 512 | func (h *KeyboardHandler) EnterSessionMode() { | |
| 491 | 513 | h.mu.RLock() | |
| 492 | - | cfg := h.cfg | |
| 514 | + | session := h.session | |
| 493 | 515 | 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) | |
| 496 | 521 | } | |
| 497 | 522 | ||
| 498 | 523 | // 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 *ShortcutSet | 56 | shortcuts *ShortcutSet | ||
| 57 | modal *ModalState | 57 | modal *ModalState | ||
| 58 | - | cfg *config.Config | 58 | + | workspace *entity.WorkspaceConfig |
| 59 | + | session *entity.SessionConfig | |||
| 59 | 60 | ||||
| 60 | // Action handler callback | 61 | // Action handler callback | ||
| 61 | onAction ActionHandler | 62 | 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 = fn | 104 | 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 = cfg | 117 | + | 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.cfg | 439 | + | 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.Millisecond | 445 | + | 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 true | 450 | return true | ||
| 444 | case ActionEnterPaneMode: | 451 | case ActionEnterPaneMode: | ||
| 445 | - | timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | 452 | + | 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 true | 457 | return true | ||
| 448 | case ActionEnterSessionMode: | 458 | case ActionEnterSessionMode: | ||
| 449 | - | timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | 459 | + | 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 true | 464 | 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 true | 468 | return true | ||
| 456 | } | 469 | } | ||
| 457 | - | timeout := time.Duration(cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond | 470 | + | 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 true | 475 | 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.cfg | 488 | + | workspace := h.workspace |
| 473 | h.mu.RUnlock() | 489 | h.mu.RUnlock() | ||
| 474 | - | timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | 490 | + | 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.cfg | 501 | + | workspace := h.workspace |
| 483 | h.mu.RUnlock() | 502 | h.mu.RUnlock() | ||
| 484 | - | timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | 503 | + | 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.cfg | 514 | + | session := h.session |
| 493 | h.mu.RUnlock() | 515 | h.mu.RUnlock() | ||
| 494 | - | timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | 516 | + | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | 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 | 13 | // 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 | + | } | |
| 21 | 27 | } | |
| 22 | 28 | ||
| 23 | 29 | // stubAccentHandler is a test stub for the AccentHandler interface. | |
| @@ -108,9 +114,8 @@ func TestIsTextInputKey(t *testing.T) { | |||
| 108 | 114 | ||
| 109 | 115 | func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |
| 110 | 116 | ctx := context.Background() | |
| 111 | - | cfg := newTestConfig() | |
| 112 | 117 | ||
| 113 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 118 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 114 | 119 | ||
| 115 | 120 | // Route all non-shortcut keys to widget (simulates WebView text input) | |
| 116 | 121 | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| @@ -139,9 +144,8 @@ func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |||
| 139 | 144 | ||
| 140 | 145 | func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |
| 141 | 146 | ctx := context.Background() | |
| 142 | - | cfg := newTestConfig() | |
| 143 | 147 | ||
| 144 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 148 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 145 | 149 | ||
| 146 | 150 | // Set a stub accent handler that always returns false (simulates "no accents for this key") | |
| 147 | 151 | h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false}) | |
| @@ -158,9 +162,8 @@ func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |||
| 158 | 162 | ||
| 159 | 163 | func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |
| 160 | 164 | ctx := context.Background() | |
| 161 | - | cfg := newTestConfig() | |
| 162 | 165 | ||
| 163 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 166 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 164 | 167 | ||
| 165 | 168 | h.SetOnAction(func(ctx context.Context, action Action) error { | |
| 166 | 169 | return nil | |
| @@ -181,9 +184,8 @@ func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |||
| 181 | 184 | ||
| 182 | 185 | func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |
| 183 | 186 | ctx := context.Background() | |
| 184 | - | cfg := newTestConfig() | |
| 185 | 187 | ||
| 186 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 188 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 187 | 189 | h.EnterTabMode() | |
| 188 | 190 | ||
| 189 | 191 | // Even if routeKey says pass to widget, modal mode ignores it | |
| @@ -198,8 +200,7 @@ func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |||
| 198 | 200 | ||
| 199 | 201 | func TestKeyboardHandler_SetRouteKey(t *testing.T) { | |
| 200 | 202 | ctx := context.Background() | |
| 201 | - | cfg := &config.Config{} | |
| 202 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 203 | + | h := NewKeyboardHandler(ctx, nil, nil) | |
| 203 | 204 | ||
| 204 | 205 | // Default: no callback set | |
| 205 | 206 | h.mu.RLock() | |
| @@ -221,9 +222,8 @@ func TestKeyboardHandler_SetRouteKey(t *testing.T) { | |||
| 221 | 222 | ||
| 222 | 223 | func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |
| 223 | 224 | ctx := context.Background() | |
| 224 | - | cfg := newTestConfig() | |
| 225 | 225 | ||
| 226 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 226 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 227 | 227 | ||
| 228 | 228 | // No routeKey set -- should default to shortcut handling | |
| 229 | 229 | // '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 cfg | 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 | + | } | |||
| 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 nil | 169 | 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 it | 191 | // 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 set | 205 | // 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 handling | 228 | // No routeKey set -- should default to shortcut handling | ||
| 229 | // 'z' is not a registered shortcut in ModeNormal, so returns false | 229 | // 'z' is not a registered shortcut in ModeNormal, so returns false | ||
M internal/ui/input/shortcuts.go +60 -46
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "sort" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 12 | 12 | ) | |
| @@ -218,8 +218,9 @@ type ShortcutSet struct { | |||
| 218 | 218 | ResizeMode ShortcutTable | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 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 { | |
| 223 | 224 | log := logging.FromContext(ctx) | |
| 224 | 225 | set := &ShortcutSet{ | |
| 225 | 226 | Global: make(ShortcutTable), | |
| @@ -229,11 +230,15 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||
| 229 | 230 | ResizeMode: make(ShortcutTable), | |
| 230 | 231 | } | |
| 231 | 232 | ||
| 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 | + | } | |
| 237 | 242 | ||
| 238 | 243 | log.Debug(). | |
| 239 | 244 | Int("global", len(set.Global)). | |
| @@ -246,81 +251,89 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||
| 246 | 251 | return set | |
| 247 | 252 | } | |
| 248 | 253 | ||
| 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) | |
| 253 | 258 | s.registerStandardShortcuts() | |
| 254 | 259 | s.registerPaneNavigationShortcuts() | |
| 255 | 260 | s.registerTabSwitchShortcuts() | |
| 256 | - | s.registerFloatingProfileShortcuts(ctx, cfg) | |
| 261 | + | s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace) | |
| 257 | 262 | } | |
| 258 | 263 | ||
| 259 | 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 | 266 | s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab") | |
| 262 | 267 | } | |
| 263 | 268 | ||
| 264 | 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 | 271 | s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane") | |
| 267 | 272 | } | |
| 268 | 273 | ||
| 269 | 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 | 276 | s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session") | |
| 272 | 277 | } | |
| 273 | 278 | ||
| 274 | 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 | 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 | 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 | 290 | s.Global[binding] = ActionEnterTabMode | |
| 283 | 291 | log.Trace(). | |
| 284 | - | Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut). | |
| 292 | + | Str("shortcut", workspace.TabMode.ActivationShortcut). | |
| 285 | 293 | Uint("keyval", binding.Keyval). | |
| 286 | 294 | Uint("mod", uint(binding.Modifiers)). | |
| 287 | 295 | Msg("tab mode activation registered") | |
| 288 | 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 | 300 | s.Global[binding] = ActionEnterPaneMode | |
| 293 | 301 | log.Trace(). | |
| 294 | - | Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut). | |
| 302 | + | Str("shortcut", workspace.PaneMode.ActivationShortcut). | |
| 295 | 303 | Uint("keyval", binding.Keyval). | |
| 296 | 304 | Uint("mod", uint(binding.Modifiers)). | |
| 297 | 305 | Msg("pane mode activation registered") | |
| 298 | 306 | } 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 | + | } | |
| 310 | 320 | } | |
| 311 | - | if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok { | |
| 321 | + | if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok { | |
| 312 | 322 | s.Global[binding] = ActionEnterResizeMode | |
| 313 | 323 | log.Trace(). | |
| 314 | - | Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut). | |
| 324 | + | Str("shortcut", workspace.ResizeMode.ActivationShortcut). | |
| 315 | 325 | Uint("keyval", binding.Keyval). | |
| 316 | 326 | Uint("mod", uint(binding.Modifiers)). | |
| 317 | 327 | Msg("resize mode activation registered") | |
| 318 | 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 | 337 | // Note: Ctrl+T is NOT registered globally - it enters tab mode. | |
| 325 | 338 | // In tab mode, use: | |
| 326 | 339 | // 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 | 374 | occupied := make(map[KeyBinding]Action, len(s.Global)) | |
| 362 | 375 | for binding, action := range s.Global { | |
| 363 | 376 | occupied[binding] = action | |
| 364 | 377 | } | |
| 365 | 378 | ||
| 366 | - | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 379 | + | for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) { | |
| 367 | 380 | s.Global[shortcut.Binding] = shortcut.Action | |
| 368 | 381 | } | |
| 369 | 382 | } | |
| @@ -373,26 +386,27 @@ type floatingProfileShortcut struct { | |||
| 373 | 386 | Action Action | |
| 374 | 387 | } | |
| 375 | 388 | ||
| 376 | - | func collectFloatingProfileShortcuts( | |
| 389 | + | // collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config. | |
| 390 | + | func collectFloatingProfileShortcutsFromWorkspace( | |
| 377 | 391 | ctx context.Context, | |
| 378 | - | cfg *config.Config, | |
| 392 | + | workspace *entity.WorkspaceConfig, | |
| 379 | 393 | occupied map[KeyBinding]Action, | |
| 380 | 394 | ) []floatingProfileShortcut { | |
| 381 | - | if cfg == nil { | |
| 395 | + | if workspace == nil { | |
| 382 | 396 | return nil | |
| 383 | 397 | } | |
| 384 | 398 | ||
| 385 | 399 | log := logging.FromContext(ctx) | |
| 386 | 400 | reserveGlobalOnlyShortcutBindings(occupied) | |
| 387 | 401 | 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 { | |
| 390 | 404 | profileNames = append(profileNames, profileName) | |
| 391 | 405 | } | |
| 392 | 406 | sort.Strings(profileNames) | |
| 393 | 407 | ||
| 394 | 408 | for _, profileName := range profileNames { | |
| 395 | - | profile := cfg.Workspace.FloatingPane.Profiles[profileName] | |
| 409 | + | profile := workspace.FloatingPane.Profiles[profileName] | |
| 396 | 410 | url := strings.TrimSpace(profile.URL) | |
| 397 | 411 | if url == "" { | |
| 398 | 412 | 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 ShortcutTable | 218 | 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 set | 251 | 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] = ActionEnterTabMode | 290 | 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] = ActionEnterPaneMode | 300 | 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] = ActionEnterSessionMode | 310 | + | 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] = ActionEnterResizeMode | 322 | 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 tab | 339 | // 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] = action | 376 | 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.Action | 380 | s.Global[shortcut.Binding] = shortcut.Action | ||
| 368 | } | 381 | } | ||
| 369 | } | 382 | } | ||
| @@ -373,26 +386,27 @@ type floatingProfileShortcut struct { | |||||
| 373 | Action Action | 386 | 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 nil | 396 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 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 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| @@ -26,13 +26,23 @@ type Manager struct { | |||
| 26 | 26 | ||
| 27 | 27 | // NewManager creates a new theme manager from configuration. | |
| 28 | 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 | 40 | log := logging.FromContext(ctx) | |
| 31 | 41 | ||
| 32 | 42 | // Determine color scheme preference | |
| 33 | 43 | scheme := "system" | |
| 34 | - | if cfg != nil && cfg.Appearance.ColorScheme != "" { | |
| 35 | - | scheme = cfg.Appearance.ColorScheme | |
| 44 | + | if appearance != nil && appearance.ColorScheme != "" { | |
| 45 | + | scheme = appearance.ColorScheme | |
| 36 | 46 | } | |
| 37 | 47 | ||
| 38 | 48 | // 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 | 52 | // Build palettes from config or defaults | |
| 43 | 53 | 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) | |
| 47 | 57 | } else { | |
| 48 | 58 | lightPalette = DefaultLightPalette() | |
| 49 | 59 | darkPalette = DefaultDarkPalette() | |
| 50 | 60 | } | |
| 51 | 61 | ||
| 52 | 62 | // 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 | |
| 56 | 65 | } | |
| 57 | 66 | ||
| 58 | 67 | 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) | |
| 62 | 71 | } | |
| 63 | 72 | ||
| 64 | 73 | // Build mode colors from config | |
| 65 | - | modeColors := modeColorsFromConfig(cfg) | |
| 74 | + | modeColors := modeColorsFromStyling(styling) | |
| 66 | 75 | ||
| 67 | 76 | log.Debug(). | |
| 68 | 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. | |
| 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 { | |
| 89 | 98 | defaults := DefaultModeColors() | |
| 90 | - | if cfg == nil { | |
| 99 | + | if styling == nil { | |
| 91 | 100 | return defaults | |
| 92 | 101 | } | |
| 93 | - | styling := &cfg.Workspace.Styling | |
| 94 | 102 | return ModeColors{ | |
| 95 | 103 | PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode), | |
| 96 | 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. | |
| 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 | + | ) { | |
| 203 | 221 | log := logging.FromContext(ctx) | |
| 204 | 222 | ||
| 205 | - | if cfg == nil { | |
| 223 | + | if appearance == nil { | |
| 206 | 224 | return | |
| 207 | 225 | } | |
| 208 | 226 | ||
| 209 | 227 | // Update scheme and resolve prefersDark via resolver | |
| 210 | 228 | scheme := "system" | |
| 211 | - | if cfg.Appearance.ColorScheme != "" { | |
| 212 | - | scheme = cfg.Appearance.ColorScheme | |
| 229 | + | if appearance.ColorScheme != "" { | |
| 230 | + | scheme = appearance.ColorScheme | |
| 213 | 231 | } | |
| 214 | 232 | m.scheme = scheme | |
| 215 | 233 | pref := m.colorResolver.Refresh() | |
| 216 | 234 | m.prefersDark = pref.PrefersDark | |
| 217 | 235 | ||
| 218 | 236 | // 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) | |
| 221 | 239 | ||
| 222 | 240 | // Update UI scale | |
| 223 | - | if cfg.DefaultUIScale > 0 { | |
| 224 | - | m.uiScale = cfg.DefaultUIScale | |
| 241 | + | if uiScale > 0 { | |
| 242 | + | m.uiScale = uiScale | |
| 225 | 243 | } | |
| 226 | 244 | ||
| 227 | 245 | defaults := DefaultFontConfig() | |
| 228 | 246 | 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), | |
| 231 | 249 | } | |
| 232 | 250 | ||
| 233 | 251 | // Update mode colors | |
| 234 | - | m.modeColors = modeColorsFromConfig(cfg) | |
| 252 | + | if styling != nil { | |
| 253 | + | m.modeColors = modeColorsFromStyling(styling) | |
| 254 | + | } | |
| 235 | 255 | ||
| 236 | 256 | log.Info(). | |
| 237 | 257 | 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 preference | 42 | // 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.ColorScheme | 45 | + | scheme = appearance.ColorScheme |
| 36 | } | 46 | } | ||
| 37 | 47 | ||||
| 38 | // Resolve whether we should use dark mode via resolver | 48 | // 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 defaults | 52 | // Build palettes from config or defaults | ||
| 43 | var lightPalette, darkPalette Palette | 53 | 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.0 | 63 | + | 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 config | 73 | // 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 defaults | 100 | 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 | return | 224 | return | ||
| 207 | } | 225 | } | ||
| 208 | 226 | ||||
| 209 | // Update scheme and resolve prefersDark via resolver | 227 | // 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.ColorScheme | 230 | + | scheme = appearance.ColorScheme |
| 213 | } | 231 | } | ||
| 214 | m.scheme = scheme | 232 | m.scheme = scheme | ||
| 215 | pref := m.colorResolver.Refresh() | 233 | pref := m.colorResolver.Refresh() | ||
| 216 | m.prefersDark = pref.PrefersDark | 234 | m.prefersDark = pref.PrefersDark | ||
| 217 | 235 | ||||
| 218 | // Update palettes | 236 | // 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 scale | 240 | // Update UI scale | ||
| 223 | - | if cfg.DefaultUIScale > 0 { | 241 | + | if uiScale > 0 { |
| 224 | - | m.uiScale = cfg.DefaultUIScale | 242 | + | 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 colors | 251 | // 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 ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 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 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestNewManager_DarkMode(t *testing.T) { | |
| 15 | 15 | ctx := context.Background() | |
| 16 | - | cfg := &config.Config{ | |
| 17 | - | Appearance: config.AppearanceConfig{ | |
| 18 | - | ColorScheme: "default", | |
| 19 | - | }, | |
| 16 | + | appearance := &entity.AppearanceConfig{ | |
| 17 | + | ColorScheme: "default", | |
| 20 | 18 | } | |
| 21 | 19 | ||
| 22 | 20 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -25,7 +23,7 @@ func TestNewManager_DarkMode(t *testing.T) { | |||
| 25 | 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 | 28 | assert.True(t, manager.PrefersDark()) | |
| 31 | 29 | assert.Equal(t, "default", manager.scheme) | |
| @@ -33,10 +31,8 @@ func TestNewManager_DarkMode(t *testing.T) { | |||
| 33 | 31 | ||
| 34 | 32 | func TestNewManager_LightMode(t *testing.T) { | |
| 35 | 33 | ctx := context.Background() | |
| 36 | - | cfg := &config.Config{ | |
| 37 | - | Appearance: config.AppearanceConfig{ | |
| 38 | - | ColorScheme: "default", | |
| 39 | - | }, | |
| 34 | + | appearance := &entity.AppearanceConfig{ | |
| 35 | + | ColorScheme: "default", | |
| 40 | 36 | } | |
| 41 | 37 | ||
| 42 | 38 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -45,7 +41,7 @@ func TestNewManager_LightMode(t *testing.T) { | |||
| 45 | 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 | 46 | assert.False(t, manager.PrefersDark()) | |
| 51 | 47 | } | |
| @@ -59,7 +55,7 @@ func TestNewManager_WithNilConfig(t *testing.T) { | |||
| 59 | 55 | Source: "fallback", | |
| 60 | 56 | }) | |
| 61 | 57 | ||
| 62 | - | manager := NewManager(ctx, nil, mockResolver) | |
| 58 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 63 | 59 | ||
| 64 | 60 | require.NotNil(t, manager) | |
| 65 | 61 | assert.True(t, manager.PrefersDark()) | |
| @@ -68,10 +64,8 @@ func TestNewManager_WithNilConfig(t *testing.T) { | |||
| 68 | 64 | ||
| 69 | 65 | func TestNewManager_UsesConfigScheme(t *testing.T) { | |
| 70 | 66 | ctx := context.Background() | |
| 71 | - | cfg := &config.Config{ | |
| 72 | - | Appearance: config.AppearanceConfig{ | |
| 73 | - | ColorScheme: "prefer-dark", | |
| 74 | - | }, | |
| 67 | + | appearance := &entity.AppearanceConfig{ | |
| 68 | + | ColorScheme: "prefer-dark", | |
| 75 | 69 | } | |
| 76 | 70 | ||
| 77 | 71 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -80,14 +74,13 @@ func TestNewManager_UsesConfigScheme(t *testing.T) { | |||
| 80 | 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 | 79 | assert.Equal(t, "prefer-dark", manager.scheme) | |
| 86 | 80 | } | |
| 87 | 81 | ||
| 88 | 82 | func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |
| 89 | 83 | ctx := context.Background() | |
| 90 | - | cfg := &config.Config{} | |
| 91 | 84 | ||
| 92 | 85 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 93 | 86 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -95,7 +88,7 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |||
| 95 | 88 | Source: "test", | |
| 96 | 89 | }) | |
| 97 | 90 | ||
| 98 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 91 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 99 | 92 | palette := manager.GetCurrentPalette() | |
| 100 | 93 | ||
| 101 | 94 | // Dark palette should be returned | |
| @@ -104,7 +97,6 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |||
| 104 | 97 | ||
| 105 | 98 | func TestManager_GetCurrentPalette_Light(t *testing.T) { | |
| 106 | 99 | ctx := context.Background() | |
| 107 | - | cfg := &config.Config{} | |
| 108 | 100 | ||
| 109 | 101 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 110 | 102 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -112,7 +104,7 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) { | |||
| 112 | 104 | Source: "test", | |
| 113 | 105 | }) | |
| 114 | 106 | ||
| 115 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 107 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 116 | 108 | palette := manager.GetCurrentPalette() | |
| 117 | 109 | ||
| 118 | 110 | // Light palette should be returned | |
| @@ -121,7 +113,6 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) { | |||
| 121 | 113 | ||
| 122 | 114 | func TestManager_SetColorScheme(t *testing.T) { | |
| 123 | 115 | ctx := context.Background() | |
| 124 | - | cfg := &config.Config{} | |
| 125 | 116 | ||
| 126 | 117 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 127 | 118 | // Initial resolve call | |
| @@ -135,7 +126,7 @@ func TestManager_SetColorScheme(t *testing.T) { | |||
| 135 | 126 | Source: "config", | |
| 136 | 127 | }) | |
| 137 | 128 | ||
| 138 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 129 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 139 | 130 | assert.True(t, manager.PrefersDark()) | |
| 140 | 131 | ||
| 141 | 132 | // Change scheme - this calls Refresh on resolver | |
| @@ -147,10 +138,8 @@ func TestManager_SetColorScheme(t *testing.T) { | |||
| 147 | 138 | ||
| 148 | 139 | func TestManager_UpdateFromConfig(t *testing.T) { | |
| 149 | 140 | ctx := context.Background() | |
| 150 | - | cfg := &config.Config{ | |
| 151 | - | Appearance: config.AppearanceConfig{ | |
| 152 | - | ColorScheme: "default", | |
| 153 | - | }, | |
| 141 | + | appearance := &entity.AppearanceConfig{ | |
| 142 | + | ColorScheme: "default", | |
| 154 | 143 | } | |
| 155 | 144 | ||
| 156 | 145 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -165,16 +154,14 @@ func TestManager_UpdateFromConfig(t *testing.T) { | |||
| 165 | 154 | Source: "config", | |
| 166 | 155 | }) | |
| 167 | 156 | ||
| 168 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 157 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 169 | 158 | assert.True(t, manager.PrefersDark()) | |
| 170 | 159 | ||
| 171 | 160 | // 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", | |
| 176 | 163 | } | |
| 177 | - | manager.UpdateFromConfig(ctx, newCfg, nil) | |
| 164 | + | manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil) | |
| 178 | 165 | ||
| 179 | 166 | assert.False(t, manager.PrefersDark()) | |
| 180 | 167 | assert.Equal(t, "prefer-light", manager.scheme) | |
| @@ -182,7 +169,6 @@ func TestManager_UpdateFromConfig(t *testing.T) { | |||
| 182 | 169 | ||
| 183 | 170 | func TestManager_UpdateFromConfig_NilConfig(t *testing.T) { | |
| 184 | 171 | ctx := context.Background() | |
| 185 | - | cfg := &config.Config{} | |
| 186 | 172 | ||
| 187 | 173 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 188 | 174 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -190,18 +176,17 @@ func TestManager_UpdateFromConfig_NilConfig(t *testing.T) { | |||
| 190 | 176 | Source: "test", | |
| 191 | 177 | }) | |
| 192 | 178 | ||
| 193 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 179 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 194 | 180 | initialScheme := manager.scheme | |
| 195 | 181 | ||
| 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) | |
| 198 | 184 | ||
| 199 | 185 | assert.Equal(t, initialScheme, manager.scheme) | |
| 200 | 186 | } | |
| 201 | 187 | ||
| 202 | 188 | func TestManager_GetWebUIThemeCSS(t *testing.T) { | |
| 203 | 189 | ctx := context.Background() | |
| 204 | - | cfg := &config.Config{} | |
| 205 | 190 | ||
| 206 | 191 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 207 | 192 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -209,7 +194,7 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) { | |||
| 209 | 194 | Source: "test", | |
| 210 | 195 | }) | |
| 211 | 196 | ||
| 212 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 197 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 213 | 198 | css := manager.GetWebUIThemeCSS() | |
| 214 | 199 | ||
| 215 | 200 | // Should contain both light and dark variables | |
| @@ -219,7 +204,6 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) { | |||
| 219 | 204 | ||
| 220 | 205 | func TestManager_GetBackgroundRGBA(t *testing.T) { | |
| 221 | 206 | ctx := context.Background() | |
| 222 | - | cfg := &config.Config{} | |
| 223 | 207 | ||
| 224 | 208 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 225 | 209 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -227,7 +211,7 @@ func TestManager_GetBackgroundRGBA(t *testing.T) { | |||
| 227 | 211 | Source: "test", | |
| 228 | 212 | }) | |
| 229 | 213 | ||
| 230 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 214 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 231 | 215 | r, g, b, a := manager.GetBackgroundRGBA() | |
| 232 | 216 | ||
| 233 | 217 | // Should return valid RGBA values (0-1 range) | |
| @@ -243,16 +227,14 @@ func TestManager_GetBackgroundRGBA(t *testing.T) { | |||
| 243 | 227 | ||
| 244 | 228 | func TestManager_CustomPalettes(t *testing.T) { | |
| 245 | 229 | 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", | |
| 256 | 238 | }, | |
| 257 | 239 | } | |
| 258 | 240 | ||
| @@ -262,7 +244,7 @@ func TestManager_CustomPalettes(t *testing.T) { | |||
| 262 | 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 | 249 | lightPalette := manager.GetLightPalette() | |
| 268 | 250 | darkPalette := manager.GetDarkPalette() | |
| @@ -275,9 +257,6 @@ func TestManager_CustomPalettes(t *testing.T) { | |||
| 275 | 257 | ||
| 276 | 258 | func TestManager_UIScale(t *testing.T) { | |
| 277 | 259 | ctx := context.Background() | |
| 278 | - | cfg := &config.Config{ | |
| 279 | - | DefaultUIScale: 1.5, | |
| 280 | - | } | |
| 281 | 260 | ||
| 282 | 261 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 283 | 262 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -285,7 +264,7 @@ func TestManager_UIScale(t *testing.T) { | |||
| 285 | 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 | 269 | // UI scale should be stored (we can't directly access it, but we can verify | |
| 291 | 270 | // the manager was created without error) | |
| @@ -294,11 +273,9 @@ func TestManager_UIScale(t *testing.T) { | |||
| 294 | 273 | ||
| 295 | 274 | func TestManager_Fonts(t *testing.T) { | |
| 296 | 275 | 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", | |
| 302 | 279 | } | |
| 303 | 280 | ||
| 304 | 281 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -307,7 +284,7 @@ func TestManager_Fonts(t *testing.T) { | |||
| 307 | 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 | 289 | // Fonts should be stored (manager creation should succeed) | |
| 313 | 290 | require.NotNil(t, manager) | |
| @@ -315,15 +292,11 @@ func TestManager_Fonts(t *testing.T) { | |||
| 315 | 292 | ||
| 316 | 293 | func TestManager_ModeColors(t *testing.T) { | |
| 317 | 294 | 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", | |
| 327 | 300 | } | |
| 328 | 301 | ||
| 329 | 302 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -332,7 +305,7 @@ func TestManager_ModeColors(t *testing.T) { | |||
| 332 | 305 | Source: "test", | |
| 333 | 306 | }) | |
| 334 | 307 | ||
| 335 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 308 | + | manager := NewManager(ctx, nil, 1.0, styling, mockResolver) | |
| 336 | 309 | modeColors := manager.GetModeColors() | |
| 337 | 310 | ||
| 338 | 311 | 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 returned | 94 | // 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 returned | 110 | // 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 call | 118 | // 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 resolver | 132 | // 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 config | 160 | // 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.scheme | 180 | initialScheme := manager.scheme | ||
| 195 | 181 | ||||
| 196 | - | // UpdateFromConfig with nil should be a no-op | 182 | + | // 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 variables | 200 | // 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 verify | 269 | // 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 ( | |||
| 6 | 6 | "regexp" | |
| 7 | 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 | 12 | // Palette holds semantic color tokens for theming. | |
| @@ -85,7 +85,7 @@ func DefaultLightPalette() Palette { | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 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 | 89 | var defaults Palette | |
| 90 | 90 | if isDark { | |
| 91 | 91 | 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 Palette | 89 | 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 | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 8 | "github.com/bnema/dumber/internal/ui/component" | |
| 10 | 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| @@ -26,31 +25,29 @@ type MainWindow struct { | |||
| 26 | 25 | contentArea *gtk.Box // Container for workspace content | |
| 27 | 26 | currentContent *gtk.Widget // Track current content for removal on tab switch | |
| 28 | 27 | ||
| 29 | - | cfg *config.Config | |
| 30 | - | logger zerolog.Logger | |
| 28 | + | tabBarPosition string // "top" or "bottom" | |
| 29 | + | logger zerolog.Logger | |
| 31 | 30 | } | |
| 32 | 31 | ||
| 33 | 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 | 35 | log := logging.FromContext(ctx) | |
| 36 | 36 | ||
| 37 | 37 | 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(), | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | // Create the application window | |
| 43 | 42 | mw.window = gtk.NewApplicationWindow(app) | |
| 44 | 43 | if mw.window == nil { | |
| 45 | 44 | return nil, ErrWindowCreationFailed | |
| 46 | 45 | } | |
| 47 | 46 | ||
| 48 | - | // Configure window | |
| 49 | 47 | title := windowTitle | |
| 50 | 48 | mw.window.SetTitle(&title) | |
| 51 | 49 | mw.window.SetDefaultSize(defaultWidth, defaultHeight) | |
| 52 | 50 | ||
| 53 | - | // Create root container (vertical box) | |
| 54 | 51 | mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 55 | 52 | if mw.rootBox == nil { | |
| 56 | 53 | mw.window.Unref() | |
| @@ -60,7 +57,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 60 | 57 | mw.rootBox.SetVexpand(true) | |
| 61 | 58 | mw.rootBox.SetVisible(true) | |
| 62 | 59 | ||
| 63 | - | // Create tab bar | |
| 64 | 60 | mw.tabBar = component.NewTabBar() | |
| 65 | 61 | if mw.tabBar == nil { | |
| 66 | 62 | mw.rootBox.Unref() | |
| @@ -68,7 +64,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 68 | 64 | return nil, ErrWidgetCreationFailed("tabBar") | |
| 69 | 65 | } | |
| 70 | 66 | ||
| 71 | - | // Create content overlay (for omnibox and other overlays) | |
| 72 | 67 | mw.contentOverlay = gtk.NewOverlay() | |
| 73 | 68 | if mw.contentOverlay == nil { | |
| 74 | 69 | mw.tabBar.Destroy() | |
| @@ -80,7 +75,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 80 | 75 | mw.contentOverlay.SetVexpand(true) | |
| 81 | 76 | mw.contentOverlay.SetVisible(true) | |
| 82 | 77 | ||
| 83 | - | // Create content area | |
| 84 | 78 | mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 85 | 79 | if mw.contentArea == nil { | |
| 86 | 80 | mw.contentOverlay.Unref() | |
| @@ -94,31 +88,25 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 94 | 88 | mw.contentArea.SetVisible(true) | |
| 95 | 89 | mw.contentArea.AddCssClass("content-area") | |
| 96 | 90 | ||
| 97 | - | // Set content area as the main child of the overlay | |
| 98 | 91 | mw.contentOverlay.SetChild(&mw.contentArea.Widget) | |
| 99 | 92 | ||
| 100 | - | // Assemble layout based on tab bar position | |
| 101 | 93 | mw.assembleLayout() | |
| 102 | 94 | ||
| 103 | - | // Set root box as window content | |
| 104 | 95 | mw.window.SetChild(&mw.rootBox.Widget) | |
| 105 | 96 | ||
| 106 | 97 | return mw, nil | |
| 107 | 98 | } | |
| 108 | 99 | ||
| 109 | - | // assembleLayout arranges widgets based on configuration. | |
| 110 | 100 | func (mw *MainWindow) assembleLayout() { | |
| 111 | 101 | tabBarPos := "top" // default | |
| 112 | - | if mw.cfg != nil { | |
| 113 | - | tabBarPos = mw.cfg.Workspace.TabBarPosition | |
| 102 | + | if mw.tabBarPosition != "" { | |
| 103 | + | tabBarPos = mw.tabBarPosition | |
| 114 | 104 | } | |
| 115 | 105 | ||
| 116 | 106 | if tabBarPos == "bottom" { | |
| 117 | - | // Content first, then tab bar | |
| 118 | 107 | mw.rootBox.Append(&mw.contentOverlay.Widget) | |
| 119 | 108 | mw.rootBox.Append(mw.tabBar.Widget()) | |
| 120 | 109 | } else { | |
| 121 | - | // Tab bar first, then content (default: top) | |
| 122 | 110 | mw.rootBox.Append(mw.tabBar.Widget()) | |
| 123 | 111 | mw.rootBox.Append(&mw.contentOverlay.Widget) | |
| 124 | 112 | } | |
| @@ -128,7 +116,7 @@ func (mw *MainWindow) assembleLayout() { | |||
| 128 | 116 | Msg("window layout assembled") | |
| 129 | 117 | } | |
| 130 | 118 | ||
| 131 | - | // Show makes the window visible. | |
| 119 | + | // Show presents the window. | |
| 132 | 120 | func (mw *MainWindow) Show() { | |
| 133 | 121 | mw.window.Present() | |
| 134 | 122 | } | |
| @@ -138,26 +126,23 @@ func (mw *MainWindow) Close() { | |||
| 138 | 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 | 130 | func (mw *MainWindow) TabBar() *component.TabBar { | |
| 143 | 131 | return mw.tabBar | |
| 144 | 132 | } | |
| 145 | 133 | ||
| 146 | - | // ContentArea returns the content area box. | |
| 134 | + | // ContentArea returns the main content container widget. | |
| 147 | 135 | func (mw *MainWindow) ContentArea() *gtk.Box { | |
| 148 | 136 | return mw.contentArea | |
| 149 | 137 | } | |
| 150 | 138 | ||
| 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. | |
| 153 | 140 | func (mw *MainWindow) SetContent(widget *gtk.Widget) { | |
| 154 | - | // Remove existing content first | |
| 155 | 141 | if mw.currentContent != nil { | |
| 156 | 142 | mw.contentArea.Remove(mw.currentContent) | |
| 157 | 143 | mw.currentContent = nil | |
| 158 | 144 | } | |
| 159 | 145 | ||
| 160 | - | // Add new content | |
| 161 | 146 | if widget != nil { | |
| 162 | 147 | widget.SetVisible(true) | |
| 163 | 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 | 154 | func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |
| 170 | 155 | return mw.window | |
| 171 | 156 | } | |
| 172 | 157 | ||
| 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. | |
| 175 | 159 | func (mw *MainWindow) SetTitle(title string) { | |
| 176 | 160 | if mw.window == nil { | |
| 177 | 161 | return | |
| 178 | 162 | } | |
| 179 | - | // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8) | |
| 180 | 163 | const maxTitleLen = 255 | |
| 181 | 164 | runes := []rune(title) | |
| 182 | 165 | if len(runes) > maxTitleLen { | |
| @@ -185,21 +168,19 @@ func (mw *MainWindow) SetTitle(title string) { | |||
| 185 | 168 | mw.window.SetTitle(&title) | |
| 186 | 169 | } | |
| 187 | 170 | ||
| 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. | |
| 190 | 172 | func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |
| 191 | 173 | return mw.contentOverlay | |
| 192 | 174 | } | |
| 193 | 175 | ||
| 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. | |
| 196 | 177 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | |
| 197 | 178 | if mw.contentOverlay != nil && widget != nil { | |
| 198 | 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 | 184 | func (mw *MainWindow) Destroy() { | |
| 204 | 185 | if mw.tabBar != nil { | |
| 205 | 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 | 204 | type WindowError struct { | |
| 224 | 205 | Message string | |
| 225 | 206 | } | |
| 226 | 207 | ||
| 208 | + | // Error implements the error interface. | |
| 227 | 209 | func (e WindowError) Error() string { | |
| 228 | 210 | return e.Message | |
| 229 | 211 | } | |
| 230 | 212 | ||
| 231 | - | // Error constants. | |
| 232 | 213 | var ( | |
| 214 | + | // ErrWindowCreationFailed is returned when the GTK application window cannot be created. | |
| 233 | 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 | 219 | func ErrWidgetCreationFailed(name string) error { | |
| 238 | 220 | return WindowError{Message: "failed to create widget: " + name} | |
| 239 | 221 | } | |
| @@ -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 content | 25 | contentArea *gtk.Box // Container for workspace content | ||
| 27 | currentContent *gtk.Widget // Track current content for removal on tab switch | 26 | currentContent *gtk.Widget // Track current content for removal on tab switch | ||
| 28 | 27 | ||||
| 29 | - | cfg *config.Config | 28 | + | tabBarPosition string // "top" or "bottom" |
| 30 | - | logger zerolog.Logger | 29 | + | 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, ErrWindowCreationFailed | 44 | return nil, ErrWindowCreationFailed | ||
| 46 | } | 45 | } | ||
| 47 | 46 | ||||
| 48 | - | // Configure window | |||
| 49 | title := windowTitle | 47 | 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, nil | 97 | 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" // default | 101 | tabBarPos := "top" // default | ||
| 112 | - | if mw.cfg != nil { | 102 | + | if mw.tabBarPosition != "" { |
| 113 | - | tabBarPos = mw.cfg.Workspace.TabBarPosition | 103 | + | 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.tabBar | 131 | 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.contentArea | 136 | 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 = nil | 143 | 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.window | 155 | 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 | return | 161 | return | ||
| 178 | } | 162 | } | ||
| 179 | - | // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8) | |||
| 180 | const maxTitleLen = 255 | 163 | 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.contentOverlay | 173 | 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 string | 205 | 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.Message | 210 | 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 | } | ||