detached eb849fe

feat(ui): implement floating pane profiles with independent sessions

Add floating pane profile system allowing named sessions (e.g. alt+m
for gmail) that operate independently from the default floating pane.

Key changes:
- Profile config schema, migration, validation, and defaults
- Keyboard dispatch and shortcut parsing for profile targets
- Independent session lifecycle per profile (own webview from pool)
- Toggle behavior preserving in-session navigation state
- Opacity-based hide/show to retain WebKit GPU surface (no black flash)
- PaneView wrapping for floating panes enabling progress bar support
- SetOpacity added to layout Widget interface and all GTK wrappers
- Active pane override in content coordinator for floating focus
- Floating pane CSS theme and workspace overlay sizing

Known issue: profile sessions loading content-heavy pages (e.g. gmail)
expand to 100% workspace height due to GTK4 overlay natural size
allocation. Default session (about:blank) sizes correctly.
commit
eb849fe498435b7e28f59e08a63138ce13241ba9
author
brice · · 2026-02-09 09:03 UTC
parent
15ee0d7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
31 files · +2568 -66
M docs/config/index.md +46 -0
@@ -300,6 +300,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
300300
301301 | Action | Default Keys | Description |
302302 |--------|--------------|-------------|
303+| `toggle_floating_pane` | `alt+f` | Toggle the workspace floating pane (persistent session) |
303304 | `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |
304305 | `next_tab` | `ctrl+tab` | Switch to next tab |
305306 | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |
@@ -317,6 +318,10 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
317318 [workspace.shortcuts.actions.next_tab]
318319 keys = ["ctrl+tab", "alt+l"]
319320 desc = "Switch to next tab"
321+
322+[workspace.shortcuts.actions.toggle_floating_pane]
323+ keys = ["alt+f"]
324+ desc = "Toggle floating pane"
320325 ```
321326
322327 > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.
@@ -324,6 +329,47 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
324329 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
325330 >
326331 > **Note:** Consume-or-expel is experimental and may change behavior between releases.
332+>
333+> **Warning:** Some `Alt+<key>` shortcuts may conflict with WebKit defaults, website handlers, or your desktop environment.
334+> If a binding does not fire, rebind it to a different key.
335+
336+### Floating Pane
337+
338+The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.
339+
340+- Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle_floating_pane`).
341+- URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.
342+
343+| Key | Type | Default | Description |
344+|-----|------|---------|-------------|
345+| `workspace.floating_pane.width_pct` | float | `0.82` | Floating pane width as fraction of workspace width (`(0,1]`) |
346+| `workspace.floating_pane.height_pct` | float | `0.72` | Floating pane height as fraction of workspace height (`(0,1]`) |
347+| `workspace.floating_pane.profiles` | map | `{}` | Named URL profiles, each with `keys[]`, `url`, optional `desc` |
348+
349+**Example:**
350+
351+```toml
352+[workspace.shortcuts.actions.toggle_floating_pane]
353+keys = ["alt+f"]
354+desc = "Toggle floating pane"
355+
356+[workspace.floating_pane]
357+width_pct = 0.82
358+height_pct = 0.72
359+
360+[workspace.floating_pane.profiles]
361+# Empty by default. Add as many entries as needed.
362+
363+[workspace.floating_pane.profiles.google]
364+keys = ["alt+g"]
365+url = "https://google.com"
366+desc = "Open floating pane on Google"
367+
368+[workspace.floating_pane.profiles.github]
369+keys = ["alt+h"]
370+url = "https://github.com"
371+desc = "Open floating pane on GitHub"
372+```
327373
328374 ### Popup Behavior
329375
@@ -300,6 +300,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
300 300
301 | Action | Default Keys | Description |301 | Action | Default Keys | Description |
302 |--------|--------------|-------------|302 |--------|--------------|-------------|
303+| `toggle_floating_pane` | `alt+f` | Toggle the workspace floating pane (persistent session) |
303 | `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |304 | `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |
304 | `next_tab` | `ctrl+tab` | Switch to next tab |305 | `next_tab` | `ctrl+tab` | Switch to next tab |
305 | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |306 | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |
@@ -317,6 +318,10 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
317 [workspace.shortcuts.actions.next_tab]318 [workspace.shortcuts.actions.next_tab]
318 keys = ["ctrl+tab", "alt+l"]319 keys = ["ctrl+tab", "alt+l"]
319 desc = "Switch to next tab"320 desc = "Switch to next tab"
321+
322+[workspace.shortcuts.actions.toggle_floating_pane]
323+ keys = ["alt+f"]
324+ desc = "Toggle floating pane"
320 ```325 ```
321 326
322 > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.327 > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.
@@ -324,6 +329,47 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
324 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.329 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
325 >330 >
326 > **Note:** Consume-or-expel is experimental and may change behavior between releases.331 > **Note:** Consume-or-expel is experimental and may change behavior between releases.
332+>
333+> **Warning:** Some `Alt+<key>` shortcuts may conflict with WebKit defaults, website handlers, or your desktop environment.
334+> If a binding does not fire, rebind it to a different key.
335+
336+### Floating Pane
337+
338+The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.
339+
340+- Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle_floating_pane`).
341+- URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.
342+
343+| Key | Type | Default | Description |
344+|-----|------|---------|-------------|
345+| `workspace.floating_pane.width_pct` | float | `0.82` | Floating pane width as fraction of workspace width (`(0,1]`) |
346+| `workspace.floating_pane.height_pct` | float | `0.72` | Floating pane height as fraction of workspace height (`(0,1]`) |
347+| `workspace.floating_pane.profiles` | map | `{}` | Named URL profiles, each with `keys[]`, `url`, optional `desc` |
348+
349+**Example:**
350+
351+```toml
352+[workspace.shortcuts.actions.toggle_floating_pane]
353+keys = ["alt+f"]
354+desc = "Toggle floating pane"
355+
356+[workspace.floating_pane]
357+width_pct = 0.82
358+height_pct = 0.72
359+
360+[workspace.floating_pane.profiles]
361+# Empty by default. Add as many entries as needed.
362+
363+[workspace.floating_pane.profiles.google]
364+keys = ["alt+g"]
365+url = "https://google.com"
366+desc = "Open floating pane on Google"
367+
368+[workspace.floating_pane.profiles.github]
369+keys = ["alt+h"]
370+url = "https://github.com"
371+desc = "Open floating pane on GitHub"
372+```
327 373
328 ### Popup Behavior374 ### Popup Behavior
329 375
M docs/reference/configuration.md +7 -0
@@ -69,6 +69,13 @@
6969 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
7070 | `workspace.resize_mode.step_percent` | float | `5.0` | |
7171 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
72+| `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
73+| `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
74+| `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
75+| `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |
76+| `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
77+| `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
78+| `workspace.floating_pane.profiles.<name>.desc` | string | | |
7279 | `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
7380 | `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
7481 | `workspace.popups.open_in_new_pane` | bool | `true` | |
@@ -69,6 +69,13 @@
69 | `workspace.resize_mode.timeout_ms` | int | `3000` | |69 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
70 | `workspace.resize_mode.step_percent` | float | `5.0` | |70 | `workspace.resize_mode.step_percent` | float | `5.0` | |
71 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |71 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
72+| `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
73+| `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
74+| `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
75+| `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |
76+| `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
77+| `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
78+| `workspace.floating_pane.profiles.<name>.desc` | string | | |
72 | `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |79 | `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
73 | `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |80 | `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
74 | `workspace.popups.open_in_new_pane` | bool | `true` | |81 | `workspace.popups.open_in_new_pane` | bool | `true` | |
M docs/reference/keybindings.md +22 -0
@@ -79,6 +79,7 @@ These work outside modal modes:
7979
8080 | Action | Keys |
8181 |--------|------|
82+| Toggle floating pane | `Alt+F` |
8283 | Close pane | `Ctrl+W` |
8384 | Next tab | `Ctrl+Tab` |
8485 | Previous tab | `Ctrl+Shift+Tab` |
@@ -87,6 +88,12 @@ These work outside modal modes:
8788 | Consume/expel up | `Alt+{` |
8889 | Consume/expel down | `Alt+}` |
8990
91+`Alt+F` is the only floating-pane shortcut enabled by default.
92+Any URL-opening shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
93+
94+Warning: some `Alt+<key>` combinations may already be handled by WebKit, websites, or your desktop environment.
95+If a shortcut does not trigger in Dumber, choose a different keybinding.
96+
9097 ## Customization
9198
9299 All keybindings can be customized in `~/.config/dumber/config.toml`:
@@ -98,6 +105,21 @@ close-pane = ["x", "q"]
98105
99106 [workspace.shortcuts.actions.close_pane]
100107 keys = ["ctrl+w"]
108+
109+[workspace.shortcuts.actions.toggle_floating_pane]
110+keys = ["alt+f"]
111+
112+[workspace.floating_pane]
113+width_pct = 0.82
114+height_pct = 0.72
115+
116+[workspace.floating_pane.profiles.google]
117+keys = ["alt+g"]
118+url = "https://google.com"
119+
120+[workspace.floating_pane.profiles.github]
121+keys = ["alt+h"]
122+url = "https://github.com"
101123 ```
102124
103125 See [Configuration](../config/index.md) for full details.
@@ -79,6 +79,7 @@ These work outside modal modes:
79 79
80 | Action | Keys |80 | Action | Keys |
81 |--------|------|81 |--------|------|
82+| Toggle floating pane | `Alt+F` |
82 | Close pane | `Ctrl+W` |83 | Close pane | `Ctrl+W` |
83 | Next tab | `Ctrl+Tab` |84 | Next tab | `Ctrl+Tab` |
84 | Previous tab | `Ctrl+Shift+Tab` |85 | Previous tab | `Ctrl+Shift+Tab` |
@@ -87,6 +88,12 @@ These work outside modal modes:
87 | Consume/expel up | `Alt+{` |88 | Consume/expel up | `Alt+{` |
88 | Consume/expel down | `Alt+}` |89 | Consume/expel down | `Alt+}` |
89 90
91+`Alt+F` is the only floating-pane shortcut enabled by default.
92+Any URL-opening shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
93+
94+Warning: some `Alt+<key>` combinations may already be handled by WebKit, websites, or your desktop environment.
95+If a shortcut does not trigger in Dumber, choose a different keybinding.
96+
90 ## Customization97 ## Customization
91 98
92 All keybindings can be customized in `~/.config/dumber/config.toml`:99 All keybindings can be customized in `~/.config/dumber/config.toml`:
@@ -98,6 +105,21 @@ close-pane = ["x", "q"]
98 105
99 [workspace.shortcuts.actions.close_pane]106 [workspace.shortcuts.actions.close_pane]
100 keys = ["ctrl+w"]107 keys = ["ctrl+w"]
108+
109+[workspace.shortcuts.actions.toggle_floating_pane]
110+keys = ["alt+f"]
111+
112+[workspace.floating_pane]
113+width_pct = 0.82
114+height_pct = 0.72
115+
116+[workspace.floating_pane.profiles.google]
117+keys = ["alt+g"]
118+url = "https://google.com"
119+
120+[workspace.floating_pane.profiles.github]
121+keys = ["alt+h"]
122+url = "https://github.com"
101 ```123 ```
102 124
103 See [Configuration](../config/index.md) for full details.125 See [Configuration](../config/index.md) for full details.
M internal/infrastructure/config/defaults.go +8 -0
@@ -33,6 +33,8 @@ const (
3333 defaultResizeMinPanePercent = 10.0
3434 defaultTabBarPosition = "bottom"
3535 defaultPopupPlacement = "right"
36+ defaultFloatingPaneWidthPct = 0.82
37+ defaultFloatingPaneHeightPct = 0.72
3638
3739 // Session defaults
3840 defaultSessionActivationShortcut = "ctrl+o"
@@ -223,6 +225,7 @@ func DefaultConfig() *Config {
223225 },
224226 Shortcuts: GlobalShortcutsConfig{
225227 Actions: map[string]ActionBinding{
228+ "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
226229 "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
227230 "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
228231 "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
@@ -232,6 +235,11 @@ func DefaultConfig() *Config {
232235 "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
233236 },
234237 },
238+ FloatingPane: FloatingPaneConfig{
239+ WidthPct: defaultFloatingPaneWidthPct,
240+ HeightPct: defaultFloatingPaneHeightPct,
241+ Profiles: map[string]FloatingPaneProfile{},
242+ },
235243 TabBarPosition: defaultTabBarPosition,
236244 HideTabBarWhenSingleTab: true,
237245 Popups: PopupBehaviorConfig{
@@ -33,6 +33,8 @@ const (
33 defaultResizeMinPanePercent = 10.033 defaultResizeMinPanePercent = 10.0
34 defaultTabBarPosition = "bottom"34 defaultTabBarPosition = "bottom"
35 defaultPopupPlacement = "right"35 defaultPopupPlacement = "right"
36+ defaultFloatingPaneWidthPct = 0.82
37+ defaultFloatingPaneHeightPct = 0.72
36 38
37 // Session defaults39 // Session defaults
38 defaultSessionActivationShortcut = "ctrl+o"40 defaultSessionActivationShortcut = "ctrl+o"
@@ -223,6 +225,7 @@ func DefaultConfig() *Config {
223 },225 },
224 Shortcuts: GlobalShortcutsConfig{226 Shortcuts: GlobalShortcutsConfig{
225 Actions: map[string]ActionBinding{227 Actions: map[string]ActionBinding{
228+ "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
226 "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},229 "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
227 "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},230 "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
228 "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},231 "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
@@ -232,6 +235,11 @@ func DefaultConfig() *Config {
232 "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},235 "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
233 },236 },
234 },237 },
238+ FloatingPane: FloatingPaneConfig{
239+ WidthPct: defaultFloatingPaneWidthPct,
240+ HeightPct: defaultFloatingPaneHeightPct,
241+ Profiles: map[string]FloatingPaneProfile{},
242+ },
235 TabBarPosition: defaultTabBarPosition,243 TabBarPosition: defaultTabBarPosition,
236 HideTabBarWhenSingleTab: true,244 HideTabBarWhenSingleTab: true,
237 Popups: PopupBehaviorConfig{245 Popups: PopupBehaviorConfig{
A internal/infrastructure/config/floating_pane_config_test.go +170 -0
@@ -0,0 +1,170 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFloatingPaneConfig_Defaults(t *testing.T) {
12+ cfg := DefaultConfig()
13+
14+ assert.InDelta(t, 0.82, cfg.Workspace.FloatingPane.WidthPct, 0.0001)
15+ assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
16+ assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
17+
18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
19+ require.True(t, ok)
20+ assert.Equal(t, []string{"alt+f"}, binding.Keys)
21+}
22+
23+func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
24+ mgr := &Manager{viper: viper.New()}
25+ mgr.setDefaults()
26+
27+ cfg, err := mgr.unmarshalConfig()
28+ require.NoError(t, err)
29+
30+ assert.InDelta(t, 0.82, cfg.Workspace.FloatingPane.WidthPct, 0.0001)
31+ assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
32+ assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
33+
34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
35+ require.True(t, ok)
36+ assert.Equal(t, []string{"alt+f"}, binding.Keys)
37+}
38+
39+func TestFloatingPaneConfig_ValidationRanges(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ mutate func(cfg *Config)
43+ errorField string
44+ }{
45+ {
46+ name: "width must be > 0",
47+ mutate: func(cfg *Config) {
48+ cfg.Workspace.FloatingPane.WidthPct = 0
49+ },
50+ errorField: "workspace.floating_pane.width_pct",
51+ },
52+ {
53+ name: "width must be <= 1",
54+ mutate: func(cfg *Config) {
55+ cfg.Workspace.FloatingPane.WidthPct = 1.5
56+ },
57+ errorField: "workspace.floating_pane.width_pct",
58+ },
59+ {
60+ name: "height must be > 0",
61+ mutate: func(cfg *Config) {
62+ cfg.Workspace.FloatingPane.HeightPct = 0
63+ },
64+ errorField: "workspace.floating_pane.height_pct",
65+ },
66+ {
67+ name: "height must be <= 1",
68+ mutate: func(cfg *Config) {
69+ cfg.Workspace.FloatingPane.HeightPct = 1.1
70+ },
71+ errorField: "workspace.floating_pane.height_pct",
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ cfg := DefaultConfig()
78+ tt.mutate(cfg)
79+
80+ err := validateConfig(cfg)
81+ require.Error(t, err)
82+ assert.Contains(t, err.Error(), tt.errorField)
83+ })
84+ }
85+}
86+
87+func TestFloatingPaneConfig_ProfileValidation(t *testing.T) {
88+ cfg := DefaultConfig()
89+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
90+ "google": {
91+ URL: "",
92+ Keys: []string{"alt+g"},
93+ },
94+ }
95+
96+ err := validateConfig(cfg)
97+ require.Error(t, err)
98+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")
99+
100+ cfg = DefaultConfig()
101+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
102+ "google": {
103+ URL: "https://google.com",
104+ Keys: nil,
105+ },
106+ }
107+
108+ err = validateConfig(cfg)
109+ require.Error(t, err)
110+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")
111+
112+ cfg = DefaultConfig()
113+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
114+ "google": {
115+ URL: "https://google.com",
116+ Keys: []string{" ", "alt+g"},
117+ },
118+ }
119+
120+ err = validateConfig(cfg)
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "empty or whitespace-only key binding")
123+
124+ cfg = DefaultConfig()
125+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
126+ "google": {
127+ URL: "https://google.com",
128+ Keys: []string{"alt+g"},
129+ },
130+ "github": {
131+ URL: "https://github.com",
132+ Keys: []string{"alt+g"},
133+ },
134+ }
135+
136+ err = validateConfig(cfg)
137+ require.Error(t, err)
138+ assert.Contains(t, err.Error(), "duplicate key binding")
139+
140+ cfg = DefaultConfig()
141+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
142+ "invalid-scheme": {
143+ URL: "ftp://example.com",
144+ Keys: []string{"alt+i"},
145+ },
146+ }
147+
148+ err = validateConfig(cfg)
149+ require.Error(t, err)
150+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")
151+ assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")
152+}
153+
154+func TestFloatingPaneConfig_ProfileMultiplicity(t *testing.T) {
155+ cfg := DefaultConfig()
156+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
157+ "google": {
158+ URL: "https://google.com",
159+ Keys: []string{"alt+g"},
160+ },
161+ "github": {
162+ URL: "https://github.com",
163+ Keys: []string{"alt+h"},
164+ },
165+ }
166+
167+ err := validateConfig(cfg)
168+ require.NoError(t, err)
169+ assert.Len(t, cfg.Workspace.FloatingPane.Profiles, 2)
170+}
@@ -0,0 +1,170 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFloatingPaneConfig_Defaults(t *testing.T) {
12+ cfg := DefaultConfig()
13+
14+ assert.InDelta(t, 0.82, cfg.Workspace.FloatingPane.WidthPct, 0.0001)
15+ assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
16+ assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
17+
18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
19+ require.True(t, ok)
20+ assert.Equal(t, []string{"alt+f"}, binding.Keys)
21+}
22+
23+func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
24+ mgr := &Manager{viper: viper.New()}
25+ mgr.setDefaults()
26+
27+ cfg, err := mgr.unmarshalConfig()
28+ require.NoError(t, err)
29+
30+ assert.InDelta(t, 0.82, cfg.Workspace.FloatingPane.WidthPct, 0.0001)
31+ assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
32+ assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
33+
34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
35+ require.True(t, ok)
36+ assert.Equal(t, []string{"alt+f"}, binding.Keys)
37+}
38+
39+func TestFloatingPaneConfig_ValidationRanges(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ mutate func(cfg *Config)
43+ errorField string
44+ }{
45+ {
46+ name: "width must be > 0",
47+ mutate: func(cfg *Config) {
48+ cfg.Workspace.FloatingPane.WidthPct = 0
49+ },
50+ errorField: "workspace.floating_pane.width_pct",
51+ },
52+ {
53+ name: "width must be <= 1",
54+ mutate: func(cfg *Config) {
55+ cfg.Workspace.FloatingPane.WidthPct = 1.5
56+ },
57+ errorField: "workspace.floating_pane.width_pct",
58+ },
59+ {
60+ name: "height must be > 0",
61+ mutate: func(cfg *Config) {
62+ cfg.Workspace.FloatingPane.HeightPct = 0
63+ },
64+ errorField: "workspace.floating_pane.height_pct",
65+ },
66+ {
67+ name: "height must be <= 1",
68+ mutate: func(cfg *Config) {
69+ cfg.Workspace.FloatingPane.HeightPct = 1.1
70+ },
71+ errorField: "workspace.floating_pane.height_pct",
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ cfg := DefaultConfig()
78+ tt.mutate(cfg)
79+
80+ err := validateConfig(cfg)
81+ require.Error(t, err)
82+ assert.Contains(t, err.Error(), tt.errorField)
83+ })
84+ }
85+}
86+
87+func TestFloatingPaneConfig_ProfileValidation(t *testing.T) {
88+ cfg := DefaultConfig()
89+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
90+ "google": {
91+ URL: "",
92+ Keys: []string{"alt+g"},
93+ },
94+ }
95+
96+ err := validateConfig(cfg)
97+ require.Error(t, err)
98+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")
99+
100+ cfg = DefaultConfig()
101+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
102+ "google": {
103+ URL: "https://google.com",
104+ Keys: nil,
105+ },
106+ }
107+
108+ err = validateConfig(cfg)
109+ require.Error(t, err)
110+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")
111+
112+ cfg = DefaultConfig()
113+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
114+ "google": {
115+ URL: "https://google.com",
116+ Keys: []string{" ", "alt+g"},
117+ },
118+ }
119+
120+ err = validateConfig(cfg)
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "empty or whitespace-only key binding")
123+
124+ cfg = DefaultConfig()
125+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
126+ "google": {
127+ URL: "https://google.com",
128+ Keys: []string{"alt+g"},
129+ },
130+ "github": {
131+ URL: "https://github.com",
132+ Keys: []string{"alt+g"},
133+ },
134+ }
135+
136+ err = validateConfig(cfg)
137+ require.Error(t, err)
138+ assert.Contains(t, err.Error(), "duplicate key binding")
139+
140+ cfg = DefaultConfig()
141+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
142+ "invalid-scheme": {
143+ URL: "ftp://example.com",
144+ Keys: []string{"alt+i"},
145+ },
146+ }
147+
148+ err = validateConfig(cfg)
149+ require.Error(t, err)
150+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")
151+ assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")
152+}
153+
154+func TestFloatingPaneConfig_ProfileMultiplicity(t *testing.T) {
155+ cfg := DefaultConfig()
156+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
157+ "google": {
158+ URL: "https://google.com",
159+ Keys: []string{"alt+g"},
160+ },
161+ "github": {
162+ URL: "https://github.com",
163+ Keys: []string{"alt+h"},
164+ },
165+ }
166+
167+ err := validateConfig(cfg)
168+ require.NoError(t, err)
169+ assert.Len(t, cfg.Workspace.FloatingPane.Profiles, 2)
170+}
M internal/infrastructure/config/loader.go +3 -0
@@ -496,6 +496,9 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
496496 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
497497 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
498498 m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
499+ m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
500+ m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
501+ m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
499502 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
500503 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
501504 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
@@ -496,6 +496,9 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
496 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)496 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
497 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)497 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
498 m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)498 m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
499+ m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
500+ m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
501+ m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
499 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)502 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
500 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)503 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
501 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)504 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
M internal/infrastructure/config/migrate.go +132 -6
@@ -113,6 +113,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
113113
114114 // Find missing keys (in defaults but not in user config)
115115 missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
116+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))
116117
117118 // Try to match deprecated keys with missing keys (detect renames)
118119 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
@@ -120,7 +121,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
120121 // Add rename changes
121122 for oldKey, newKey := range renames {
122123 oldValue := m.formatValue(userKeysWithValues[oldKey])
123- newValue := m.formatValue(m.defaultViper.Get(newKey))
124+ newValue := m.formatValue(m.defaultValueForKey(newKey))
124125 changes = append(changes, port.KeyChange{
125126 Type: port.KeyChangeRenamed,
126127 OldKey: oldKey,
@@ -144,7 +145,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
144145 changes = append(changes, port.KeyChange{
145146 Type: port.KeyChangeAdded,
146147 NewKey: newKey,
147- NewValue: m.formatValue(m.defaultViper.Get(newKey)),
148+ NewValue: m.formatValue(m.defaultValueForKey(newKey)),
148149 })
149150 }
150151
@@ -172,6 +173,9 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
172173 func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
173174 var deprecated []string
174175 for userKey := range userKeys {
176+ if userKey == "database.path" {
177+ continue
178+ }
175179 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
176180 deprecated = append(deprecated, userKey)
177181 }
@@ -257,9 +261,10 @@ const (
257261
258262 // Config format constants.
259263 const (
260- configFormatTOML = "toml"
261- configFormatYAML = "yaml"
262- configFormatJSON = "json"
264+ configFormatTOML = "toml"
265+ configFormatYAML = "yaml"
266+ configFormatJSON = "json"
267+ workspaceShortcutsActionsKey = "workspace.shortcuts.actions"
263268 )
264269
265270 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -405,6 +410,7 @@ func (m *Migrator) Migrate() ([]string, error) {
405410 for key := range keysToRemove {
406411 m.deleteNestedKey(rawConfig, key)
407412 }
413+ m.mergeMissingWorkspaceShortcutActions(rawConfig)
408414
409415 // Create a new Viper instance with defaults for added keys
410416 userViper := viper.New()
@@ -579,7 +585,7 @@ func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []stri
579585
580586 // GetKeyInfo returns detailed information about a config key.
581587 func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
582- value := m.defaultViper.Get(key)
588+ value := m.defaultValueForKey(key)
583589 if value == nil {
584590 return port.KeyInfo{
585591 Key: key,
@@ -600,6 +606,21 @@ func (*Migrator) GetConfigFile() (string, error) {
600606 return GetConfigFile()
601607 }
602608
609+func (m *Migrator) defaultValueForKey(key string) any {
610+ if value := m.defaultViper.Get(key); value != nil {
611+ return value
612+ }
613+
614+ if strings.HasPrefix(key, workspaceShortcutsActionsKey+".") {
615+ actionName := strings.TrimPrefix(key, workspaceShortcutsActionsKey+".")
616+ if action, ok := m.defaultConfig.Workspace.Shortcuts.Actions[actionName]; ok {
617+ return action
618+ }
619+ }
620+
621+ return nil
622+}
623+
603624 // getAllDefaultKeys returns all keys from the default configuration.
604625 func (m *Migrator) getAllDefaultKeys() []string {
605626 keys := m.defaultViper.AllKeys()
@@ -727,6 +748,111 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
727748 return false
728749 }
729750
751+// detectMissingWorkspaceShortcutActions finds default global shortcut actions not present in user config.
752+func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[string]any) []string {
753+ if userKeysWithValues == nil {
754+ return nil
755+ }
756+
757+ userActionsValue, ok := userKeysWithValues[workspaceShortcutsActionsKey]
758+ if !ok {
759+ return nil
760+ }
761+
762+ userActions, ok := m.toStringAnyMap(userActionsValue)
763+ if !ok {
764+ return nil
765+ }
766+
767+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
768+ if len(defaultActions) == 0 {
769+ return nil
770+ }
771+
772+ missing := make([]string, 0)
773+ for actionName := range defaultActions {
774+ if _, exists := userActions[actionName]; exists {
775+ continue
776+ }
777+ missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
778+ }
779+
780+ sort.Strings(missing)
781+ return missing
782+}
783+
784+// mergeMissingWorkspaceShortcutActions injects new default shortcut actions while preserving user overrides.
785+func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any) {
786+ actionsValue := m.getNestedValue(rawConfig, workspaceShortcutsActionsKey)
787+ if actionsValue == nil {
788+ return
789+ }
790+
791+ userActions, ok := m.toStringAnyMap(actionsValue)
792+ if !ok {
793+ return
794+ }
795+
796+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
797+ for actionName, defaultAction := range defaultActions {
798+ if _, exists := userActions[actionName]; exists {
799+ continue
800+ }
801+ userActions[actionName] = defaultAction
802+ }
803+
804+ m.setNestedValue(rawConfig, workspaceShortcutsActionsKey, userActions)
805+}
806+
807+func appendUniqueKeys(base, extra []string) []string {
808+ if len(extra) == 0 {
809+ return base
810+ }
811+
812+ seen := make(map[string]bool, len(base)+len(extra))
813+ for _, key := range base {
814+ seen[key] = true
815+ }
816+
817+ for _, key := range extra {
818+ if seen[key] {
819+ continue
820+ }
821+ base = append(base, key)
822+ seen[key] = true
823+ }
824+
825+ sort.Strings(base)
826+ return base
827+}
828+
829+func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
830+ if value == nil {
831+ return nil, false
832+ }
833+
834+ if m, ok := value.(map[string]any); ok {
835+ return m, true
836+ }
837+
838+ rv := reflect.ValueOf(value)
839+ if rv.Kind() != reflect.Map {
840+ return nil, false
841+ }
842+
843+ out := make(map[string]any, rv.Len())
844+ iter := rv.MapRange()
845+ for iter.Next() {
846+ k := iter.Key()
847+ if k.Kind() != reflect.String {
848+ return nil, false
849+ }
850+ out[k.String()] = iter.Value().Interface()
851+ }
852+
853+ return out, true
854+}
855+
730856 // findMissingKeys returns keys that are in defaults but not in user config.
731857 func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
732858 missing := make([]string, 0)
@@ -113,6 +113,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
113 113
114 // Find missing keys (in defaults but not in user config)114 // Find missing keys (in defaults but not in user config)
115 missingKeys := m.findMissingKeys(defaultKeys, userKeySet)115 missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
116+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))
116 117
117 // Try to match deprecated keys with missing keys (detect renames)118 // Try to match deprecated keys with missing keys (detect renames)
118 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)119 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
@@ -120,7 +121,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
120 // Add rename changes121 // Add rename changes
121 for oldKey, newKey := range renames {122 for oldKey, newKey := range renames {
122 oldValue := m.formatValue(userKeysWithValues[oldKey])123 oldValue := m.formatValue(userKeysWithValues[oldKey])
123- newValue := m.formatValue(m.defaultViper.Get(newKey))124+ newValue := m.formatValue(m.defaultValueForKey(newKey))
124 changes = append(changes, port.KeyChange{125 changes = append(changes, port.KeyChange{
125 Type: port.KeyChangeRenamed,126 Type: port.KeyChangeRenamed,
126 OldKey: oldKey,127 OldKey: oldKey,
@@ -144,7 +145,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
144 changes = append(changes, port.KeyChange{145 changes = append(changes, port.KeyChange{
145 Type: port.KeyChangeAdded,146 Type: port.KeyChangeAdded,
146 NewKey: newKey,147 NewKey: newKey,
147- NewValue: m.formatValue(m.defaultViper.Get(newKey)),148+ NewValue: m.formatValue(m.defaultValueForKey(newKey)),
148 })149 })
149 }150 }
150 151
@@ -172,6 +173,9 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
172 func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {173 func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
173 var deprecated []string174 var deprecated []string
174 for userKey := range userKeys {175 for userKey := range userKeys {
176+ if userKey == "database.path" {
177+ continue
178+ }
175 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {179 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
176 deprecated = append(deprecated, userKey)180 deprecated = append(deprecated, userKey)
177 }181 }
@@ -257,9 +261,10 @@ const (
257 261
258 // Config format constants.262 // Config format constants.
259 const (263 const (
260- configFormatTOML = "toml"264+ configFormatTOML = "toml"
261- configFormatYAML = "yaml"265+ configFormatYAML = "yaml"
262- configFormatJSON = "json"266+ configFormatJSON = "json"
267+ workspaceShortcutsActionsKey = "workspace.shortcuts.actions"
263 )268 )
264 269
265 // typesAreCompatible checks if the default types for two keys are compatible.270 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -405,6 +410,7 @@ func (m *Migrator) Migrate() ([]string, error) {
405 for key := range keysToRemove {410 for key := range keysToRemove {
406 m.deleteNestedKey(rawConfig, key)411 m.deleteNestedKey(rawConfig, key)
407 }412 }
413+ m.mergeMissingWorkspaceShortcutActions(rawConfig)
408 414
409 // Create a new Viper instance with defaults for added keys415 // Create a new Viper instance with defaults for added keys
410 userViper := viper.New()416 userViper := viper.New()
@@ -579,7 +585,7 @@ func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []stri
579 585
580 // GetKeyInfo returns detailed information about a config key.586 // GetKeyInfo returns detailed information about a config key.
581 func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {587 func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
582- value := m.defaultViper.Get(key)588+ value := m.defaultValueForKey(key)
583 if value == nil {589 if value == nil {
584 return port.KeyInfo{590 return port.KeyInfo{
585 Key: key,591 Key: key,
@@ -600,6 +606,21 @@ func (*Migrator) GetConfigFile() (string, error) {
600 return GetConfigFile()606 return GetConfigFile()
601 }607 }
602 608
609+func (m *Migrator) defaultValueForKey(key string) any {
610+ if value := m.defaultViper.Get(key); value != nil {
611+ return value
612+ }
613+
614+ if strings.HasPrefix(key, workspaceShortcutsActionsKey+".") {
615+ actionName := strings.TrimPrefix(key, workspaceShortcutsActionsKey+".")
616+ if action, ok := m.defaultConfig.Workspace.Shortcuts.Actions[actionName]; ok {
617+ return action
618+ }
619+ }
620+
621+ return nil
622+}
623+
603 // getAllDefaultKeys returns all keys from the default configuration.624 // getAllDefaultKeys returns all keys from the default configuration.
604 func (m *Migrator) getAllDefaultKeys() []string {625 func (m *Migrator) getAllDefaultKeys() []string {
605 keys := m.defaultViper.AllKeys()626 keys := m.defaultViper.AllKeys()
@@ -727,6 +748,111 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
727 return false748 return false
728 }749 }
729 750
751+// detectMissingWorkspaceShortcutActions finds default global shortcut actions not present in user config.
752+func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[string]any) []string {
753+ if userKeysWithValues == nil {
754+ return nil
755+ }
756+
757+ userActionsValue, ok := userKeysWithValues[workspaceShortcutsActionsKey]
758+ if !ok {
759+ return nil
760+ }
761+
762+ userActions, ok := m.toStringAnyMap(userActionsValue)
763+ if !ok {
764+ return nil
765+ }
766+
767+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
768+ if len(defaultActions) == 0 {
769+ return nil
770+ }
771+
772+ missing := make([]string, 0)
773+ for actionName := range defaultActions {
774+ if _, exists := userActions[actionName]; exists {
775+ continue
776+ }
777+ missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
778+ }
779+
780+ sort.Strings(missing)
781+ return missing
782+}
783+
784+// mergeMissingWorkspaceShortcutActions injects new default shortcut actions while preserving user overrides.
785+func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any) {
786+ actionsValue := m.getNestedValue(rawConfig, workspaceShortcutsActionsKey)
787+ if actionsValue == nil {
788+ return
789+ }
790+
791+ userActions, ok := m.toStringAnyMap(actionsValue)
792+ if !ok {
793+ return
794+ }
795+
796+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
797+ for actionName, defaultAction := range defaultActions {
798+ if _, exists := userActions[actionName]; exists {
799+ continue
800+ }
801+ userActions[actionName] = defaultAction
802+ }
803+
804+ m.setNestedValue(rawConfig, workspaceShortcutsActionsKey, userActions)
805+}
806+
807+func appendUniqueKeys(base, extra []string) []string {
808+ if len(extra) == 0 {
809+ return base
810+ }
811+
812+ seen := make(map[string]bool, len(base)+len(extra))
813+ for _, key := range base {
814+ seen[key] = true
815+ }
816+
817+ for _, key := range extra {
818+ if seen[key] {
819+ continue
820+ }
821+ base = append(base, key)
822+ seen[key] = true
823+ }
824+
825+ sort.Strings(base)
826+ return base
827+}
828+
829+func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
830+ if value == nil {
831+ return nil, false
832+ }
833+
834+ if m, ok := value.(map[string]any); ok {
835+ return m, true
836+ }
837+
838+ rv := reflect.ValueOf(value)
839+ if rv.Kind() != reflect.Map {
840+ return nil, false
841+ }
842+
843+ out := make(map[string]any, rv.Len())
844+ iter := rv.MapRange()
845+ for iter.Next() {
846+ k := iter.Key()
847+ if k.Kind() != reflect.String {
848+ return nil, false
849+ }
850+ out[k.String()] = iter.Value().Interface()
851+ }
852+
853+ return out, true
854+}
855+
730 // findMissingKeys returns keys that are in defaults but not in user config.856 // findMissingKeys returns keys that are in defaults but not in user config.
731 func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {857 func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
732 missing := make([]string, 0)858 missing := make([]string, 0)
M internal/infrastructure/config/migrate_test.go +101 -0
@@ -539,6 +539,16 @@ func TestMigrator_FindDeprecatedKeys(t *testing.T) {
539539 },
540540 expected: []string{},
541541 },
542+ {
543+ name: "database.path ignored as deprecated",
544+ userKeys: map[string]any{
545+ "database.path": "",
546+ },
547+ defaultKeys: map[string]bool{
548+ "history.max_entries": true,
549+ },
550+ expected: []string{},
551+ },
542552 }
543553
544554 for _, tt := range tests {
@@ -677,6 +687,97 @@ func TestMigrator_GetConfigType(t *testing.T) {
677687 }
678688 }
679689
690+func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
691+ m := NewMigrator()
692+
693+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
694+ userActions := make(map[string]any, len(defaultActions))
695+ for actionName, value := range defaultActions {
696+ userActions[actionName] = value
697+ }
698+ delete(userActions, "toggle_floating_pane")
699+
700+ missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701+ "workspace.shortcuts.actions": userActions,
702+ })
703+
704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)
705+}
706+
707+func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
708+ m := NewMigrator()
709+
710+ rawConfig := map[string]any{
711+ "workspace": map[string]any{
712+ "shortcuts": map[string]any{
713+ "actions": map[string]any{
714+ "close_pane": map[string]any{
715+ "keys": []string{"ctrl+w", "ctrl+i"},
716+ "desc": "Custom close pane",
717+ },
718+ "next_tab": map[string]any{
719+ "keys": []string{"ctrl+tab"},
720+ "desc": "Switch to next tab",
721+ },
722+ "previous_tab": map[string]any{
723+ "keys": []string{"ctrl+shift+tab"},
724+ "desc": "Switch to previous tab",
725+ },
726+ "consume_or_expel_left": map[string]any{
727+ "keys": []string{"alt+["},
728+ "desc": "Consume/expel pane left",
729+ },
730+ "consume_or_expel_right": map[string]any{
731+ "keys": []string{"alt+]"},
732+ "desc": "Consume/expel pane right",
733+ },
734+ "consume_or_expel_up": map[string]any{
735+ "keys": []string{"alt+{"},
736+ "desc": "Consume/expel pane up",
737+ },
738+ "consume_or_expel_down": map[string]any{
739+ "keys": []string{"alt+}"},
740+ "desc": "Consume/expel pane down",
741+ },
742+ },
743+ },
744+ },
745+ }
746+
747+ m.mergeMissingWorkspaceShortcutActions(rawConfig)
748+
749+ actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
750+ actions, ok := actionsAny.(map[string]any)
751+ require.True(t, ok)
752+
753+ toggleAny, hasToggle := actions["toggle_floating_pane"]
754+ assert.True(t, hasToggle)
755+ assert.NotNil(t, toggleAny)
756+
757+ closePaneAny, hasClosePane := actions["close_pane"]
758+ require.True(t, hasClosePane)
759+ closePane, ok := closePaneAny.(map[string]any)
760+ require.True(t, ok)
761+ assert.Equal(t, "Custom close pane", closePane["desc"])
762+}
763+
764+func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765+ m := NewMigrator()
766+
767+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")
768+
769+ action, ok := value.(ActionBinding)
770+ require.True(t, ok)
771+ assert.Equal(t, []string{"alt+f"}, action.Keys)
772+}
773+
774+func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
775+ m := NewMigrator()
776+
777+ value := m.defaultValueForKey("history.max_entries")
778+ assert.Equal(t, 10000, value)
779+}
780+
680781 func TestMigrator_GetNestedValue(t *testing.T) {
681782 m := NewMigrator()
682783
@@ -539,6 +539,16 @@ func TestMigrator_FindDeprecatedKeys(t *testing.T) {
539 },539 },
540 expected: []string{},540 expected: []string{},
541 },541 },
542+ {
543+ name: "database.path ignored as deprecated",
544+ userKeys: map[string]any{
545+ "database.path": "",
546+ },
547+ defaultKeys: map[string]bool{
548+ "history.max_entries": true,
549+ },
550+ expected: []string{},
551+ },
542 }552 }
543 553
544 for _, tt := range tests {554 for _, tt := range tests {
@@ -677,6 +687,97 @@ func TestMigrator_GetConfigType(t *testing.T) {
677 }687 }
678 }688 }
679 689
690+func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
691+ m := NewMigrator()
692+
693+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
694+ userActions := make(map[string]any, len(defaultActions))
695+ for actionName, value := range defaultActions {
696+ userActions[actionName] = value
697+ }
698+ delete(userActions, "toggle_floating_pane")
699+
700+ missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701+ "workspace.shortcuts.actions": userActions,
702+ })
703+
704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)
705+}
706+
707+func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
708+ m := NewMigrator()
709+
710+ rawConfig := map[string]any{
711+ "workspace": map[string]any{
712+ "shortcuts": map[string]any{
713+ "actions": map[string]any{
714+ "close_pane": map[string]any{
715+ "keys": []string{"ctrl+w", "ctrl+i"},
716+ "desc": "Custom close pane",
717+ },
718+ "next_tab": map[string]any{
719+ "keys": []string{"ctrl+tab"},
720+ "desc": "Switch to next tab",
721+ },
722+ "previous_tab": map[string]any{
723+ "keys": []string{"ctrl+shift+tab"},
724+ "desc": "Switch to previous tab",
725+ },
726+ "consume_or_expel_left": map[string]any{
727+ "keys": []string{"alt+["},
728+ "desc": "Consume/expel pane left",
729+ },
730+ "consume_or_expel_right": map[string]any{
731+ "keys": []string{"alt+]"},
732+ "desc": "Consume/expel pane right",
733+ },
734+ "consume_or_expel_up": map[string]any{
735+ "keys": []string{"alt+{"},
736+ "desc": "Consume/expel pane up",
737+ },
738+ "consume_or_expel_down": map[string]any{
739+ "keys": []string{"alt+}"},
740+ "desc": "Consume/expel pane down",
741+ },
742+ },
743+ },
744+ },
745+ }
746+
747+ m.mergeMissingWorkspaceShortcutActions(rawConfig)
748+
749+ actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
750+ actions, ok := actionsAny.(map[string]any)
751+ require.True(t, ok)
752+
753+ toggleAny, hasToggle := actions["toggle_floating_pane"]
754+ assert.True(t, hasToggle)
755+ assert.NotNil(t, toggleAny)
756+
757+ closePaneAny, hasClosePane := actions["close_pane"]
758+ require.True(t, hasClosePane)
759+ closePane, ok := closePaneAny.(map[string]any)
760+ require.True(t, ok)
761+ assert.Equal(t, "Custom close pane", closePane["desc"])
762+}
763+
764+func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765+ m := NewMigrator()
766+
767+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")
768+
769+ action, ok := value.(ActionBinding)
770+ require.True(t, ok)
771+ assert.Equal(t, []string{"alt+f"}, action.Keys)
772+}
773+
774+func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
775+ m := NewMigrator()
776+
777+ value := m.defaultValueForKey("history.max_entries")
778+ assert.Equal(t, 10000, value)
779+}
780+
680 func TestMigrator_GetNestedValue(t *testing.T) {781 func TestMigrator_GetNestedValue(t *testing.T) {
681 m := NewMigrator()782 m := NewMigrator()
682 783
M internal/infrastructure/config/schema.go +16 -0
@@ -420,6 +420,8 @@ type WorkspaceConfig struct {
420420 ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
421421 // Shortcuts holds global (non-modal) keyboard shortcuts.
422422 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
423+ // FloatingPane configures workspace-level floating pane behavior.
424+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
423425 // TabBarPosition determines tab bar placement: "top" or "bottom".
424426 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
425427 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
@@ -503,6 +505,20 @@ type GlobalShortcutsConfig struct {
503505 Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
504506 }
505507
508+// FloatingPaneConfig defines persistent floating pane behavior.
509+type FloatingPaneConfig struct {
510+ WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
511+ HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
512+ Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
513+}
514+
515+// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
516+type FloatingPaneProfile struct {
517+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
518+ URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
519+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
520+}
521+
506522 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
507523 func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
508524 keyToAction := make(map[string]string)
@@ -420,6 +420,8 @@ type WorkspaceConfig struct {
420 ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`420 ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
421 // Shortcuts holds global (non-modal) keyboard shortcuts.421 // Shortcuts holds global (non-modal) keyboard shortcuts.
422 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`422 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
423+ // FloatingPane configures workspace-level floating pane behavior.
424+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
423 // TabBarPosition determines tab bar placement: "top" or "bottom".425 // TabBarPosition determines tab bar placement: "top" or "bottom".
424 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`426 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
425 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.427 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
@@ -503,6 +505,20 @@ type GlobalShortcutsConfig struct {
503 Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`505 Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
504 }506 }
505 507
508+// FloatingPaneConfig defines persistent floating pane behavior.
509+type FloatingPaneConfig struct {
510+ WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
511+ HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
512+ Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
513+}
514+
515+// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
516+type FloatingPaneProfile struct {
517+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
518+ URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
519+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
520+}
521+
506 // GetKeyBindings returns an inverted map for O(1) key→action lookup.522 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
507 func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {523 func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
508 keyToAction := make(map[string]string)524 keyToAction := make(map[string]string)
M internal/infrastructure/config/schema_provider.go +24 -0
@@ -453,6 +453,30 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
453453 Description: "Global keyboard shortcuts (use Keybindings tab to edit)",
454454 Section: SectionWorkspace,
455455 },
456+ // Floating pane
457+ {
458+ Key: "workspace.floating_pane.width_pct",
459+ Type: "float64",
460+ Default: fmt.Sprintf("%.2f", defaults.Workspace.FloatingPane.WidthPct),
461+ Description: "Floating pane width as a fraction of workspace width",
462+ Range: "(0,1]",
463+ Section: SectionWorkspace,
464+ },
465+ {
466+ Key: "workspace.floating_pane.height_pct",
467+ Type: "float64",
468+ Default: fmt.Sprintf("%.2f", defaults.Workspace.FloatingPane.HeightPct),
469+ Description: "Floating pane height as a fraction of workspace height",
470+ Range: "(0,1]",
471+ Section: SectionWorkspace,
472+ },
473+ {
474+ Key: "workspace.floating_pane.profiles.<name>",
475+ Type: "object",
476+ Default: "(empty)",
477+ Description: "Named floating pane URL profiles with keys, url, and optional desc",
478+ Section: SectionWorkspace,
479+ },
456480 // Popups
457481 {
458482 Key: "workspace.popups.behavior",
@@ -453,6 +453,30 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
453 Description: "Global keyboard shortcuts (use Keybindings tab to edit)",453 Description: "Global keyboard shortcuts (use Keybindings tab to edit)",
454 Section: SectionWorkspace,454 Section: SectionWorkspace,
455 },455 },
456+ // Floating pane
457+ {
458+ Key: "workspace.floating_pane.width_pct",
459+ Type: "float64",
460+ Default: fmt.Sprintf("%.2f", defaults.Workspace.FloatingPane.WidthPct),
461+ Description: "Floating pane width as a fraction of workspace width",
462+ Range: "(0,1]",
463+ Section: SectionWorkspace,
464+ },
465+ {
466+ Key: "workspace.floating_pane.height_pct",
467+ Type: "float64",
468+ Default: fmt.Sprintf("%.2f", defaults.Workspace.FloatingPane.HeightPct),
469+ Description: "Floating pane height as a fraction of workspace height",
470+ Range: "(0,1]",
471+ Section: SectionWorkspace,
472+ },
473+ {
474+ Key: "workspace.floating_pane.profiles.<name>",
475+ Type: "object",
476+ Default: "(empty)",
477+ Description: "Named floating pane URL profiles with keys, url, and optional desc",
478+ Section: SectionWorkspace,
479+ },
456 // Popups480 // Popups
457 {481 {
458 Key: "workspace.popups.behavior",482 Key: "workspace.popups.behavior",
M internal/infrastructure/config/validation.go +91 -13
@@ -22,6 +22,7 @@ func validateConfig(config *Config) error {
2222 validationErrors = append(validationErrors, validatePaneMode(config)...)
2323 validationErrors = append(validationErrors, validateTabBar(config)...)
2424 validationErrors = append(validationErrors, validateTabMode(config)...)
25+ validationErrors = append(validationErrors, validateFloatingPane(config)...)
2526 validationErrors = append(validationErrors, validateLogging(config)...)
2627 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
2728 validationErrors = append(validationErrors, validateOmnibox(config)...)
@@ -224,6 +225,73 @@ func validateTabMode(config *Config) []string {
224225 return validationErrors
225226 }
226227
228+func validateFloatingPane(config *Config) []string {
229+ var validationErrors []string
230+ seenProfileKeys := make(map[string]string)
231+
232+ if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
233+ validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
234+ }
235+
236+ if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
237+ validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
238+ }
239+
240+ for profileName, profile := range config.Workspace.FloatingPane.Profiles {
241+ if strings.TrimSpace(profile.URL) == "" {
242+ validationErrors = append(validationErrors, fmt.Sprintf(
243+ "workspace.floating_pane.profiles.%s.url is required",
244+ profileName,
245+ ))
246+ } else {
247+ validationErrors = append(validationErrors, validateWorkspaceURLValue(
248+ fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
249+ profile.URL,
250+ )...)
251+ }
252+ if len(profile.Keys) == 0 {
253+ validationErrors = append(validationErrors, fmt.Sprintf(
254+ "workspace.floating_pane.profiles.%s.keys must have at least one entry",
255+ profileName,
256+ ))
257+ }
258+
259+ for _, key := range profile.Keys {
260+ normalizedKey := normalizeBindingKeyString(key)
261+ if normalizedKey == "" {
262+ validationErrors = append(validationErrors, fmt.Sprintf(
263+ "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
264+ profileName,
265+ key,
266+ ))
267+ continue
268+ }
269+
270+ if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
271+ validationErrors = append(validationErrors, fmt.Sprintf(
272+ "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
273+ profileName,
274+ key,
275+ existingProfile,
276+ ))
277+ continue
278+ }
279+
280+ seenProfileKeys[normalizedKey] = profileName
281+ }
282+ }
283+
284+ return validationErrors
285+}
286+
287+func normalizeBindingKeyString(key string) string {
288+ normalized := strings.ToLower(strings.TrimSpace(key))
289+ if normalized == "" {
290+ return ""
291+ }
292+ return strings.Join(strings.Fields(normalized), "")
293+}
294+
227295 func validateLogging(config *Config) []string {
228296 var validationErrors []string
229297 if config.Logging.MaxAge < 0 {
@@ -256,27 +324,37 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
256324 return validationErrors
257325 }
258326
259- normalized := domainurl.Normalize(config.Workspace.NewPaneURL)
260- parsed, err := url.Parse(normalized)
327+ validationErrors = append(validationErrors, validateWorkspaceURLValue(
328+ "workspace.new_pane_url",
329+ config.Workspace.NewPaneURL,
330+ )...)
331+
332+ return validationErrors
333+}
334+
335+func validateWorkspaceURLValue(fieldPath, value string) []string {
336+ trimmed := strings.TrimSpace(value)
337+ normalized := domainurl.Normalize(trimmed)
338+ candidate := normalized
339+ if rawParsed, rawErr := url.Parse(trimmed); rawErr == nil && rawParsed.Scheme != "" {
340+ candidate = trimmed
341+ }
342+
343+ parsed, err := url.Parse(candidate)
261344 if err != nil {
262- validationErrors = append(validationErrors, fmt.Sprintf(
263- "workspace.new_pane_url must be a valid URL (got: %s)",
264- config.Workspace.NewPaneURL,
265- ))
266- return validationErrors
345+ return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
267346 }
268347
269348 switch parsed.Scheme {
270349 case "http", "https", "dumb", "file", "about":
271- // ok
350+ return nil
272351 default:
273- validationErrors = append(validationErrors, fmt.Sprintf(
274- "workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)",
352+ return []string{fmt.Sprintf(
353+ "%s must use one of: http, https, dumb, file, about (got: %s)",
354+ fieldPath,
275355 parsed.Scheme,
276- ))
356+ )}
277357 }
278-
279- return validationErrors
280358 }
281359
282360 func validateOmnibox(config *Config) []string {
@@ -22,6 +22,7 @@ func validateConfig(config *Config) error {
22 validationErrors = append(validationErrors, validatePaneMode(config)...)22 validationErrors = append(validationErrors, validatePaneMode(config)...)
23 validationErrors = append(validationErrors, validateTabBar(config)...)23 validationErrors = append(validationErrors, validateTabBar(config)...)
24 validationErrors = append(validationErrors, validateTabMode(config)...)24 validationErrors = append(validationErrors, validateTabMode(config)...)
25+ validationErrors = append(validationErrors, validateFloatingPane(config)...)
25 validationErrors = append(validationErrors, validateLogging(config)...)26 validationErrors = append(validationErrors, validateLogging(config)...)
26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)27 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
27 validationErrors = append(validationErrors, validateOmnibox(config)...)28 validationErrors = append(validationErrors, validateOmnibox(config)...)
@@ -224,6 +225,73 @@ func validateTabMode(config *Config) []string {
224 return validationErrors225 return validationErrors
225 }226 }
226 227
228+func validateFloatingPane(config *Config) []string {
229+ var validationErrors []string
230+ seenProfileKeys := make(map[string]string)
231+
232+ if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
233+ validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
234+ }
235+
236+ if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
237+ validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
238+ }
239+
240+ for profileName, profile := range config.Workspace.FloatingPane.Profiles {
241+ if strings.TrimSpace(profile.URL) == "" {
242+ validationErrors = append(validationErrors, fmt.Sprintf(
243+ "workspace.floating_pane.profiles.%s.url is required",
244+ profileName,
245+ ))
246+ } else {
247+ validationErrors = append(validationErrors, validateWorkspaceURLValue(
248+ fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
249+ profile.URL,
250+ )...)
251+ }
252+ if len(profile.Keys) == 0 {
253+ validationErrors = append(validationErrors, fmt.Sprintf(
254+ "workspace.floating_pane.profiles.%s.keys must have at least one entry",
255+ profileName,
256+ ))
257+ }
258+
259+ for _, key := range profile.Keys {
260+ normalizedKey := normalizeBindingKeyString(key)
261+ if normalizedKey == "" {
262+ validationErrors = append(validationErrors, fmt.Sprintf(
263+ "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
264+ profileName,
265+ key,
266+ ))
267+ continue
268+ }
269+
270+ if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
271+ validationErrors = append(validationErrors, fmt.Sprintf(
272+ "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
273+ profileName,
274+ key,
275+ existingProfile,
276+ ))
277+ continue
278+ }
279+
280+ seenProfileKeys[normalizedKey] = profileName
281+ }
282+ }
283+
284+ return validationErrors
285+}
286+
287+func normalizeBindingKeyString(key string) string {
288+ normalized := strings.ToLower(strings.TrimSpace(key))
289+ if normalized == "" {
290+ return ""
291+ }
292+ return strings.Join(strings.Fields(normalized), "")
293+}
294+
227 func validateLogging(config *Config) []string {295 func validateLogging(config *Config) []string {
228 var validationErrors []string296 var validationErrors []string
229 if config.Logging.MaxAge < 0 {297 if config.Logging.MaxAge < 0 {
@@ -256,27 +324,37 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
256 return validationErrors324 return validationErrors
257 }325 }
258 326
259- normalized := domainurl.Normalize(config.Workspace.NewPaneURL)327+ validationErrors = append(validationErrors, validateWorkspaceURLValue(
260- parsed, err := url.Parse(normalized)328+ "workspace.new_pane_url",
329+ config.Workspace.NewPaneURL,
330+ )...)
331+
332+ return validationErrors
333+}
334+
335+func validateWorkspaceURLValue(fieldPath, value string) []string {
336+ trimmed := strings.TrimSpace(value)
337+ normalized := domainurl.Normalize(trimmed)
338+ candidate := normalized
339+ if rawParsed, rawErr := url.Parse(trimmed); rawErr == nil && rawParsed.Scheme != "" {
340+ candidate = trimmed
341+ }
342+
343+ parsed, err := url.Parse(candidate)
261 if err != nil {344 if err != nil {
262- validationErrors = append(validationErrors, fmt.Sprintf(345+ return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
263- "workspace.new_pane_url must be a valid URL (got: %s)",
264- config.Workspace.NewPaneURL,
265- ))
266- return validationErrors
267 }346 }
268 347
269 switch parsed.Scheme {348 switch parsed.Scheme {
270 case "http", "https", "dumb", "file", "about":349 case "http", "https", "dumb", "file", "about":
271- // ok350+ return nil
272 default:351 default:
273- validationErrors = append(validationErrors, fmt.Sprintf(352+ return []string{fmt.Sprintf(
274- "workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)",353+ "%s must use one of: http, https, dumb, file, about (got: %s)",
354+ fieldPath,
275 parsed.Scheme,355 parsed.Scheme,
276- ))356+ )}
277 }357 }
278-
279- return validationErrors
280 }358 }
281 359
282 func validateOmnibox(config *Config) []string {360 func validateOmnibox(config *Config) []string {
M internal/ui/app.go +102 -24
@@ -145,6 +145,7 @@ type App struct {
145145 type floatingWorkspaceSession struct {
146146 paneID entity.PaneID
147147 pane *component.FloatingPane
148+ paneView *component.PaneView
148149 webView *webkit.WebView
149150 overlay layout.OverlayWidget
150151 widget layout.Widget
@@ -1956,7 +1957,8 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
19561957 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
19571958 if session.widget != nil {
19581959 wsView.AddWorkspaceOverlayWidget(session.widget)
1959- session.widget.SetVisible(session.pane.IsVisible())
1960+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1961+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
19601962 }
19611963 }
19621964 a.syncFloatingFocus()
@@ -2102,11 +2104,55 @@ func decorateFloatingOverlay(overlay layout.OverlayWidget) {
21022104 return
21032105 }
21042106
2107+ overlay.SetHexpand(false)
2108+ overlay.SetVexpand(false)
21052109 overlay.AddCssClass("floating-pane-container")
21062110 overlay.AddCssClass("pane-border")
21072111 overlay.AddCssClass("pane-active")
21082112 }
21092113
2114+// showFloatingWidget makes a floating pane widget interactive and visible.
2115+// Uses opacity instead of SetVisible to keep the GPU surface allocated,
2116+// preventing the black flash when WebKit needs to re-composite after unmap.
2117+func showFloatingWidget(w layout.Widget) {
2118+ if w == nil {
2119+ return
2120+ }
2121+ w.SetOpacity(1)
2122+ w.SetCanTarget(true)
2123+ w.SetCanFocus(true)
2124+}
2125+
2126+// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2127+// Uses opacity 0 instead of SetVisible(false) so GTK keeps the widget mapped
2128+// and WebKit retains its rendered surface in GPU memory.
2129+func hideFloatingWidget(w layout.Widget) {
2130+ if w == nil {
2131+ return
2132+ }
2133+ w.SetOpacity(0)
2134+ w.SetCanTarget(false)
2135+ w.SetCanFocus(false)
2136+}
2137+
2138+// setFloatingWidgetShown applies show or hide based on the visible flag.
2139+func setFloatingWidgetShown(w layout.Widget, visible bool) {
2140+ if visible {
2141+ showFloatingWidget(w)
2142+ } else {
2143+ hideFloatingWidget(w)
2144+ }
2145+}
2146+
2147+func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
2148+ if workspaceOverlay == nil || floatingOverlay == nil {
2149+ return
2150+ }
2151+
2152+ workspaceOverlay.SetMeasureOverlay(floatingOverlay, true)
2153+ workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2154+}
2155+
21102156 func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
21112157 if a.deps != nil && a.deps.Config != nil {
21122158 return a.deps.Config.Workspace.FloatingPane
@@ -2128,6 +2174,10 @@ func (a *App) ensureFloatingSession(
21282174 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
21292175 if session.widget != nil {
21302176 wsView.AddWorkspaceOverlayWidget(session.widget)
2177+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
2178+ }
2179+ if session.paneView != nil {
2180+ wsView.RegisterPaneView(session.paneID, session.paneView)
21312181 }
21322182 return session, nil
21332183 }
@@ -2155,13 +2205,27 @@ func (a *App) ensureFloatingSession(
21552205 webViewWidget.SetHexpand(true)
21562206 webViewWidget.SetVexpand(true)
21572207
2158- overlay := a.widgetFactory.NewOverlay()
2159- overlay.SetChild(webViewWidget)
2160- decorateFloatingOverlay(overlay)
2161- overlay.SetHalign(gtk.AlignCenterValue)
2162- overlay.SetValign(gtk.AlignCenterValue)
2163- overlay.SetVisible(false)
2164- wsView.AddWorkspaceOverlayWidget(overlay)
2208+ // Wrap the WebView in a PaneView so it gets progress bar, loading
2209+ // skeleton, and link status overlays — the same widget hierarchy as
2210+ // regular workspace panes. This allows the content coordinator's
2211+ // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2212+ // and update the floating pane automatically.
2213+ pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2214+ pv.SetActive(true)
2215+ // Hide loading skeleton — floating panes have their own themed container.
2216+ pv.HideLoadingSkeleton()
2217+ wsView.RegisterPaneView(paneID, pv)
2218+
2219+ // Use the PaneView's overlay directly as the floating overlay.
2220+ // This avoids nesting two overlays which causes GTK4 allocation
2221+ // issues (inner overlay expanding to fill the parent's allocation).
2222+ pvOverlay := pv.Overlay()
2223+ decorateFloatingOverlay(pvOverlay)
2224+ pvOverlay.SetHalign(gtk.AlignCenterValue)
2225+ pvOverlay.SetValign(gtk.AlignCenterValue)
2226+ hideFloatingWidget(pvOverlay)
2227+ wsView.AddWorkspaceOverlayWidget(pvOverlay)
2228+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
21652229
21662230 floatingCfg := a.currentFloatingConfig()
21672231 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
@@ -2175,11 +2239,12 @@ func (a *App) ensureFloatingSession(
21752239 })
21762240
21772241 session := &floatingWorkspaceSession{
2178- paneID: paneID,
2179- pane: floatingPane,
2180- webView: wv,
2181- overlay: overlay,
2182- widget: overlay,
2242+ paneID: paneID,
2243+ pane: floatingPane,
2244+ paneView: pv,
2245+ webView: wv,
2246+ overlay: pvOverlay,
2247+ widget: pvOverlay,
21832248 }
21842249 a.floatingSessions[key] = session
21852250 return session, nil
@@ -2208,8 +2273,13 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta
22082273 session.pane.Hide(ctx)
22092274 }
22102275 a.hideFloatingOmnibox(ctx, session)
2211- if wsView := a.workspaceViews[tabID]; wsView != nil && session.widget != nil {
2212- wsView.RemoveWorkspaceOverlayWidget(session.widget)
2276+ if wsView := a.workspaceViews[tabID]; wsView != nil {
2277+ if session.widget != nil {
2278+ wsView.RemoveWorkspaceOverlayWidget(session.widget)
2279+ }
2280+ if session.paneID != "" {
2281+ wsView.UnregisterPaneView(session.paneID)
2282+ }
22132283 }
22142284 if a.contentCoord != nil && session.paneID != "" {
22152285 a.contentCoord.ReleaseWebView(ctx, session.paneID)
@@ -2414,9 +2484,7 @@ func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspac
24142484 a.stopFloatingResizeWatcher(session)
24152485 session.pane.Hide(ctx)
24162486 a.hideFloatingOmnibox(ctx, session)
2417- if session.widget != nil {
2418- session.widget.SetVisible(false)
2419- }
2487+ hideFloatingWidget(session.widget)
24202488 }
24212489
24222490 func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
@@ -2474,12 +2542,24 @@ func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url
24742542 if err != nil {
24752543 return err
24762544 }
2545+ hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2546+ isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
2547+
2548+ if hasURL && isProfileSession && session.pane.IsVisible() {
2549+ a.hideFloatingSession(ctx, session)
2550+ a.syncFloatingFocus()
2551+ return nil
2552+ }
2553+
24772554 a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
24782555
2479- hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
24802556 if hasURL {
2481- if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2482- return err
2557+ if isProfileSession && session.pane.SessionStarted() {
2558+ session.pane.Show()
2559+ } else {
2560+ if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2561+ return err
2562+ }
24832563 }
24842564 a.hideFloatingOmnibox(ctx, session)
24852565 } else {
@@ -2492,9 +2572,7 @@ func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url
24922572 }
24932573
24942574 a.resizeFloatingWidget(session)
2495- if session.widget != nil {
2496- session.widget.SetVisible(session.pane.IsVisible())
2497- }
2575+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
24982576 a.startFloatingResizeWatcher(session)
24992577 a.syncFloatingFocus()
25002578
@@ -145,6 +145,7 @@ type App struct {
145 type floatingWorkspaceSession struct {145 type floatingWorkspaceSession struct {
146 paneID entity.PaneID146 paneID entity.PaneID
147 pane *component.FloatingPane147 pane *component.FloatingPane
148+ paneView *component.PaneView
148 webView *webkit.WebView149 webView *webkit.WebView
149 overlay layout.OverlayWidget150 overlay layout.OverlayWidget
150 widget layout.Widget151 widget layout.Widget
@@ -1956,7 +1957,8 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
1956 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())1957 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1957 if session.widget != nil {1958 if session.widget != nil {
1958 wsView.AddWorkspaceOverlayWidget(session.widget)1959 wsView.AddWorkspaceOverlayWidget(session.widget)
1959- session.widget.SetVisible(session.pane.IsVisible())1960+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1961+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
1960 }1962 }
1961 }1963 }
1962 a.syncFloatingFocus()1964 a.syncFloatingFocus()
@@ -2102,11 +2104,55 @@ func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2102 return2104 return
2103 }2105 }
2104 2106
2107+ overlay.SetHexpand(false)
2108+ overlay.SetVexpand(false)
2105 overlay.AddCssClass("floating-pane-container")2109 overlay.AddCssClass("floating-pane-container")
2106 overlay.AddCssClass("pane-border")2110 overlay.AddCssClass("pane-border")
2107 overlay.AddCssClass("pane-active")2111 overlay.AddCssClass("pane-active")
2108 }2112 }
2109 2113
2114+// showFloatingWidget makes a floating pane widget interactive and visible.
2115+// Uses opacity instead of SetVisible to keep the GPU surface allocated,
2116+// preventing the black flash when WebKit needs to re-composite after unmap.
2117+func showFloatingWidget(w layout.Widget) {
2118+ if w == nil {
2119+ return
2120+ }
2121+ w.SetOpacity(1)
2122+ w.SetCanTarget(true)
2123+ w.SetCanFocus(true)
2124+}
2125+
2126+// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2127+// Uses opacity 0 instead of SetVisible(false) so GTK keeps the widget mapped
2128+// and WebKit retains its rendered surface in GPU memory.
2129+func hideFloatingWidget(w layout.Widget) {
2130+ if w == nil {
2131+ return
2132+ }
2133+ w.SetOpacity(0)
2134+ w.SetCanTarget(false)
2135+ w.SetCanFocus(false)
2136+}
2137+
2138+// setFloatingWidgetShown applies show or hide based on the visible flag.
2139+func setFloatingWidgetShown(w layout.Widget, visible bool) {
2140+ if visible {
2141+ showFloatingWidget(w)
2142+ } else {
2143+ hideFloatingWidget(w)
2144+ }
2145+}
2146+
2147+func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
2148+ if workspaceOverlay == nil || floatingOverlay == nil {
2149+ return
2150+ }
2151+
2152+ workspaceOverlay.SetMeasureOverlay(floatingOverlay, true)
2153+ workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2154+}
2155+
2110 func (a *App) currentFloatingConfig() config.FloatingPaneConfig {2156 func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2111 if a.deps != nil && a.deps.Config != nil {2157 if a.deps != nil && a.deps.Config != nil {
2112 return a.deps.Config.Workspace.FloatingPane2158 return a.deps.Config.Workspace.FloatingPane
@@ -2128,6 +2174,10 @@ func (a *App) ensureFloatingSession(
2128 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())2174 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2129 if session.widget != nil {2175 if session.widget != nil {
2130 wsView.AddWorkspaceOverlayWidget(session.widget)2176 wsView.AddWorkspaceOverlayWidget(session.widget)
2177+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
2178+ }
2179+ if session.paneView != nil {
2180+ wsView.RegisterPaneView(session.paneID, session.paneView)
2131 }2181 }
2132 return session, nil2182 return session, nil
2133 }2183 }
@@ -2155,13 +2205,27 @@ func (a *App) ensureFloatingSession(
2155 webViewWidget.SetHexpand(true)2205 webViewWidget.SetHexpand(true)
2156 webViewWidget.SetVexpand(true)2206 webViewWidget.SetVexpand(true)
2157 2207
2158- overlay := a.widgetFactory.NewOverlay()2208+ // Wrap the WebView in a PaneView so it gets progress bar, loading
2159- overlay.SetChild(webViewWidget)2209+ // skeleton, and link status overlays — the same widget hierarchy as
2160- decorateFloatingOverlay(overlay)2210+ // regular workspace panes. This allows the content coordinator's
2161- overlay.SetHalign(gtk.AlignCenterValue)2211+ // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2162- overlay.SetValign(gtk.AlignCenterValue)2212+ // and update the floating pane automatically.
2163- overlay.SetVisible(false)2213+ pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2164- wsView.AddWorkspaceOverlayWidget(overlay)2214+ pv.SetActive(true)
2215+ // Hide loading skeleton — floating panes have their own themed container.
2216+ pv.HideLoadingSkeleton()
2217+ wsView.RegisterPaneView(paneID, pv)
2218+
2219+ // Use the PaneView's overlay directly as the floating overlay.
2220+ // This avoids nesting two overlays which causes GTK4 allocation
2221+ // issues (inner overlay expanding to fill the parent's allocation).
2222+ pvOverlay := pv.Overlay()
2223+ decorateFloatingOverlay(pvOverlay)
2224+ pvOverlay.SetHalign(gtk.AlignCenterValue)
2225+ pvOverlay.SetValign(gtk.AlignCenterValue)
2226+ hideFloatingWidget(pvOverlay)
2227+ wsView.AddWorkspaceOverlayWidget(pvOverlay)
2228+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
2165 2229
2166 floatingCfg := a.currentFloatingConfig()2230 floatingCfg := a.currentFloatingConfig()
2167 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{2231 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
@@ -2175,11 +2239,12 @@ func (a *App) ensureFloatingSession(
2175 })2239 })
2176 2240
2177 session := &floatingWorkspaceSession{2241 session := &floatingWorkspaceSession{
2178- paneID: paneID,2242+ paneID: paneID,
2179- pane: floatingPane,2243+ pane: floatingPane,
2180- webView: wv,2244+ paneView: pv,
2181- overlay: overlay,2245+ webView: wv,
2182- widget: overlay,2246+ overlay: pvOverlay,
2247+ widget: pvOverlay,
2183 }2248 }
2184 a.floatingSessions[key] = session2249 a.floatingSessions[key] = session
2185 return session, nil2250 return session, nil
@@ -2208,8 +2273,13 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta
2208 session.pane.Hide(ctx)2273 session.pane.Hide(ctx)
2209 }2274 }
2210 a.hideFloatingOmnibox(ctx, session)2275 a.hideFloatingOmnibox(ctx, session)
2211- if wsView := a.workspaceViews[tabID]; wsView != nil && session.widget != nil {2276+ if wsView := a.workspaceViews[tabID]; wsView != nil {
2212- wsView.RemoveWorkspaceOverlayWidget(session.widget)2277+ if session.widget != nil {
2278+ wsView.RemoveWorkspaceOverlayWidget(session.widget)
2279+ }
2280+ if session.paneID != "" {
2281+ wsView.UnregisterPaneView(session.paneID)
2282+ }
2213 }2283 }
2214 if a.contentCoord != nil && session.paneID != "" {2284 if a.contentCoord != nil && session.paneID != "" {
2215 a.contentCoord.ReleaseWebView(ctx, session.paneID)2285 a.contentCoord.ReleaseWebView(ctx, session.paneID)
@@ -2414,9 +2484,7 @@ func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspac
2414 a.stopFloatingResizeWatcher(session)2484 a.stopFloatingResizeWatcher(session)
2415 session.pane.Hide(ctx)2485 session.pane.Hide(ctx)
2416 a.hideFloatingOmnibox(ctx, session)2486 a.hideFloatingOmnibox(ctx, session)
2417- if session.widget != nil {2487+ hideFloatingWidget(session.widget)
2418- session.widget.SetVisible(false)
2419- }
2420 }2488 }
2421 2489
2422 func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {2490 func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
@@ -2474,12 +2542,24 @@ func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url
2474 if err != nil {2542 if err != nil {
2475 return err2543 return err
2476 }2544 }
2545+ hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2546+ isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
2547+
2548+ if hasURL && isProfileSession && session.pane.IsVisible() {
2549+ a.hideFloatingSession(ctx, session)
2550+ a.syncFloatingFocus()
2551+ return nil
2552+ }
2553+
2477 a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)2554 a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2478 2555
2479- hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2480 if hasURL {2556 if hasURL {
2481- if err := session.pane.ShowURL(ctx, url[0]); err != nil {2557+ if isProfileSession && session.pane.SessionStarted() {
2482- return err2558+ session.pane.Show()
2559+ } else {
2560+ if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2561+ return err
2562+ }
2483 }2563 }
2484 a.hideFloatingOmnibox(ctx, session)2564 a.hideFloatingOmnibox(ctx, session)
2485 } else {2565 } else {
@@ -2492,9 +2572,7 @@ func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url
2492 }2572 }
2493 2573
2494 a.resizeFloatingWidget(session)2574 a.resizeFloatingWidget(session)
2495- if session.widget != nil {2575+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
2496- session.widget.SetVisible(session.pane.IsVisible())
2497- }
2498 a.startFloatingResizeWatcher(session)2576 a.startFloatingResizeWatcher(session)
2499 a.syncFloatingFocus()2577 a.syncFloatingFocus()
2500 2578
A internal/ui/component/floating_pane.go +243 -0
@@ -0,0 +1,243 @@
1+package component
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+const defaultFloatingPaneURL = "about:blank"
12+
13+// FloatingPaneOptions controls floating pane behavior.
14+type FloatingPaneOptions struct {
15+ WidthPct float64
16+ HeightPct float64
17+ FallbackWidth int
18+ FallbackHeight int
19+ OnNavigate func(ctx context.Context, url string) error
20+}
21+
22+// FloatingPane tracks persistent floating workspace pane state.
23+type FloatingPane struct {
24+ parent layout.OverlayWidget
25+ widthPct float64
26+ heightPct float64
27+ fallbackWidth int
28+ fallbackHeight int
29+ onNavigate func(ctx context.Context, url string) error
30+
31+ mu sync.RWMutex
32+ visible bool
33+ omniboxVisible bool
34+ sessionStarted bool
35+ currentURL string
36+ calculatedWidth int
37+ calculatedHeight int
38+}
39+
40+// NewFloatingPane creates a floating pane state container.
41+func NewFloatingPane(parent layout.OverlayWidget, opts FloatingPaneOptions) *FloatingPane {
42+ if opts.WidthPct <= 0 || opts.WidthPct > 1 {
43+ opts.WidthPct = 0.82
44+ }
45+ if opts.HeightPct <= 0 || opts.HeightPct > 1 {
46+ opts.HeightPct = 0.72
47+ }
48+ if opts.FallbackWidth <= 0 {
49+ opts.FallbackWidth = 1200
50+ }
51+ if opts.FallbackHeight <= 0 {
52+ opts.FallbackHeight = 800
53+ }
54+
55+ fp := &FloatingPane{
56+ parent: parent,
57+ widthPct: opts.WidthPct,
58+ heightPct: opts.HeightPct,
59+ fallbackWidth: opts.FallbackWidth,
60+ fallbackHeight: opts.FallbackHeight,
61+ onNavigate: opts.OnNavigate,
62+ }
63+ fp.Resize()
64+ return fp
65+}
66+
67+// ShowToggle toggles pane visibility; first open initializes a blank session.
68+func (fp *FloatingPane) ShowToggle(ctx context.Context) error {
69+ fp.mu.Lock()
70+ if fp.visible {
71+ fp.visible = false
72+ fp.omniboxVisible = false
73+ fp.mu.Unlock()
74+ return nil
75+ }
76+
77+ navigateToBlank := !fp.sessionStarted
78+ showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL
79+ fp.visible = true
80+ fp.omniboxVisible = showOmnibox
81+ fp.mu.Unlock()
82+
83+ if navigateToBlank {
84+ if err := fp.Navigate(ctx, defaultFloatingPaneURL); err != nil {
85+ fp.mu.Lock()
86+ fp.visible = false
87+ fp.omniboxVisible = false
88+ fp.mu.Unlock()
89+ return err
90+ }
91+ }
92+
93+ return nil
94+}
95+
96+// Show makes the pane visible without navigation.
97+// Use for profile sessions that already have a loaded URL.
98+func (fp *FloatingPane) Show() {
99+ fp.mu.Lock()
100+ defer fp.mu.Unlock()
101+ fp.visible = true
102+ fp.omniboxVisible = false
103+}
104+
105+// SessionStarted reports whether the floating pane has navigated at least once.
106+func (fp *FloatingPane) SessionStarted() bool {
107+ fp.mu.RLock()
108+ defer fp.mu.RUnlock()
109+ return fp.sessionStarted
110+}
111+
112+// ShowURL shows the pane and navigates to a target URL.
113+func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error {
114+ url = strings.TrimSpace(url)
115+ if url == "" {
116+ return fp.ShowToggle(ctx)
117+ }
118+
119+ fp.mu.Lock()
120+ fp.visible = true
121+ fp.omniboxVisible = false
122+ shouldNavigate := !fp.sessionStarted || fp.currentURL != url
123+ fp.mu.Unlock()
124+
125+ if shouldNavigate {
126+ return fp.Navigate(ctx, url)
127+ }
128+
129+ return nil
130+}
131+
132+// Hide hides the pane while preserving web session state.
133+func (fp *FloatingPane) Hide(context.Context) {
134+ fp.mu.Lock()
135+ defer fp.mu.Unlock()
136+ fp.visible = false
137+ fp.omniboxVisible = false
138+}
139+
140+// Navigate loads a URL into the floating pane's persistent session.
141+func (fp *FloatingPane) Navigate(ctx context.Context, url string) error {
142+ url = strings.TrimSpace(url)
143+ if url == "" {
144+ return nil
145+ }
146+
147+ fp.mu.RLock()
148+ onNavigate := fp.onNavigate
149+ fp.mu.RUnlock()
150+
151+ if onNavigate != nil {
152+ if err := onNavigate(ctx, url); err != nil {
153+ return err
154+ }
155+ }
156+
157+ fp.mu.Lock()
158+ fp.currentURL = url
159+ fp.sessionStarted = true
160+ fp.mu.Unlock()
161+
162+ return nil
163+}
164+
165+// Resize recalculates pane dimensions from parent overlay allocation.
166+func (fp *FloatingPane) Resize() {
167+ fp.mu.RLock()
168+ parent := fp.parent
169+ widthPct := fp.widthPct
170+ heightPct := fp.heightPct
171+ fallbackWidth := fp.fallbackWidth
172+ fallbackHeight := fp.fallbackHeight
173+ fp.mu.RUnlock()
174+
175+ width, height := CalculateOverlayDimensions(
176+ parent,
177+ widthPct,
178+ heightPct,
179+ fallbackWidth,
180+ fallbackHeight,
181+ )
182+
183+ fp.mu.Lock()
184+ fp.calculatedWidth = width
185+ fp.calculatedHeight = height
186+ fp.mu.Unlock()
187+}
188+
189+// SetParentOverlay updates the workspace overlay used for sizing calculations.
190+func (fp *FloatingPane) SetParentOverlay(parent layout.OverlayWidget) {
191+ fp.mu.Lock()
192+ fp.parent = parent
193+ fp.mu.Unlock()
194+ fp.Resize()
195+}
196+
197+// Dimensions returns the last calculated pane dimensions.
198+func (fp *FloatingPane) Dimensions() (width, height int) {
199+ fp.mu.RLock()
200+ defer fp.mu.RUnlock()
201+ return fp.calculatedWidth, fp.calculatedHeight
202+}
203+
204+// CurrentURL returns the current URL loaded in the floating session.
205+func (fp *FloatingPane) CurrentURL() string {
206+ fp.mu.RLock()
207+ defer fp.mu.RUnlock()
208+ return fp.currentURL
209+}
210+
211+// IsVisible reports whether the floating pane is currently visible.
212+func (fp *FloatingPane) IsVisible() bool {
213+ fp.mu.RLock()
214+ defer fp.mu.RUnlock()
215+ return fp.visible
216+}
217+
218+// IsOmniboxVisible reports whether the floating pane omnibox is visible.
219+func (fp *FloatingPane) IsOmniboxVisible() bool {
220+ fp.mu.RLock()
221+ defer fp.mu.RUnlock()
222+ return fp.omniboxVisible
223+}
224+
225+// SetOmniboxVisible updates omnibox visibility state for the floating pane.
226+func (fp *FloatingPane) SetOmniboxVisible(visible bool) {
227+ fp.mu.Lock()
228+ fp.omniboxVisible = visible
229+ fp.mu.Unlock()
230+}
231+
232+// RecordLoadedURL updates floating pane URL state from external navigation events.
233+func (fp *FloatingPane) RecordLoadedURL(url string) {
234+ url = strings.TrimSpace(url)
235+ if url == "" {
236+ return
237+ }
238+
239+ fp.mu.Lock()
240+ fp.currentURL = url
241+ fp.sessionStarted = true
242+ fp.mu.Unlock()
243+}
@@ -0,0 +1,243 @@
1+package component
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+const defaultFloatingPaneURL = "about:blank"
12+
13+// FloatingPaneOptions controls floating pane behavior.
14+type FloatingPaneOptions struct {
15+ WidthPct float64
16+ HeightPct float64
17+ FallbackWidth int
18+ FallbackHeight int
19+ OnNavigate func(ctx context.Context, url string) error
20+}
21+
22+// FloatingPane tracks persistent floating workspace pane state.
23+type FloatingPane struct {
24+ parent layout.OverlayWidget
25+ widthPct float64
26+ heightPct float64
27+ fallbackWidth int
28+ fallbackHeight int
29+ onNavigate func(ctx context.Context, url string) error
30+
31+ mu sync.RWMutex
32+ visible bool
33+ omniboxVisible bool
34+ sessionStarted bool
35+ currentURL string
36+ calculatedWidth int
37+ calculatedHeight int
38+}
39+
40+// NewFloatingPane creates a floating pane state container.
41+func NewFloatingPane(parent layout.OverlayWidget, opts FloatingPaneOptions) *FloatingPane {
42+ if opts.WidthPct <= 0 || opts.WidthPct > 1 {
43+ opts.WidthPct = 0.82
44+ }
45+ if opts.HeightPct <= 0 || opts.HeightPct > 1 {
46+ opts.HeightPct = 0.72
47+ }
48+ if opts.FallbackWidth <= 0 {
49+ opts.FallbackWidth = 1200
50+ }
51+ if opts.FallbackHeight <= 0 {
52+ opts.FallbackHeight = 800
53+ }
54+
55+ fp := &FloatingPane{
56+ parent: parent,
57+ widthPct: opts.WidthPct,
58+ heightPct: opts.HeightPct,
59+ fallbackWidth: opts.FallbackWidth,
60+ fallbackHeight: opts.FallbackHeight,
61+ onNavigate: opts.OnNavigate,
62+ }
63+ fp.Resize()
64+ return fp
65+}
66+
67+// ShowToggle toggles pane visibility; first open initializes a blank session.
68+func (fp *FloatingPane) ShowToggle(ctx context.Context) error {
69+ fp.mu.Lock()
70+ if fp.visible {
71+ fp.visible = false
72+ fp.omniboxVisible = false
73+ fp.mu.Unlock()
74+ return nil
75+ }
76+
77+ navigateToBlank := !fp.sessionStarted
78+ showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL
79+ fp.visible = true
80+ fp.omniboxVisible = showOmnibox
81+ fp.mu.Unlock()
82+
83+ if navigateToBlank {
84+ if err := fp.Navigate(ctx, defaultFloatingPaneURL); err != nil {
85+ fp.mu.Lock()
86+ fp.visible = false
87+ fp.omniboxVisible = false
88+ fp.mu.Unlock()
89+ return err
90+ }
91+ }
92+
93+ return nil
94+}
95+
96+// Show makes the pane visible without navigation.
97+// Use for profile sessions that already have a loaded URL.
98+func (fp *FloatingPane) Show() {
99+ fp.mu.Lock()
100+ defer fp.mu.Unlock()
101+ fp.visible = true
102+ fp.omniboxVisible = false
103+}
104+
105+// SessionStarted reports whether the floating pane has navigated at least once.
106+func (fp *FloatingPane) SessionStarted() bool {
107+ fp.mu.RLock()
108+ defer fp.mu.RUnlock()
109+ return fp.sessionStarted
110+}
111+
112+// ShowURL shows the pane and navigates to a target URL.
113+func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error {
114+ url = strings.TrimSpace(url)
115+ if url == "" {
116+ return fp.ShowToggle(ctx)
117+ }
118+
119+ fp.mu.Lock()
120+ fp.visible = true
121+ fp.omniboxVisible = false
122+ shouldNavigate := !fp.sessionStarted || fp.currentURL != url
123+ fp.mu.Unlock()
124+
125+ if shouldNavigate {
126+ return fp.Navigate(ctx, url)
127+ }
128+
129+ return nil
130+}
131+
132+// Hide hides the pane while preserving web session state.
133+func (fp *FloatingPane) Hide(context.Context) {
134+ fp.mu.Lock()
135+ defer fp.mu.Unlock()
136+ fp.visible = false
137+ fp.omniboxVisible = false
138+}
139+
140+// Navigate loads a URL into the floating pane's persistent session.
141+func (fp *FloatingPane) Navigate(ctx context.Context, url string) error {
142+ url = strings.TrimSpace(url)
143+ if url == "" {
144+ return nil
145+ }
146+
147+ fp.mu.RLock()
148+ onNavigate := fp.onNavigate
149+ fp.mu.RUnlock()
150+
151+ if onNavigate != nil {
152+ if err := onNavigate(ctx, url); err != nil {
153+ return err
154+ }
155+ }
156+
157+ fp.mu.Lock()
158+ fp.currentURL = url
159+ fp.sessionStarted = true
160+ fp.mu.Unlock()
161+
162+ return nil
163+}
164+
165+// Resize recalculates pane dimensions from parent overlay allocation.
166+func (fp *FloatingPane) Resize() {
167+ fp.mu.RLock()
168+ parent := fp.parent
169+ widthPct := fp.widthPct
170+ heightPct := fp.heightPct
171+ fallbackWidth := fp.fallbackWidth
172+ fallbackHeight := fp.fallbackHeight
173+ fp.mu.RUnlock()
174+
175+ width, height := CalculateOverlayDimensions(
176+ parent,
177+ widthPct,
178+ heightPct,
179+ fallbackWidth,
180+ fallbackHeight,
181+ )
182+
183+ fp.mu.Lock()
184+ fp.calculatedWidth = width
185+ fp.calculatedHeight = height
186+ fp.mu.Unlock()
187+}
188+
189+// SetParentOverlay updates the workspace overlay used for sizing calculations.
190+func (fp *FloatingPane) SetParentOverlay(parent layout.OverlayWidget) {
191+ fp.mu.Lock()
192+ fp.parent = parent
193+ fp.mu.Unlock()
194+ fp.Resize()
195+}
196+
197+// Dimensions returns the last calculated pane dimensions.
198+func (fp *FloatingPane) Dimensions() (width, height int) {
199+ fp.mu.RLock()
200+ defer fp.mu.RUnlock()
201+ return fp.calculatedWidth, fp.calculatedHeight
202+}
203+
204+// CurrentURL returns the current URL loaded in the floating session.
205+func (fp *FloatingPane) CurrentURL() string {
206+ fp.mu.RLock()
207+ defer fp.mu.RUnlock()
208+ return fp.currentURL
209+}
210+
211+// IsVisible reports whether the floating pane is currently visible.
212+func (fp *FloatingPane) IsVisible() bool {
213+ fp.mu.RLock()
214+ defer fp.mu.RUnlock()
215+ return fp.visible
216+}
217+
218+// IsOmniboxVisible reports whether the floating pane omnibox is visible.
219+func (fp *FloatingPane) IsOmniboxVisible() bool {
220+ fp.mu.RLock()
221+ defer fp.mu.RUnlock()
222+ return fp.omniboxVisible
223+}
224+
225+// SetOmniboxVisible updates omnibox visibility state for the floating pane.
226+func (fp *FloatingPane) SetOmniboxVisible(visible bool) {
227+ fp.mu.Lock()
228+ fp.omniboxVisible = visible
229+ fp.mu.Unlock()
230+}
231+
232+// RecordLoadedURL updates floating pane URL state from external navigation events.
233+func (fp *FloatingPane) RecordLoadedURL(url string) {
234+ url = strings.TrimSpace(url)
235+ if url == "" {
236+ return
237+ }
238+
239+ fp.mu.Lock()
240+ fp.currentURL = url
241+ fp.sessionStarted = true
242+ fp.mu.Unlock()
243+}
A internal/ui/component/floating_pane_test.go +151 -0
@@ -0,0 +1,151 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestFloatingPane_HideShowPreservesState(t *testing.T) {
14+ overlay := layoutmocks.NewMockOverlayWidget(t)
15+ overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
16+ overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
17+
18+ navigated := make([]string, 0)
19+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
20+ WidthPct: 0.82,
21+ HeightPct: 0.72,
22+ FallbackWidth: 1200,
23+ FallbackHeight: 800,
24+ OnNavigate: func(_ context.Context, url string) error {
25+ navigated = append(navigated, url)
26+ return nil
27+ },
28+ })
29+
30+ require.NoError(t, fp.ShowToggle(context.Background()))
31+ require.Equal(t, "about:blank", fp.CurrentURL())
32+
33+ require.NoError(t, fp.Navigate(context.Background(), "https://google.com"))
34+ fp.Hide(context.Background())
35+ require.NoError(t, fp.ShowToggle(context.Background()))
36+
37+ assert.Equal(t, "https://google.com", fp.CurrentURL())
38+ assert.False(t, fp.IsOmniboxVisible())
39+ assert.Equal(t, []string{"about:blank", "https://google.com"}, navigated)
40+}
41+
42+func TestFloatingPane_ShowURLLoadsTarget(t *testing.T) {
43+ overlay := layoutmocks.NewMockOverlayWidget(t)
44+ overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
45+ overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
46+
47+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
48+ WidthPct: 0.82,
49+ HeightPct: 0.72,
50+ FallbackWidth: 1000,
51+ FallbackHeight: 700,
52+ })
53+
54+ require.NoError(t, fp.ShowURL(context.Background(), "https://github.com"))
55+
56+ assert.True(t, fp.IsVisible())
57+ assert.False(t, fp.IsOmniboxVisible())
58+ assert.Equal(t, "https://github.com", fp.CurrentURL())
59+}
60+
61+func TestFloatingPane_ResizeUsesWorkspacePercentages(t *testing.T) {
62+ overlay := layoutmocks.NewMockOverlayWidget(t)
63+ overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
64+ overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
65+
66+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
67+ WidthPct: 0.82,
68+ HeightPct: 0.72,
69+ FallbackWidth: 1000,
70+ FallbackHeight: 700,
71+ })
72+
73+ fp.Resize()
74+ width, height := fp.Dimensions()
75+
76+ assert.Equal(t, 820, width)
77+ assert.Equal(t, 504, height)
78+}
79+
80+func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
81+ overlay := layoutmocks.NewMockOverlayWidget(t)
82+ overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
83+ overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
84+
85+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
86+ WidthPct: 0.82,
87+ HeightPct: 0.72,
88+ FallbackWidth: 1200,
89+ FallbackHeight: 800,
90+ })
91+
92+ require.NoError(t, fp.ShowToggle(context.Background()))
93+
94+ assert.True(t, fp.IsVisible())
95+ assert.True(t, fp.IsOmniboxVisible())
96+ assert.Equal(t, "about:blank", fp.CurrentURL())
97+}
98+
99+func TestFloatingPane_SetOmniboxVisible(t *testing.T) {
100+ fp := NewFloatingPane(nil, FloatingPaneOptions{
101+ WidthPct: 0.82,
102+ HeightPct: 0.72,
103+ FallbackWidth: 1200,
104+ FallbackHeight: 800,
105+ })
106+
107+ fp.SetOmniboxVisible(true)
108+ assert.True(t, fp.IsOmniboxVisible())
109+
110+ fp.SetOmniboxVisible(false)
111+ assert.False(t, fp.IsOmniboxVisible())
112+}
113+
114+func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) {
115+ overlayA := layoutmocks.NewMockOverlayWidget(t)
116+ overlayA.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
117+ overlayA.EXPECT().GetAllocatedHeight().Return(800).Maybe()
118+
119+ overlayB := layoutmocks.NewMockOverlayWidget(t)
120+ overlayB.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
121+ overlayB.EXPECT().GetAllocatedHeight().Return(700).Maybe()
122+
123+ fp := NewFloatingPane(overlayA, FloatingPaneOptions{
124+ WidthPct: 0.82,
125+ HeightPct: 0.72,
126+ FallbackWidth: 1200,
127+ FallbackHeight: 800,
128+ })
129+
130+ var wg sync.WaitGroup
131+ for i := 0; i < 200; i++ {
132+ wg.Add(2)
133+ go func() {
134+ defer wg.Done()
135+ fp.Resize()
136+ }()
137+ go func(iter int) {
138+ defer wg.Done()
139+ if iter%2 == 0 {
140+ fp.SetParentOverlay(overlayA)
141+ return
142+ }
143+ fp.SetParentOverlay(overlayB)
144+ }(i)
145+ }
146+ wg.Wait()
147+
148+ width, height := fp.Dimensions()
149+ assert.Positive(t, width)
150+ assert.Positive(t, height)
151+}
@@ -0,0 +1,151 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestFloatingPane_HideShowPreservesState(t *testing.T) {
14+ overlay := layoutmocks.NewMockOverlayWidget(t)
15+ overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
16+ overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
17+
18+ navigated := make([]string, 0)
19+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
20+ WidthPct: 0.82,
21+ HeightPct: 0.72,
22+ FallbackWidth: 1200,
23+ FallbackHeight: 800,
24+ OnNavigate: func(_ context.Context, url string) error {
25+ navigated = append(navigated, url)
26+ return nil
27+ },
28+ })
29+
30+ require.NoError(t, fp.ShowToggle(context.Background()))
31+ require.Equal(t, "about:blank", fp.CurrentURL())
32+
33+ require.NoError(t, fp.Navigate(context.Background(), "https://google.com"))
34+ fp.Hide(context.Background())
35+ require.NoError(t, fp.ShowToggle(context.Background()))
36+
37+ assert.Equal(t, "https://google.com", fp.CurrentURL())
38+ assert.False(t, fp.IsOmniboxVisible())
39+ assert.Equal(t, []string{"about:blank", "https://google.com"}, navigated)
40+}
41+
42+func TestFloatingPane_ShowURLLoadsTarget(t *testing.T) {
43+ overlay := layoutmocks.NewMockOverlayWidget(t)
44+ overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
45+ overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
46+
47+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
48+ WidthPct: 0.82,
49+ HeightPct: 0.72,
50+ FallbackWidth: 1000,
51+ FallbackHeight: 700,
52+ })
53+
54+ require.NoError(t, fp.ShowURL(context.Background(), "https://github.com"))
55+
56+ assert.True(t, fp.IsVisible())
57+ assert.False(t, fp.IsOmniboxVisible())
58+ assert.Equal(t, "https://github.com", fp.CurrentURL())
59+}
60+
61+func TestFloatingPane_ResizeUsesWorkspacePercentages(t *testing.T) {
62+ overlay := layoutmocks.NewMockOverlayWidget(t)
63+ overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
64+ overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
65+
66+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
67+ WidthPct: 0.82,
68+ HeightPct: 0.72,
69+ FallbackWidth: 1000,
70+ FallbackHeight: 700,
71+ })
72+
73+ fp.Resize()
74+ width, height := fp.Dimensions()
75+
76+ assert.Equal(t, 820, width)
77+ assert.Equal(t, 504, height)
78+}
79+
80+func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
81+ overlay := layoutmocks.NewMockOverlayWidget(t)
82+ overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
83+ overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
84+
85+ fp := NewFloatingPane(overlay, FloatingPaneOptions{
86+ WidthPct: 0.82,
87+ HeightPct: 0.72,
88+ FallbackWidth: 1200,
89+ FallbackHeight: 800,
90+ })
91+
92+ require.NoError(t, fp.ShowToggle(context.Background()))
93+
94+ assert.True(t, fp.IsVisible())
95+ assert.True(t, fp.IsOmniboxVisible())
96+ assert.Equal(t, "about:blank", fp.CurrentURL())
97+}
98+
99+func TestFloatingPane_SetOmniboxVisible(t *testing.T) {
100+ fp := NewFloatingPane(nil, FloatingPaneOptions{
101+ WidthPct: 0.82,
102+ HeightPct: 0.72,
103+ FallbackWidth: 1200,
104+ FallbackHeight: 800,
105+ })
106+
107+ fp.SetOmniboxVisible(true)
108+ assert.True(t, fp.IsOmniboxVisible())
109+
110+ fp.SetOmniboxVisible(false)
111+ assert.False(t, fp.IsOmniboxVisible())
112+}
113+
114+func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) {
115+ overlayA := layoutmocks.NewMockOverlayWidget(t)
116+ overlayA.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
117+ overlayA.EXPECT().GetAllocatedHeight().Return(800).Maybe()
118+
119+ overlayB := layoutmocks.NewMockOverlayWidget(t)
120+ overlayB.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
121+ overlayB.EXPECT().GetAllocatedHeight().Return(700).Maybe()
122+
123+ fp := NewFloatingPane(overlayA, FloatingPaneOptions{
124+ WidthPct: 0.82,
125+ HeightPct: 0.72,
126+ FallbackWidth: 1200,
127+ FallbackHeight: 800,
128+ })
129+
130+ var wg sync.WaitGroup
131+ for i := 0; i < 200; i++ {
132+ wg.Add(2)
133+ go func() {
134+ defer wg.Done()
135+ fp.Resize()
136+ }()
137+ go func(iter int) {
138+ defer wg.Done()
139+ if iter%2 == 0 {
140+ fp.SetParentOverlay(overlayA)
141+ return
142+ }
143+ fp.SetParentOverlay(overlayB)
144+ }(i)
145+ }
146+ wg.Wait()
147+
148+ width, height := fp.Dimensions()
149+ assert.Positive(t, width)
150+ assert.Positive(t, height)
151+}
M internal/ui/component/modal_sizing.go +30 -0
@@ -138,6 +138,36 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
138138 return width, marginTop
139139 }
140140
141+// CalculateOverlayDimensions computes width and height from overlay allocation percentages.
142+func CalculateOverlayDimensions(
143+ parent layout.OverlayWidget,
144+ widthPct, heightPct float64,
145+ fallbackWidth, fallbackHeight int,
146+) (width, height int) {
147+ parentWidth := fallbackWidth
148+ parentHeight := fallbackHeight
149+
150+ if parent != nil {
151+ if allocatedWidth := parent.GetAllocatedWidth(); allocatedWidth >= 100 {
152+ parentWidth = allocatedWidth
153+ }
154+ if allocatedHeight := parent.GetAllocatedHeight(); allocatedHeight >= 100 {
155+ parentHeight = allocatedHeight
156+ }
157+ }
158+
159+ if widthPct <= 0 || widthPct > 1 {
160+ widthPct = 1.0
161+ }
162+ if heightPct <= 0 || heightPct > 1 {
163+ heightPct = 1.0
164+ }
165+
166+ width = int(float64(parentWidth) * widthPct)
167+ height = int(float64(parentHeight) * heightPct)
168+ return width, height
169+}
170+
141171 // SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.
142172 // Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.
143173 func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {
@@ -138,6 +138,36 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
138 return width, marginTop138 return width, marginTop
139 }139 }
140 140
141+// CalculateOverlayDimensions computes width and height from overlay allocation percentages.
142+func CalculateOverlayDimensions(
143+ parent layout.OverlayWidget,
144+ widthPct, heightPct float64,
145+ fallbackWidth, fallbackHeight int,
146+) (width, height int) {
147+ parentWidth := fallbackWidth
148+ parentHeight := fallbackHeight
149+
150+ if parent != nil {
151+ if allocatedWidth := parent.GetAllocatedWidth(); allocatedWidth >= 100 {
152+ parentWidth = allocatedWidth
153+ }
154+ if allocatedHeight := parent.GetAllocatedHeight(); allocatedHeight >= 100 {
155+ parentHeight = allocatedHeight
156+ }
157+ }
158+
159+ if widthPct <= 0 || widthPct > 1 {
160+ widthPct = 1.0
161+ }
162+ if heightPct <= 0 || heightPct > 1 {
163+ heightPct = 1.0
164+ }
165+
166+ width = int(float64(parentWidth) * widthPct)
167+ height = int(float64(parentHeight) * heightPct)
168+ return width, height
169+}
170+
141 // SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.171 // SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.
142 // Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.172 // Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.
143 func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {173 func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {
M internal/ui/component/workspace_view.go +65 -0
@@ -56,6 +56,7 @@ type WorkspaceView struct {
5656 // Hover suppression for keyboard navigation (Issue #89)
5757 // Prevents hover focus from overriding keyboard-initiated focus changes
5858 hoverSuppressedUntil time.Time
59+ hoverFocusLocked atomic.Bool
5960
6061 // newPaneFocusLock suppresses hover focus after a new pane is created.
6162 // Rule: new pane creation always wins focus. Hover focus is blocked
@@ -116,6 +117,10 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
116117 return
117118 }
118119
120+ if a.wv.IsHoverFocusLocked() {
121+ return
122+ }
123+
119124 // Skip if new pane was just created — focus stays on new pane
120125 // until the user intentionally moves the mouse (see SetOnMouseMotion).
121126 if a.wv.newPaneFocusLock.Load() {
@@ -529,6 +534,56 @@ func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
529534 }
530535 }
531536
537+// WorkspaceOverlayWidget returns the workspace-level overlay.
538+func (wv *WorkspaceView) WorkspaceOverlayWidget() layout.OverlayWidget {
539+ wv.mu.RLock()
540+ defer wv.mu.RUnlock()
541+ return wv.overlay
542+}
543+
544+// AddWorkspaceOverlayWidget adds a widget to the workspace-level overlay safely.
545+func (wv *WorkspaceView) AddWorkspaceOverlayWidget(widget layout.Widget) {
546+ if widget == nil {
547+ return
548+ }
549+
550+ wv.mu.Lock()
551+ defer wv.mu.Unlock()
552+ if wv.overlay == nil {
553+ return
554+ }
555+
556+ if parent := widget.GetParent(); parent != nil {
557+ if parent == wv.overlay {
558+ return
559+ }
560+ widget.Unparent()
561+ }
562+
563+ wv.overlay.AddOverlay(widget)
564+ wv.overlay.SetClipOverlay(widget, false)
565+ wv.overlay.SetMeasureOverlay(widget, false)
566+}
567+
568+// RemoveWorkspaceOverlayWidget removes a widget from the workspace-level overlay safely.
569+func (wv *WorkspaceView) RemoveWorkspaceOverlayWidget(widget layout.Widget) {
570+ if widget == nil {
571+ return
572+ }
573+
574+ wv.mu.Lock()
575+ defer wv.mu.Unlock()
576+ if wv.overlay == nil {
577+ return
578+ }
579+
580+ if parent := widget.GetParent(); parent == wv.overlay {
581+ wv.overlay.RemoveOverlay(widget)
582+ } else if parent != nil {
583+ widget.Unparent()
584+ }
585+}
586+
532587 // GetPaneWidget returns the widget for a pane ID.
533588 // Implements focus.PaneGeometryProvider.
534589 func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
@@ -860,6 +915,16 @@ func (wv *WorkspaceView) IsHoverSuppressed() bool {
860915 return time.Now().Before(wv.hoverSuppressedUntil)
861916 }
862917
918+// SetHoverFocusLocked enables or disables hover-to-focus behavior.
919+func (wv *WorkspaceView) SetHoverFocusLocked(locked bool) {
920+ wv.hoverFocusLocked.Store(locked)
921+}
922+
923+// IsHoverFocusLocked returns whether hover-to-focus is currently locked.
924+func (wv *WorkspaceView) IsHoverFocusLocked() bool {
925+ return wv.hoverFocusLocked.Load()
926+}
927+
863928 // CancelAllPendingHovers cancels any pending hover timers on all pane views.
864929 // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
865930 func (wv *WorkspaceView) CancelAllPendingHovers() {
@@ -56,6 +56,7 @@ type WorkspaceView struct {
56 // Hover suppression for keyboard navigation (Issue #89)56 // Hover suppression for keyboard navigation (Issue #89)
57 // Prevents hover focus from overriding keyboard-initiated focus changes57 // Prevents hover focus from overriding keyboard-initiated focus changes
58 hoverSuppressedUntil time.Time58 hoverSuppressedUntil time.Time
59+ hoverFocusLocked atomic.Bool
59 60
60 // newPaneFocusLock suppresses hover focus after a new pane is created.61 // newPaneFocusLock suppresses hover focus after a new pane is created.
61 // Rule: new pane creation always wins focus. Hover focus is blocked62 // Rule: new pane creation always wins focus. Hover focus is blocked
@@ -116,6 +117,10 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
116 return117 return
117 }118 }
118 119
120+ if a.wv.IsHoverFocusLocked() {
121+ return
122+ }
123+
119 // Skip if new pane was just created — focus stays on new pane124 // Skip if new pane was just created — focus stays on new pane
120 // until the user intentionally moves the mouse (see SetOnMouseMotion).125 // until the user intentionally moves the mouse (see SetOnMouseMotion).
121 if a.wv.newPaneFocusLock.Load() {126 if a.wv.newPaneFocusLock.Load() {
@@ -529,6 +534,56 @@ func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
529 }534 }
530 }535 }
531 536
537+// WorkspaceOverlayWidget returns the workspace-level overlay.
538+func (wv *WorkspaceView) WorkspaceOverlayWidget() layout.OverlayWidget {
539+ wv.mu.RLock()
540+ defer wv.mu.RUnlock()
541+ return wv.overlay
542+}
543+
544+// AddWorkspaceOverlayWidget adds a widget to the workspace-level overlay safely.
545+func (wv *WorkspaceView) AddWorkspaceOverlayWidget(widget layout.Widget) {
546+ if widget == nil {
547+ return
548+ }
549+
550+ wv.mu.Lock()
551+ defer wv.mu.Unlock()
552+ if wv.overlay == nil {
553+ return
554+ }
555+
556+ if parent := widget.GetParent(); parent != nil {
557+ if parent == wv.overlay {
558+ return
559+ }
560+ widget.Unparent()
561+ }
562+
563+ wv.overlay.AddOverlay(widget)
564+ wv.overlay.SetClipOverlay(widget, false)
565+ wv.overlay.SetMeasureOverlay(widget, false)
566+}
567+
568+// RemoveWorkspaceOverlayWidget removes a widget from the workspace-level overlay safely.
569+func (wv *WorkspaceView) RemoveWorkspaceOverlayWidget(widget layout.Widget) {
570+ if widget == nil {
571+ return
572+ }
573+
574+ wv.mu.Lock()
575+ defer wv.mu.Unlock()
576+ if wv.overlay == nil {
577+ return
578+ }
579+
580+ if parent := widget.GetParent(); parent == wv.overlay {
581+ wv.overlay.RemoveOverlay(widget)
582+ } else if parent != nil {
583+ widget.Unparent()
584+ }
585+}
586+
532 // GetPaneWidget returns the widget for a pane ID.587 // GetPaneWidget returns the widget for a pane ID.
533 // Implements focus.PaneGeometryProvider.588 // Implements focus.PaneGeometryProvider.
534 func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {589 func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
@@ -860,6 +915,16 @@ func (wv *WorkspaceView) IsHoverSuppressed() bool {
860 return time.Now().Before(wv.hoverSuppressedUntil)915 return time.Now().Before(wv.hoverSuppressedUntil)
861 }916 }
862 917
918+// SetHoverFocusLocked enables or disables hover-to-focus behavior.
919+func (wv *WorkspaceView) SetHoverFocusLocked(locked bool) {
920+ wv.hoverFocusLocked.Store(locked)
921+}
922+
923+// IsHoverFocusLocked returns whether hover-to-focus is currently locked.
924+func (wv *WorkspaceView) IsHoverFocusLocked() bool {
925+ return wv.hoverFocusLocked.Load()
926+}
927+
863 // CancelAllPendingHovers cancels any pending hover timers on all pane views.928 // CancelAllPendingHovers cancels any pending hover timers on all pane views.
864 // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.929 // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
865 func (wv *WorkspaceView) CancelAllPendingHovers() {930 func (wv *WorkspaceView) CancelAllPendingHovers() {
M internal/ui/component/workspace_view_test.go +64 -0
@@ -258,6 +258,70 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
258258 assert.NotNil(t, wv.GetPaneView(pane.ID))
259259 }
260260
261+func TestWorkspaceView_HoverFocusLockState(t *testing.T) {
262+ var wv component.WorkspaceView
263+
264+ assert.False(t, wv.IsHoverFocusLocked())
265+ wv.SetHoverFocusLocked(true)
266+ assert.True(t, wv.IsHoverFocusLocked())
267+
268+ wv.SetHoverFocusLocked(false)
269+ assert.False(t, wv.IsHoverFocusLocked())
270+}
271+
272+func TestAddWorkspaceOverlayWidget_DoesNotReaddWhenAlreadyAttached(t *testing.T) {
273+ mockFactory := mocks.NewMockWidgetFactory(t)
274+ ctx := context.Background()
275+
276+ mockContainer := mocks.NewMockBoxWidget(t)
277+ mockOverlay := mocks.NewMockOverlayWidget(t)
278+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
279+ mockContainer.EXPECT().SetHexpand(true).Once()
280+ mockContainer.EXPECT().SetVexpand(true).Once()
281+ mockContainer.EXPECT().SetVisible(true).Once()
282+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
283+ mockOverlay.EXPECT().SetHexpand(true).Once()
284+ mockOverlay.EXPECT().SetVexpand(true).Once()
285+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
286+ mockOverlay.EXPECT().SetVisible(true).Once()
287+
288+ wv := component.NewWorkspaceView(ctx, mockFactory)
289+
290+ widget := mocks.NewMockWidget(t)
291+ widget.EXPECT().GetParent().Return(mockOverlay).Once()
292+
293+ wv.AddWorkspaceOverlayWidget(widget)
294+}
295+
296+func TestAddWorkspaceOverlayWidget_UnparentsBeforeAdding(t *testing.T) {
297+ mockFactory := mocks.NewMockWidgetFactory(t)
298+ ctx := context.Background()
299+
300+ mockContainer := mocks.NewMockBoxWidget(t)
301+ mockOverlay := mocks.NewMockOverlayWidget(t)
302+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
303+ mockContainer.EXPECT().SetHexpand(true).Once()
304+ mockContainer.EXPECT().SetVexpand(true).Once()
305+ mockContainer.EXPECT().SetVisible(true).Once()
306+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
307+ mockOverlay.EXPECT().SetHexpand(true).Once()
308+ mockOverlay.EXPECT().SetVexpand(true).Once()
309+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
310+ mockOverlay.EXPECT().SetVisible(true).Once()
311+
312+ wv := component.NewWorkspaceView(ctx, mockFactory)
313+
314+ widget := mocks.NewMockWidget(t)
315+ otherParent := mocks.NewMockWidget(t)
316+ widget.EXPECT().GetParent().Return(otherParent).Once()
317+ widget.EXPECT().Unparent().Once()
318+ mockOverlay.EXPECT().AddOverlay(widget).Once()
319+ mockOverlay.EXPECT().SetClipOverlay(widget, false).Once()
320+ mockOverlay.EXPECT().SetMeasureOverlay(widget, false).Once()
321+
322+ wv.AddWorkspaceOverlayWidget(widget)
323+}
324+
261325 func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
262326 // Arrange
263327 mockFactory := mocks.NewMockWidgetFactory(t)
@@ -258,6 +258,70 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
258 assert.NotNil(t, wv.GetPaneView(pane.ID))258 assert.NotNil(t, wv.GetPaneView(pane.ID))
259 }259 }
260 260
261+func TestWorkspaceView_HoverFocusLockState(t *testing.T) {
262+ var wv component.WorkspaceView
263+
264+ assert.False(t, wv.IsHoverFocusLocked())
265+ wv.SetHoverFocusLocked(true)
266+ assert.True(t, wv.IsHoverFocusLocked())
267+
268+ wv.SetHoverFocusLocked(false)
269+ assert.False(t, wv.IsHoverFocusLocked())
270+}
271+
272+func TestAddWorkspaceOverlayWidget_DoesNotReaddWhenAlreadyAttached(t *testing.T) {
273+ mockFactory := mocks.NewMockWidgetFactory(t)
274+ ctx := context.Background()
275+
276+ mockContainer := mocks.NewMockBoxWidget(t)
277+ mockOverlay := mocks.NewMockOverlayWidget(t)
278+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
279+ mockContainer.EXPECT().SetHexpand(true).Once()
280+ mockContainer.EXPECT().SetVexpand(true).Once()
281+ mockContainer.EXPECT().SetVisible(true).Once()
282+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
283+ mockOverlay.EXPECT().SetHexpand(true).Once()
284+ mockOverlay.EXPECT().SetVexpand(true).Once()
285+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
286+ mockOverlay.EXPECT().SetVisible(true).Once()
287+
288+ wv := component.NewWorkspaceView(ctx, mockFactory)
289+
290+ widget := mocks.NewMockWidget(t)
291+ widget.EXPECT().GetParent().Return(mockOverlay).Once()
292+
293+ wv.AddWorkspaceOverlayWidget(widget)
294+}
295+
296+func TestAddWorkspaceOverlayWidget_UnparentsBeforeAdding(t *testing.T) {
297+ mockFactory := mocks.NewMockWidgetFactory(t)
298+ ctx := context.Background()
299+
300+ mockContainer := mocks.NewMockBoxWidget(t)
301+ mockOverlay := mocks.NewMockOverlayWidget(t)
302+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
303+ mockContainer.EXPECT().SetHexpand(true).Once()
304+ mockContainer.EXPECT().SetVexpand(true).Once()
305+ mockContainer.EXPECT().SetVisible(true).Once()
306+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
307+ mockOverlay.EXPECT().SetHexpand(true).Once()
308+ mockOverlay.EXPECT().SetVexpand(true).Once()
309+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
310+ mockOverlay.EXPECT().SetVisible(true).Once()
311+
312+ wv := component.NewWorkspaceView(ctx, mockFactory)
313+
314+ widget := mocks.NewMockWidget(t)
315+ otherParent := mocks.NewMockWidget(t)
316+ widget.EXPECT().GetParent().Return(otherParent).Once()
317+ widget.EXPECT().Unparent().Once()
318+ mockOverlay.EXPECT().AddOverlay(widget).Once()
319+ mockOverlay.EXPECT().SetClipOverlay(widget, false).Once()
320+ mockOverlay.EXPECT().SetMeasureOverlay(widget, false).Once()
321+
322+ wv.AddWorkspaceOverlayWidget(widget)
323+}
324+
261 func TestSetActivePaneID_UpdatesStyling(t *testing.T) {325 func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
262 // Arrange326 // Arrange
263 mockFactory := mocks.NewMockWidgetFactory(t)327 mockFactory := mocks.NewMockWidgetFactory(t)
M internal/ui/coordinator/content.go +55 -0
@@ -95,6 +95,10 @@ type ContentCoordinator struct {
9595
9696 webViews map[entity.PaneID]*webkit.WebView
9797 webViewsMu sync.RWMutex
98+
99+ activePaneOverride entity.PaneID
100+ activePaneOverrideMu sync.RWMutex
101+
98102 paneTitles map[entity.PaneID]string
99103 titleMu sync.RWMutex
100104
@@ -440,6 +444,18 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView)
440444 func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
441445 log := logging.FromContext(ctx)
442446
447+ if paneID, ok := c.activePaneOverrideID(); ok {
448+ wv := c.getWebViewLocked(paneID)
449+ if wv != nil {
450+ return wv
451+ }
452+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
453+ }
454+ if c.getActiveWS == nil {
455+ log.Debug().Msg("no active workspace resolver")
456+ return nil
457+ }
458+
443459 ws, _ := c.getActiveWS()
444460 if ws == nil {
445461 log.Debug().Msg("no active workspace")
@@ -455,6 +471,45 @@ func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView
455471 return c.getWebViewLocked(pane.Pane.ID)
456472 }
457473
474+// ActivePaneID returns the currently active pane ID used by navigation.
475+func (c *ContentCoordinator) ActivePaneID(ctx context.Context) entity.PaneID {
476+ if paneID, ok := c.activePaneOverrideID(); ok {
477+ return paneID
478+ }
479+ if c.getActiveWS == nil {
480+ return ""
481+ }
482+
483+ ws, _ := c.getActiveWS()
484+ if ws == nil {
485+ return ""
486+ }
487+ return ws.ActivePaneID
488+}
489+
490+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
491+func (c *ContentCoordinator) SetActivePaneOverride(paneID entity.PaneID) {
492+ c.activePaneOverrideMu.Lock()
493+ c.activePaneOverride = paneID
494+ c.activePaneOverrideMu.Unlock()
495+}
496+
497+// ClearActivePaneOverride removes any forced active pane override.
498+func (c *ContentCoordinator) ClearActivePaneOverride() {
499+ c.activePaneOverrideMu.Lock()
500+ c.activePaneOverride = ""
501+ c.activePaneOverrideMu.Unlock()
502+}
503+
504+func (c *ContentCoordinator) activePaneOverrideID() (entity.PaneID, bool) {
505+ c.activePaneOverrideMu.RLock()
506+ defer c.activePaneOverrideMu.RUnlock()
507+ if c.activePaneOverride == "" {
508+ return "", false
509+ }
510+ return c.activePaneOverride, true
511+}
512+
458513 // GetWebView returns the WebView for a specific pane.
459514 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
460515 return c.getWebViewLocked(paneID)
@@ -95,6 +95,10 @@ type ContentCoordinator struct {
95 95
96 webViews map[entity.PaneID]*webkit.WebView96 webViews map[entity.PaneID]*webkit.WebView
97 webViewsMu sync.RWMutex97 webViewsMu sync.RWMutex
98+
99+ activePaneOverride entity.PaneID
100+ activePaneOverrideMu sync.RWMutex
101+
98 paneTitles map[entity.PaneID]string102 paneTitles map[entity.PaneID]string
99 titleMu sync.RWMutex103 titleMu sync.RWMutex
100 104
@@ -440,6 +444,18 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView)
440 func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {444 func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
441 log := logging.FromContext(ctx)445 log := logging.FromContext(ctx)
442 446
447+ if paneID, ok := c.activePaneOverrideID(); ok {
448+ wv := c.getWebViewLocked(paneID)
449+ if wv != nil {
450+ return wv
451+ }
452+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
453+ }
454+ if c.getActiveWS == nil {
455+ log.Debug().Msg("no active workspace resolver")
456+ return nil
457+ }
458+
443 ws, _ := c.getActiveWS()459 ws, _ := c.getActiveWS()
444 if ws == nil {460 if ws == nil {
445 log.Debug().Msg("no active workspace")461 log.Debug().Msg("no active workspace")
@@ -455,6 +471,45 @@ func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView
455 return c.getWebViewLocked(pane.Pane.ID)471 return c.getWebViewLocked(pane.Pane.ID)
456 }472 }
457 473
474+// ActivePaneID returns the currently active pane ID used by navigation.
475+func (c *ContentCoordinator) ActivePaneID(ctx context.Context) entity.PaneID {
476+ if paneID, ok := c.activePaneOverrideID(); ok {
477+ return paneID
478+ }
479+ if c.getActiveWS == nil {
480+ return ""
481+ }
482+
483+ ws, _ := c.getActiveWS()
484+ if ws == nil {
485+ return ""
486+ }
487+ return ws.ActivePaneID
488+}
489+
490+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
491+func (c *ContentCoordinator) SetActivePaneOverride(paneID entity.PaneID) {
492+ c.activePaneOverrideMu.Lock()
493+ c.activePaneOverride = paneID
494+ c.activePaneOverrideMu.Unlock()
495+}
496+
497+// ClearActivePaneOverride removes any forced active pane override.
498+func (c *ContentCoordinator) ClearActivePaneOverride() {
499+ c.activePaneOverrideMu.Lock()
500+ c.activePaneOverride = ""
501+ c.activePaneOverrideMu.Unlock()
502+}
503+
504+func (c *ContentCoordinator) activePaneOverrideID() (entity.PaneID, bool) {
505+ c.activePaneOverrideMu.RLock()
506+ defer c.activePaneOverrideMu.RUnlock()
507+ if c.activePaneOverride == "" {
508+ return "", false
509+ }
510+ return c.activePaneOverride, true
511+}
512+
458 // GetWebView returns the WebView for a specific pane.513 // GetWebView returns the WebView for a specific pane.
459 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {514 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
460 return c.getWebViewLocked(paneID)515 return c.getWebViewLocked(paneID)
A internal/ui/coordinator/content_active_override_test.go +44 -0
@@ -0,0 +1,44 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+)
12+
13+func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
14+ mainWV := &webkit.WebView{}
15+ floatingWV := &webkit.WebView{}
16+
17+ c := &ContentCoordinator{
18+ webViews: map[entity.PaneID]*webkit.WebView{
19+ "main-pane": mainWV,
20+ "floating-pane": floatingWV,
21+ },
22+ }
23+
24+ c.SetActivePaneOverride("floating-pane")
25+
26+ assert.Equal(t, floatingWV, c.ActiveWebView(context.Background()))
27+ assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
28+}
29+
30+func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
31+ mainWV := &webkit.WebView{}
32+
33+ c := &ContentCoordinator{
34+ webViews: map[entity.PaneID]*webkit.WebView{
35+ "main-pane": mainWV,
36+ },
37+ }
38+
39+ c.SetActivePaneOverride("main-pane")
40+ c.ClearActivePaneOverride()
41+
42+ assert.Nil(t, c.ActiveWebView(context.Background()))
43+ assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
44+}
@@ -0,0 +1,44 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+)
12+
13+func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
14+ mainWV := &webkit.WebView{}
15+ floatingWV := &webkit.WebView{}
16+
17+ c := &ContentCoordinator{
18+ webViews: map[entity.PaneID]*webkit.WebView{
19+ "main-pane": mainWV,
20+ "floating-pane": floatingWV,
21+ },
22+ }
23+
24+ c.SetActivePaneOverride("floating-pane")
25+
26+ assert.Equal(t, floatingWV, c.ActiveWebView(context.Background()))
27+ assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
28+}
29+
30+func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
31+ mainWV := &webkit.WebView{}
32+
33+ c := &ContentCoordinator{
34+ webViews: map[entity.PaneID]*webkit.WebView{
35+ "main-pane": mainWV,
36+ },
37+ }
38+
39+ c.SetActivePaneOverride("main-pane")
40+ c.ClearActivePaneOverride()
41+
42+ assert.Nil(t, c.ActiveWebView(context.Background()))
43+ assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
44+}
M internal/ui/coordinator/navigation.go +19 -10
@@ -42,12 +42,24 @@ func NewNavigationCoordinator(
4242 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
4343 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
4444 c.omniboxProvider = provider
45+ if c.omniboxProvider != nil {
46+ c.omniboxProvider.SetOmniboxOnNavigate(func(url string) {
47+ ctx := context.Background()
48+ if err := c.Navigate(ctx, url); err != nil {
49+ logging.FromContext(ctx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed")
50+ }
51+ })
52+ }
4553 }
4654
4755 // Navigate loads a URL in the active pane using NavigateUseCase.
4856 // This properly handles history recording and zoom application.
4957 func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {
5058 log := logging.FromContext(ctx)
59+ if c.contentCoord == nil {
60+ log.Warn().Str("url", url).Msg("content coordinator not initialized")
61+ return fmt.Errorf("content coordinator not initialized")
62+ }
5163
5264 wv := c.contentCoord.ActiveWebView(ctx)
5365 if wv == nil {
@@ -56,16 +68,13 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
5668 }
5769
5870 // Get active pane ID for tracking
59- ws, _ := c.contentCoord.getActiveWS()
60- var paneID string
61- if ws != nil {
62- if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {
63- paneID = string(pane.Pane.ID)
64- // Track original URL for cross-domain redirect favicon caching
65- c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)
66- // Pre-load cached favicon asynchronously (don't block navigation start)
67- go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
68- }
71+ activePaneID := c.contentCoord.ActivePaneID(ctx)
72+ paneID := string(activePaneID)
73+ if activePaneID != "" {
74+ // Track original URL for cross-domain redirect favicon caching
75+ c.contentCoord.SetNavigationOrigin(activePaneID, url)
76+ // Pre-load cached favicon asynchronously (don't block navigation start)
77+ go c.contentCoord.PreloadCachedFavicon(ctx, activePaneID, url)
6978 }
7079
7180 // Use NavigateUseCase which handles history + zoom
@@ -42,12 +42,24 @@ func NewNavigationCoordinator(
42 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.42 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
43 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {43 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
44 c.omniboxProvider = provider44 c.omniboxProvider = provider
45+ if c.omniboxProvider != nil {
46+ c.omniboxProvider.SetOmniboxOnNavigate(func(url string) {
47+ ctx := context.Background()
48+ if err := c.Navigate(ctx, url); err != nil {
49+ logging.FromContext(ctx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed")
50+ }
51+ })
52+ }
45 }53 }
46 54
47 // Navigate loads a URL in the active pane using NavigateUseCase.55 // Navigate loads a URL in the active pane using NavigateUseCase.
48 // This properly handles history recording and zoom application.56 // This properly handles history recording and zoom application.
49 func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {57 func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {
50 log := logging.FromContext(ctx)58 log := logging.FromContext(ctx)
59+ if c.contentCoord == nil {
60+ log.Warn().Str("url", url).Msg("content coordinator not initialized")
61+ return fmt.Errorf("content coordinator not initialized")
62+ }
51 63
52 wv := c.contentCoord.ActiveWebView(ctx)64 wv := c.contentCoord.ActiveWebView(ctx)
53 if wv == nil {65 if wv == nil {
@@ -56,16 +68,13 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
56 }68 }
57 69
58 // Get active pane ID for tracking70 // Get active pane ID for tracking
59- ws, _ := c.contentCoord.getActiveWS()71+ activePaneID := c.contentCoord.ActivePaneID(ctx)
60- var paneID string72+ paneID := string(activePaneID)
61- if ws != nil {73+ if activePaneID != "" {
62- if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {74+ // Track original URL for cross-domain redirect favicon caching
63- paneID = string(pane.Pane.ID)75+ c.contentCoord.SetNavigationOrigin(activePaneID, url)
64- // Track original URL for cross-domain redirect favicon caching76+ // Pre-load cached favicon asynchronously (don't block navigation start)
65- c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)77+ go c.contentCoord.PreloadCachedFavicon(ctx, activePaneID, url)
66- // Pre-load cached favicon asynchronously (don't block navigation start)
67- go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)
68- }
69 }78 }
70 79
71 // Use NavigateUseCase which handles history + zoom80 // Use NavigateUseCase which handles history + zoom
A internal/ui/coordinator/navigation_test.go +41 -0
@@ -0,0 +1,41 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+type fakeOmniboxProvider struct {
12+ onNavigate func(string)
13+}
14+
15+func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) {}
16+
17+func (f *fakeOmniboxProvider) UpdateOmniboxZoom(float64) {}
18+
19+func (f *fakeOmniboxProvider) SetOmniboxOnNavigate(fn func(url string)) {
20+ f.onNavigate = fn
21+}
22+
23+func TestNavigationCoordinator_NavigateWithoutContentCoordinatorReturnsError(t *testing.T) {
24+ c := &NavigationCoordinator{}
25+
26+ err := c.Navigate(context.Background(), "https://example.com")
27+ require.Error(t, err)
28+ assert.Contains(t, err.Error(), "content coordinator")
29+}
30+
31+func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) {
32+ c := &NavigationCoordinator{}
33+ provider := &fakeOmniboxProvider{}
34+
35+ c.SetOmniboxProvider(provider)
36+ require.NotNil(t, provider.onNavigate)
37+
38+ assert.NotPanics(t, func() {
39+ provider.onNavigate("https://example.com")
40+ })
41+}
@@ -0,0 +1,41 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+type fakeOmniboxProvider struct {
12+ onNavigate func(string)
13+}
14+
15+func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) {}
16+
17+func (f *fakeOmniboxProvider) UpdateOmniboxZoom(float64) {}
18+
19+func (f *fakeOmniboxProvider) SetOmniboxOnNavigate(fn func(url string)) {
20+ f.onNavigate = fn
21+}
22+
23+func TestNavigationCoordinator_NavigateWithoutContentCoordinatorReturnsError(t *testing.T) {
24+ c := &NavigationCoordinator{}
25+
26+ err := c.Navigate(context.Background(), "https://example.com")
27+ require.Error(t, err)
28+ assert.Contains(t, err.Error(), "content coordinator")
29+}
30+
31+func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) {
32+ c := &NavigationCoordinator{}
33+ provider := &fakeOmniboxProvider{}
34+
35+ c.SetOmniboxProvider(provider)
36+ require.NotNil(t, provider.onNavigate)
37+
38+ assert.NotPanics(t, func() {
39+ provider.onNavigate("https://example.com")
40+ })
41+}
M internal/ui/dispatcher/keyboard.go +34 -0
@@ -31,6 +31,8 @@ type KeyboardDispatcher struct {
3131 onSessionOpen func(ctx context.Context) error
3232 onMovePaneToTab func(ctx context.Context) error
3333 onMovePaneToNext func(ctx context.Context) error
34+ onToggleFloating func(ctx context.Context) error
35+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
3436 }
3537
3638 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -94,6 +96,24 @@ func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context)
9496 d.onMovePaneToNext = fn
9597 }
9698
99+func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
100+ d.onToggleFloating = fn
101+}
102+
103+func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
104+ if fn == nil {
105+ d.onOpenFloating = nil
106+ return
107+ }
108+ d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
109+ return fn(ctx, target.URL)
110+ }
111+}
112+
113+func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
114+ d.onOpenFloating = fn
115+}
116+
97117 func (d *KeyboardDispatcher) initActionHandlers() {
98118 const (
99119 firstTabIndex = 0
@@ -191,6 +211,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
191211 input.ActionFindPrev: d.handleFindPrev,
192212 input.ActionCloseFind: d.handleFindClose,
193213 input.ActionOpenDevTools: d.navCoord.OpenDevTools,
214+ input.ActionToggleFloatingPane: func(ctx context.Context) error {
215+ if d.onToggleFloating != nil {
216+ return d.onToggleFloating(ctx)
217+ }
218+ return d.logNoop(ctx, "toggle floating pane action (no handler)")
219+ },
194220 input.ActionToggleFullscreen: func(ctx context.Context) error {
195221 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
196222 },
@@ -208,6 +234,14 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
208234 log := logging.FromContext(ctx)
209235 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
210236
237+ if target, ok := input.ParseFloatingProfileTarget(action); ok {
238+ if d.onOpenFloating != nil {
239+ return d.onOpenFloating(ctx, target)
240+ }
241+ log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
242+ return nil
243+ }
244+
211245 if handler, ok := d.actionHandlers[action]; ok {
212246 return handler(ctx)
213247 }
@@ -31,6 +31,8 @@ type KeyboardDispatcher struct {
31 onSessionOpen func(ctx context.Context) error31 onSessionOpen func(ctx context.Context) error
32 onMovePaneToTab func(ctx context.Context) error32 onMovePaneToTab func(ctx context.Context) error
33 onMovePaneToNext func(ctx context.Context) error33 onMovePaneToNext func(ctx context.Context) error
34+ onToggleFloating func(ctx context.Context) error
35+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
34 }36 }
35 37
36 // NewKeyboardDispatcher creates a new KeyboardDispatcher.38 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -94,6 +96,24 @@ func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context)
94 d.onMovePaneToNext = fn96 d.onMovePaneToNext = fn
95 }97 }
96 98
99+func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
100+ d.onToggleFloating = fn
101+}
102+
103+func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
104+ if fn == nil {
105+ d.onOpenFloating = nil
106+ return
107+ }
108+ d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
109+ return fn(ctx, target.URL)
110+ }
111+}
112+
113+func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
114+ d.onOpenFloating = fn
115+}
116+
97 func (d *KeyboardDispatcher) initActionHandlers() {117 func (d *KeyboardDispatcher) initActionHandlers() {
98 const (118 const (
99 firstTabIndex = 0119 firstTabIndex = 0
@@ -191,6 +211,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
191 input.ActionFindPrev: d.handleFindPrev,211 input.ActionFindPrev: d.handleFindPrev,
192 input.ActionCloseFind: d.handleFindClose,212 input.ActionCloseFind: d.handleFindClose,
193 input.ActionOpenDevTools: d.navCoord.OpenDevTools,213 input.ActionOpenDevTools: d.navCoord.OpenDevTools,
214+ input.ActionToggleFloatingPane: func(ctx context.Context) error {
215+ if d.onToggleFloating != nil {
216+ return d.onToggleFloating(ctx)
217+ }
218+ return d.logNoop(ctx, "toggle floating pane action (no handler)")
219+ },
194 input.ActionToggleFullscreen: func(ctx context.Context) error {220 input.ActionToggleFullscreen: func(ctx context.Context) error {
195 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")221 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
196 },222 },
@@ -208,6 +234,14 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
208 log := logging.FromContext(ctx)234 log := logging.FromContext(ctx)
209 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")235 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
210 236
237+ if target, ok := input.ParseFloatingProfileTarget(action); ok {
238+ if d.onOpenFloating != nil {
239+ return d.onOpenFloating(ctx, target)
240+ }
241+ log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
242+ return nil
243+ }
244+
211 if handler, ok := d.actionHandlers[action]; ok {245 if handler, ok := d.actionHandlers[action]; ok {
212 return handler(ctx)246 return handler(ctx)
213 }247 }
A internal/ui/floating_pane_test.go +293 -0
@@ -0,0 +1,293 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/component"
9+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
15+ overlay := layoutmocks.NewMockOverlayWidget(t)
16+ overlay.EXPECT().SetHexpand(false).Once()
17+ overlay.EXPECT().SetVexpand(false).Once()
18+ overlay.EXPECT().AddCssClass("floating-pane-container").Once()
19+ overlay.EXPECT().AddCssClass("pane-border").Once()
20+ overlay.EXPECT().AddCssClass("pane-active").Once()
21+
22+ decorateFloatingOverlay(overlay)
23+}
24+
25+func TestConfigureFloatingOverlayMeasurement_UsesMeasuredOverlay(t *testing.T) {
26+ workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
27+ floatingOverlay := layoutmocks.NewMockWidget(t)
28+
29+ workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, true).Once()
30+ workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
31+
32+ configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
33+}
34+
35+func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
36+ app, tabID, session := newFloatingPaneTestApp(t)
37+
38+ err := app.ToggleFloatingPane(context.Background())
39+ require.NoError(t, err)
40+ assert.True(t, session.pane.IsVisible())
41+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
42+
43+ err = app.ToggleFloatingPane(context.Background())
44+ require.NoError(t, err)
45+ assert.False(t, session.pane.IsVisible())
46+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
47+
48+ _, gotTabID := app.activeFloatingSession()
49+ assert.Equal(t, tabID, gotTabID)
50+}
51+
52+func TestFloatingPane_ProfileShortcutOpensConfiguredURL(t *testing.T) {
53+ app, _, session := newFloatingPaneTestApp(t)
54+
55+ err := app.OpenFloatingPaneURL(context.Background(), "https://google.com")
56+ require.NoError(t, err)
57+
58+ assert.True(t, session.pane.IsVisible())
59+ assert.False(t, session.pane.IsOmniboxVisible())
60+ assert.Equal(t, "https://google.com", session.pane.CurrentURL())
61+}
62+
63+func TestFloatingPane_ActivePaneSwitchPreservesSession(t *testing.T) {
64+ app, tabID, session := newFloatingPaneTestApp(t)
65+
66+ err := app.OpenFloatingPaneURL(context.Background(), "https://example.com/start")
67+ require.NoError(t, err)
68+
69+ activeTab := app.tabs.Find(tabID)
70+ require.NotNil(t, activeTab)
71+ activeTab.Workspace.ActivePaneID = entity.PaneID("pane-two")
72+
73+ err = app.ToggleFloatingPane(context.Background())
74+ require.NoError(t, err)
75+ err = app.ToggleFloatingPane(context.Background())
76+ require.NoError(t, err)
77+
78+ assert.Equal(t, "https://example.com/start", session.pane.CurrentURL())
79+}
80+
81+func TestFloatingPane_OmniboxNavigationTargetsFloatingSession(t *testing.T) {
82+ app, _, session := newFloatingPaneTestApp(t)
83+
84+ require.NoError(t, session.pane.ShowToggle(context.Background()))
85+ require.True(t, session.pane.IsOmniboxVisible())
86+
87+ err := app.navigateFromOmnibox(context.Background(), "https://example.com/omnibox")
88+ require.NoError(t, err)
89+ assert.Equal(t, "https://example.com/omnibox", session.pane.CurrentURL())
90+}
91+
92+func TestFloatingPane_HandleViewportTick_RecalculatesOnWorkspaceResize(t *testing.T) {
93+ ctx := context.Background()
94+ overlay := layoutmocks.NewMockOverlayWidget(t)
95+ width := 1000
96+ height := 700
97+ overlay.EXPECT().GetAllocatedWidth().RunAndReturn(func() int { return width }).Maybe()
98+ overlay.EXPECT().GetAllocatedHeight().RunAndReturn(func() int { return height }).Maybe()
99+
100+ widget := layoutmocks.NewMockWidget(t)
101+ pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
102+ WidthPct: 0.82,
103+ HeightPct: 0.72,
104+ FallbackWidth: floatingPaneFallbackWidth,
105+ FallbackHeight: floatingPaneFallbackHeight,
106+ })
107+ require.NoError(t, pane.ShowURL(ctx, "https://example.com"))
108+
109+ session := &floatingWorkspaceSession{pane: pane, overlay: overlay, widget: widget}
110+ app := &App{}
111+
112+ widget.EXPECT().SetSizeRequest(820, 504).Once()
113+ assert.True(t, app.handleFloatingViewportTick(session))
114+
115+ width = 1200
116+ height = 900
117+ widget.EXPECT().SetSizeRequest(983, 648).Once()
118+ assert.True(t, app.handleFloatingViewportTick(session))
119+
120+ pane.Hide(ctx)
121+ assert.False(t, app.handleFloatingViewportTick(session))
122+}
123+
124+func TestFloatingPane_HideShowAfterURIUpdateKeepsOmniboxHidden(t *testing.T) {
125+ app, _, session := newFloatingPaneTestApp(t)
126+
127+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
128+ require.True(t, session.pane.IsOmniboxVisible())
129+
130+ app.updateFloatingSessionURI(session.paneID, "https://google.com")
131+
132+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
133+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
134+
135+ assert.Equal(t, "https://google.com", session.pane.CurrentURL())
136+ assert.False(t, session.pane.IsOmniboxVisible())
137+}
138+
139+func TestFloatingPane_ProfileSessionsRemainIndependent(t *testing.T) {
140+ app, tabID, _ := newFloatingPaneTestApp(t)
141+
142+ gmailSession := newFloatingPaneSession(tabID, "profile:gmail")
143+ blankSession := newFloatingPaneSession(tabID, "profile:blank")
144+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = gmailSession
145+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:blank"}] = blankSession
146+
147+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
148+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
149+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:blank", "about:blank"))
150+
151+ assert.Equal(t, "https://mail.google.com", gmailSession.pane.CurrentURL())
152+ assert.False(t, gmailSession.pane.IsVisible())
153+ assert.Equal(t, "about:blank", blankSession.pane.CurrentURL())
154+ assert.True(t, blankSession.pane.IsVisible())
155+ assert.NotEqual(t, gmailSession.paneID, blankSession.paneID)
156+}
157+
158+func TestFloatingPane_ProfileShortcutTogglesAndPreservesSessionState(t *testing.T) {
159+ app, tabID, _ := newFloatingPaneTestApp(t)
160+ session := newFloatingPaneSession(tabID, "profile:gmail")
161+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
162+
163+ baseURL := "https://mail.google.com"
164+ draftURL := "https://mail.google.com/mail/u/0/#inbox?compose=new"
165+
166+ // First open: navigates to base URL
167+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
168+ assert.True(t, session.pane.IsVisible())
169+ assert.Equal(t, baseURL, session.pane.CurrentURL())
170+
171+ // Simulate user navigating within gmail (compose draft etc.)
172+ app.updateFloatingSessionURI(session.paneID, draftURL)
173+ assert.Equal(t, draftURL, session.pane.CurrentURL())
174+
175+ // Second press: hides (toggle off), preserves draft URL
176+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
177+ assert.False(t, session.pane.IsVisible())
178+ assert.Equal(t, draftURL, session.pane.CurrentURL())
179+
180+ // Third press: shows again without re-navigating, draft URL preserved
181+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
182+ assert.True(t, session.pane.IsVisible())
183+ assert.Equal(t, draftURL, session.pane.CurrentURL())
184+}
185+
186+func TestFloatingPane_ProfileRapidToggleNeverShowsAboutBlank(t *testing.T) {
187+ app, tabID, _ := newFloatingPaneTestApp(t)
188+ session := newFloatingPaneSession(tabID, "profile:gmail")
189+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
190+
191+ baseURL := "https://mail.google.com"
192+
193+ // Rapid toggle 10 times: should never end up on about:blank
194+ for i := 0; i < 10; i++ {
195+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
196+ assert.NotEqual(t, "about:blank", session.pane.CurrentURL(),
197+ "iteration %d: profile session should never show about:blank", i)
198+ if session.pane.IsVisible() {
199+ assert.Equal(t, baseURL, session.pane.CurrentURL())
200+ }
201+ }
202+}
203+
204+func TestFloatingPane_DefaultSessionStillUsesAboutBlank(t *testing.T) {
205+ app, _, session := newFloatingPaneTestApp(t)
206+
207+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
208+ assert.True(t, session.pane.IsVisible())
209+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
210+}
211+
212+func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
213+ app, tabID, _ := newFloatingPaneTestApp(t)
214+ session := newFloatingPaneSession(tabID, "profile:gmail")
215+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
216+
217+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
218+
219+ handled := app.closeActiveFloatingPane(context.Background())
220+ assert.True(t, handled)
221+ assert.False(t, session.pane.IsVisible())
222+
223+ handled = app.closeActiveFloatingPane(context.Background())
224+ assert.False(t, handled)
225+}
226+
227+func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) {
228+ app, _, session := newFloatingPaneTestApp(t)
229+
230+ require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault))
231+ assert.True(t, session.pane.IsVisible())
232+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
233+
234+ require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault, "https://example.com"))
235+ assert.True(t, session.pane.IsVisible())
236+ assert.Equal(t, "https://example.com", session.pane.CurrentURL())
237+}
238+
239+func TestFloatingPane_EnsureFloatingSession_CachedAllowsNilWorkspaceView(t *testing.T) {
240+ app, tabID, session := newFloatingPaneTestApp(t)
241+
242+ got, err := app.ensureFloatingSession(context.Background(), tabID, floatingSessionIDDefault, nil)
243+ require.NoError(t, err)
244+ assert.Same(t, session, got)
245+}
246+
247+func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
248+ session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
249+ session.resizeWatcherActive = true
250+ session.resizeTickID = 42
251+
252+ app := &App{}
253+ app.hideFloatingSession(context.Background(), session)
254+
255+ assert.False(t, session.resizeWatcherActive)
256+ assert.Equal(t, uint(0), session.resizeTickID)
257+}
258+
259+func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
260+ t.Helper()
261+
262+ tabID := entity.TabID("tab-1")
263+ workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
264+ tabs := entity.NewTabList()
265+ tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})
266+
267+ session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
268+
269+ app := &App{
270+ tabs: tabs,
271+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
272+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
273+ {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
274+ },
275+ }
276+
277+ return app, tabID, session
278+}
279+
280+func newFloatingPaneSession(tabID entity.TabID, sessionID string) *floatingWorkspaceSession {
281+ return &floatingWorkspaceSession{
282+ paneID: floatingPaneIDForSession(tabID, sessionID),
283+ pane: component.NewFloatingPane(nil, component.FloatingPaneOptions{
284+ WidthPct: 0.82,
285+ HeightPct: 0.72,
286+ FallbackWidth: floatingPaneFallbackWidth,
287+ FallbackHeight: floatingPaneFallbackHeight,
288+ OnNavigate: func(context.Context, string) error {
289+ return nil
290+ },
291+ }),
292+ }
293+}
@@ -0,0 +1,293 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/component"
9+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
15+ overlay := layoutmocks.NewMockOverlayWidget(t)
16+ overlay.EXPECT().SetHexpand(false).Once()
17+ overlay.EXPECT().SetVexpand(false).Once()
18+ overlay.EXPECT().AddCssClass("floating-pane-container").Once()
19+ overlay.EXPECT().AddCssClass("pane-border").Once()
20+ overlay.EXPECT().AddCssClass("pane-active").Once()
21+
22+ decorateFloatingOverlay(overlay)
23+}
24+
25+func TestConfigureFloatingOverlayMeasurement_UsesMeasuredOverlay(t *testing.T) {
26+ workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
27+ floatingOverlay := layoutmocks.NewMockWidget(t)
28+
29+ workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, true).Once()
30+ workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
31+
32+ configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
33+}
34+
35+func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
36+ app, tabID, session := newFloatingPaneTestApp(t)
37+
38+ err := app.ToggleFloatingPane(context.Background())
39+ require.NoError(t, err)
40+ assert.True(t, session.pane.IsVisible())
41+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
42+
43+ err = app.ToggleFloatingPane(context.Background())
44+ require.NoError(t, err)
45+ assert.False(t, session.pane.IsVisible())
46+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
47+
48+ _, gotTabID := app.activeFloatingSession()
49+ assert.Equal(t, tabID, gotTabID)
50+}
51+
52+func TestFloatingPane_ProfileShortcutOpensConfiguredURL(t *testing.T) {
53+ app, _, session := newFloatingPaneTestApp(t)
54+
55+ err := app.OpenFloatingPaneURL(context.Background(), "https://google.com")
56+ require.NoError(t, err)
57+
58+ assert.True(t, session.pane.IsVisible())
59+ assert.False(t, session.pane.IsOmniboxVisible())
60+ assert.Equal(t, "https://google.com", session.pane.CurrentURL())
61+}
62+
63+func TestFloatingPane_ActivePaneSwitchPreservesSession(t *testing.T) {
64+ app, tabID, session := newFloatingPaneTestApp(t)
65+
66+ err := app.OpenFloatingPaneURL(context.Background(), "https://example.com/start")
67+ require.NoError(t, err)
68+
69+ activeTab := app.tabs.Find(tabID)
70+ require.NotNil(t, activeTab)
71+ activeTab.Workspace.ActivePaneID = entity.PaneID("pane-two")
72+
73+ err = app.ToggleFloatingPane(context.Background())
74+ require.NoError(t, err)
75+ err = app.ToggleFloatingPane(context.Background())
76+ require.NoError(t, err)
77+
78+ assert.Equal(t, "https://example.com/start", session.pane.CurrentURL())
79+}
80+
81+func TestFloatingPane_OmniboxNavigationTargetsFloatingSession(t *testing.T) {
82+ app, _, session := newFloatingPaneTestApp(t)
83+
84+ require.NoError(t, session.pane.ShowToggle(context.Background()))
85+ require.True(t, session.pane.IsOmniboxVisible())
86+
87+ err := app.navigateFromOmnibox(context.Background(), "https://example.com/omnibox")
88+ require.NoError(t, err)
89+ assert.Equal(t, "https://example.com/omnibox", session.pane.CurrentURL())
90+}
91+
92+func TestFloatingPane_HandleViewportTick_RecalculatesOnWorkspaceResize(t *testing.T) {
93+ ctx := context.Background()
94+ overlay := layoutmocks.NewMockOverlayWidget(t)
95+ width := 1000
96+ height := 700
97+ overlay.EXPECT().GetAllocatedWidth().RunAndReturn(func() int { return width }).Maybe()
98+ overlay.EXPECT().GetAllocatedHeight().RunAndReturn(func() int { return height }).Maybe()
99+
100+ widget := layoutmocks.NewMockWidget(t)
101+ pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
102+ WidthPct: 0.82,
103+ HeightPct: 0.72,
104+ FallbackWidth: floatingPaneFallbackWidth,
105+ FallbackHeight: floatingPaneFallbackHeight,
106+ })
107+ require.NoError(t, pane.ShowURL(ctx, "https://example.com"))
108+
109+ session := &floatingWorkspaceSession{pane: pane, overlay: overlay, widget: widget}
110+ app := &App{}
111+
112+ widget.EXPECT().SetSizeRequest(820, 504).Once()
113+ assert.True(t, app.handleFloatingViewportTick(session))
114+
115+ width = 1200
116+ height = 900
117+ widget.EXPECT().SetSizeRequest(983, 648).Once()
118+ assert.True(t, app.handleFloatingViewportTick(session))
119+
120+ pane.Hide(ctx)
121+ assert.False(t, app.handleFloatingViewportTick(session))
122+}
123+
124+func TestFloatingPane_HideShowAfterURIUpdateKeepsOmniboxHidden(t *testing.T) {
125+ app, _, session := newFloatingPaneTestApp(t)
126+
127+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
128+ require.True(t, session.pane.IsOmniboxVisible())
129+
130+ app.updateFloatingSessionURI(session.paneID, "https://google.com")
131+
132+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
133+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
134+
135+ assert.Equal(t, "https://google.com", session.pane.CurrentURL())
136+ assert.False(t, session.pane.IsOmniboxVisible())
137+}
138+
139+func TestFloatingPane_ProfileSessionsRemainIndependent(t *testing.T) {
140+ app, tabID, _ := newFloatingPaneTestApp(t)
141+
142+ gmailSession := newFloatingPaneSession(tabID, "profile:gmail")
143+ blankSession := newFloatingPaneSession(tabID, "profile:blank")
144+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = gmailSession
145+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:blank"}] = blankSession
146+
147+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
148+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
149+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:blank", "about:blank"))
150+
151+ assert.Equal(t, "https://mail.google.com", gmailSession.pane.CurrentURL())
152+ assert.False(t, gmailSession.pane.IsVisible())
153+ assert.Equal(t, "about:blank", blankSession.pane.CurrentURL())
154+ assert.True(t, blankSession.pane.IsVisible())
155+ assert.NotEqual(t, gmailSession.paneID, blankSession.paneID)
156+}
157+
158+func TestFloatingPane_ProfileShortcutTogglesAndPreservesSessionState(t *testing.T) {
159+ app, tabID, _ := newFloatingPaneTestApp(t)
160+ session := newFloatingPaneSession(tabID, "profile:gmail")
161+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
162+
163+ baseURL := "https://mail.google.com"
164+ draftURL := "https://mail.google.com/mail/u/0/#inbox?compose=new"
165+
166+ // First open: navigates to base URL
167+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
168+ assert.True(t, session.pane.IsVisible())
169+ assert.Equal(t, baseURL, session.pane.CurrentURL())
170+
171+ // Simulate user navigating within gmail (compose draft etc.)
172+ app.updateFloatingSessionURI(session.paneID, draftURL)
173+ assert.Equal(t, draftURL, session.pane.CurrentURL())
174+
175+ // Second press: hides (toggle off), preserves draft URL
176+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
177+ assert.False(t, session.pane.IsVisible())
178+ assert.Equal(t, draftURL, session.pane.CurrentURL())
179+
180+ // Third press: shows again without re-navigating, draft URL preserved
181+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
182+ assert.True(t, session.pane.IsVisible())
183+ assert.Equal(t, draftURL, session.pane.CurrentURL())
184+}
185+
186+func TestFloatingPane_ProfileRapidToggleNeverShowsAboutBlank(t *testing.T) {
187+ app, tabID, _ := newFloatingPaneTestApp(t)
188+ session := newFloatingPaneSession(tabID, "profile:gmail")
189+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
190+
191+ baseURL := "https://mail.google.com"
192+
193+ // Rapid toggle 10 times: should never end up on about:blank
194+ for i := 0; i < 10; i++ {
195+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
196+ assert.NotEqual(t, "about:blank", session.pane.CurrentURL(),
197+ "iteration %d: profile session should never show about:blank", i)
198+ if session.pane.IsVisible() {
199+ assert.Equal(t, baseURL, session.pane.CurrentURL())
200+ }
201+ }
202+}
203+
204+func TestFloatingPane_DefaultSessionStillUsesAboutBlank(t *testing.T) {
205+ app, _, session := newFloatingPaneTestApp(t)
206+
207+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
208+ assert.True(t, session.pane.IsVisible())
209+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
210+}
211+
212+func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
213+ app, tabID, _ := newFloatingPaneTestApp(t)
214+ session := newFloatingPaneSession(tabID, "profile:gmail")
215+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
216+
217+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
218+
219+ handled := app.closeActiveFloatingPane(context.Background())
220+ assert.True(t, handled)
221+ assert.False(t, session.pane.IsVisible())
222+
223+ handled = app.closeActiveFloatingPane(context.Background())
224+ assert.False(t, handled)
225+}
226+
227+func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) {
228+ app, _, session := newFloatingPaneTestApp(t)
229+
230+ require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault))
231+ assert.True(t, session.pane.IsVisible())
232+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
233+
234+ require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault, "https://example.com"))
235+ assert.True(t, session.pane.IsVisible())
236+ assert.Equal(t, "https://example.com", session.pane.CurrentURL())
237+}
238+
239+func TestFloatingPane_EnsureFloatingSession_CachedAllowsNilWorkspaceView(t *testing.T) {
240+ app, tabID, session := newFloatingPaneTestApp(t)
241+
242+ got, err := app.ensureFloatingSession(context.Background(), tabID, floatingSessionIDDefault, nil)
243+ require.NoError(t, err)
244+ assert.Same(t, session, got)
245+}
246+
247+func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
248+ session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
249+ session.resizeWatcherActive = true
250+ session.resizeTickID = 42
251+
252+ app := &App{}
253+ app.hideFloatingSession(context.Background(), session)
254+
255+ assert.False(t, session.resizeWatcherActive)
256+ assert.Equal(t, uint(0), session.resizeTickID)
257+}
258+
259+func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
260+ t.Helper()
261+
262+ tabID := entity.TabID("tab-1")
263+ workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
264+ tabs := entity.NewTabList()
265+ tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})
266+
267+ session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
268+
269+ app := &App{
270+ tabs: tabs,
271+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
272+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
273+ {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
274+ },
275+ }
276+
277+ return app, tabID, session
278+}
279+
280+func newFloatingPaneSession(tabID entity.TabID, sessionID string) *floatingWorkspaceSession {
281+ return &floatingWorkspaceSession{
282+ paneID: floatingPaneIDForSession(tabID, sessionID),
283+ pane: component.NewFloatingPane(nil, component.FloatingPaneOptions{
284+ WidthPct: 0.82,
285+ HeightPct: 0.72,
286+ FallbackWidth: floatingPaneFallbackWidth,
287+ FallbackHeight: floatingPaneFallbackHeight,
288+ OnNavigate: func(context.Context, string) error {
289+ return nil
290+ },
291+ }),
292+ }
293+}
A internal/ui/input/floating_shortcuts_test.go +164 -0
@@ -0,0 +1,164 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
14+ cfg := config.DefaultConfig()
15+ set := NewShortcutSet(context.Background(), cfg)
16+
17+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)
18+ require.True(t, ok)
19+ assert.Equal(t, ActionToggleFloatingPane, action)
20+}
21+
22+func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
23+ cfg := config.DefaultConfig()
24+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
25+ "google": {
26+ Keys: []string{"alt+g"},
27+ URL: "https://google.com",
28+ },
29+ "github": {
30+ Keys: []string{"alt+y"},
31+ URL: "https://github.com",
32+ },
33+ }
34+
35+ set := NewShortcutSet(context.Background(), cfg)
36+
37+ googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
38+ require.True(t, ok)
39+ googleTarget, ok := ParseFloatingProfileTarget(googleAction)
40+ require.True(t, ok)
41+ assert.Equal(t, "google", googleTarget.SessionID)
42+ assert.Equal(t, "https://google.com", googleTarget.URL)
43+
44+ githubAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_y), Modifiers: ModAlt}, ModeNormal)
45+ require.True(t, ok)
46+ githubTarget, ok := ParseFloatingProfileTarget(githubAction)
47+ require.True(t, ok)
48+ assert.Equal(t, "github", githubTarget.SessionID)
49+ assert.Equal(t, "https://github.com", githubTarget.URL)
50+}
51+
52+func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
53+ cfg := config.DefaultConfig()
54+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
55+ "tab-conflict": {
56+ Keys: []string{"alt+1"},
57+ URL: "https://example.com/tab",
58+ },
59+ "pane-conflict": {
60+ Keys: []string{"alt+["},
61+ URL: "https://example.com/pane",
62+ },
63+ "ok": {
64+ Keys: []string{"alt+g"},
65+ URL: "https://example.com/ok",
66+ },
67+ }
68+
69+ occupied := map[KeyBinding]Action{
70+ {Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}: ActionSwitchTabIndex1,
71+ {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,
72+ }
73+
74+ shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied)
75+ require.Len(t, shortcuts, 1)
76+ assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)
77+ target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)
78+ require.True(t, ok)
79+ assert.Equal(t, "ok", target.SessionID)
80+ assert.Equal(t, "https://example.com/ok", target.URL)
81+}
82+
83+func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {
84+ cfg := config.DefaultConfig()
85+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
86+ "conflict": {
87+ Keys: []string{"alt+["},
88+ URL: "https://example.com/override-attempt",
89+ },
90+ }
91+
92+ set := NewShortcutSet(context.Background(), cfg)
93+
94+ tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)
95+ require.True(t, ok)
96+ assert.Equal(t, ActionConsumeOrExpelLeft, tabAction)
97+
98+ omniboxAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_l), Modifiers: ModCtrl}, ModeNormal)
99+ require.True(t, ok)
100+ assert.Equal(t, ActionOpenOmnibox, omniboxAction)
101+}
102+
103+func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
104+ cfg := config.DefaultConfig()
105+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
106+ "alt-one-conflict": {
107+ Keys: []string{"alt+1"},
108+ URL: "https://example.com/one",
109+ },
110+ "alt-tab-conflict": {
111+ Keys: []string{"alt+tab"},
112+ URL: "https://example.com/tab",
113+ },
114+ "ok": {
115+ Keys: []string{"alt+g"},
116+ URL: "https://example.com/ok",
117+ },
118+ }
119+
120+ set := NewShortcutSet(context.Background(), cfg)
121+
122+ _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)
123+ assert.False(t, ok)
124+
125+ _, ok = set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}, ModeNormal)
126+ assert.False(t, ok)
127+
128+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
129+ require.True(t, ok)
130+ target, ok := ParseFloatingProfileTarget(action)
131+ require.True(t, ok)
132+ assert.Equal(t, "ok", target.SessionID)
133+ assert.Equal(t, "https://example.com/ok", target.URL)
134+}
135+
136+func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {
137+ cfg := config.DefaultConfig()
138+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
139+ "work-mail": {
140+ Keys: []string{"alt+w"},
141+ URL: "https://mail.google.com",
142+ },
143+ "personal-mail": {
144+ Keys: []string{"alt+p"},
145+ URL: "https://mail.google.com",
146+ },
147+ }
148+
149+ set := NewShortcutSet(context.Background(), cfg)
150+
151+ workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)
152+ require.True(t, ok)
153+ workTarget, ok := ParseFloatingProfileTarget(workAction)
154+ require.True(t, ok)
155+
156+ personalAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_p), Modifiers: ModAlt}, ModeNormal)
157+ require.True(t, ok)
158+ personalTarget, ok := ParseFloatingProfileTarget(personalAction)
159+ require.True(t, ok)
160+
161+ assert.Equal(t, "https://mail.google.com", workTarget.URL)
162+ assert.Equal(t, "https://mail.google.com", personalTarget.URL)
163+ assert.NotEqual(t, workTarget.SessionID, personalTarget.SessionID)
164+}
@@ -0,0 +1,164 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
14+ cfg := config.DefaultConfig()
15+ set := NewShortcutSet(context.Background(), cfg)
16+
17+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)
18+ require.True(t, ok)
19+ assert.Equal(t, ActionToggleFloatingPane, action)
20+}
21+
22+func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
23+ cfg := config.DefaultConfig()
24+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
25+ "google": {
26+ Keys: []string{"alt+g"},
27+ URL: "https://google.com",
28+ },
29+ "github": {
30+ Keys: []string{"alt+y"},
31+ URL: "https://github.com",
32+ },
33+ }
34+
35+ set := NewShortcutSet(context.Background(), cfg)
36+
37+ googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
38+ require.True(t, ok)
39+ googleTarget, ok := ParseFloatingProfileTarget(googleAction)
40+ require.True(t, ok)
41+ assert.Equal(t, "google", googleTarget.SessionID)
42+ assert.Equal(t, "https://google.com", googleTarget.URL)
43+
44+ githubAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_y), Modifiers: ModAlt}, ModeNormal)
45+ require.True(t, ok)
46+ githubTarget, ok := ParseFloatingProfileTarget(githubAction)
47+ require.True(t, ok)
48+ assert.Equal(t, "github", githubTarget.SessionID)
49+ assert.Equal(t, "https://github.com", githubTarget.URL)
50+}
51+
52+func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
53+ cfg := config.DefaultConfig()
54+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
55+ "tab-conflict": {
56+ Keys: []string{"alt+1"},
57+ URL: "https://example.com/tab",
58+ },
59+ "pane-conflict": {
60+ Keys: []string{"alt+["},
61+ URL: "https://example.com/pane",
62+ },
63+ "ok": {
64+ Keys: []string{"alt+g"},
65+ URL: "https://example.com/ok",
66+ },
67+ }
68+
69+ occupied := map[KeyBinding]Action{
70+ {Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}: ActionSwitchTabIndex1,
71+ {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,
72+ }
73+
74+ shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied)
75+ require.Len(t, shortcuts, 1)
76+ assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)
77+ target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)
78+ require.True(t, ok)
79+ assert.Equal(t, "ok", target.SessionID)
80+ assert.Equal(t, "https://example.com/ok", target.URL)
81+}
82+
83+func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {
84+ cfg := config.DefaultConfig()
85+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
86+ "conflict": {
87+ Keys: []string{"alt+["},
88+ URL: "https://example.com/override-attempt",
89+ },
90+ }
91+
92+ set := NewShortcutSet(context.Background(), cfg)
93+
94+ tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)
95+ require.True(t, ok)
96+ assert.Equal(t, ActionConsumeOrExpelLeft, tabAction)
97+
98+ omniboxAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_l), Modifiers: ModCtrl}, ModeNormal)
99+ require.True(t, ok)
100+ assert.Equal(t, ActionOpenOmnibox, omniboxAction)
101+}
102+
103+func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
104+ cfg := config.DefaultConfig()
105+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
106+ "alt-one-conflict": {
107+ Keys: []string{"alt+1"},
108+ URL: "https://example.com/one",
109+ },
110+ "alt-tab-conflict": {
111+ Keys: []string{"alt+tab"},
112+ URL: "https://example.com/tab",
113+ },
114+ "ok": {
115+ Keys: []string{"alt+g"},
116+ URL: "https://example.com/ok",
117+ },
118+ }
119+
120+ set := NewShortcutSet(context.Background(), cfg)
121+
122+ _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)
123+ assert.False(t, ok)
124+
125+ _, ok = set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}, ModeNormal)
126+ assert.False(t, ok)
127+
128+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
129+ require.True(t, ok)
130+ target, ok := ParseFloatingProfileTarget(action)
131+ require.True(t, ok)
132+ assert.Equal(t, "ok", target.SessionID)
133+ assert.Equal(t, "https://example.com/ok", target.URL)
134+}
135+
136+func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {
137+ cfg := config.DefaultConfig()
138+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
139+ "work-mail": {
140+ Keys: []string{"alt+w"},
141+ URL: "https://mail.google.com",
142+ },
143+ "personal-mail": {
144+ Keys: []string{"alt+p"},
145+ URL: "https://mail.google.com",
146+ },
147+ }
148+
149+ set := NewShortcutSet(context.Background(), cfg)
150+
151+ workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)
152+ require.True(t, ok)
153+ workTarget, ok := ParseFloatingProfileTarget(workAction)
154+ require.True(t, ok)
155+
156+ personalAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_p), Modifiers: ModAlt}, ModeNormal)
157+ require.True(t, ok)
158+ personalTarget, ok := ParseFloatingProfileTarget(personalAction)
159+ require.True(t, ok)
160+
161+ assert.Equal(t, "https://mail.google.com", workTarget.URL)
162+ assert.Equal(t, "https://mail.google.com", personalTarget.URL)
163+ assert.NotEqual(t, workTarget.SessionID, personalTarget.SessionID)
164+}
M internal/ui/input/global_shortcuts.go +55 -6
@@ -3,6 +3,7 @@ package input
33
44 import (
55 "context"
6+ "strings"
67
78 "github.com/bnema/dumber/internal/infrastructure/config"
89 "github.com/bnema/dumber/internal/logging"
@@ -18,6 +19,7 @@ type GlobalShortcutHandler struct {
1819 controller *gtk.ShortcutController
1920 onAction ActionHandler
2021 ctx context.Context
22+ registered map[KeyBinding]Action
2123
2224 // Keep references to callbacks to prevent GC from collecting them
2325 callbacks []gtk.ShortcutFunc
@@ -39,6 +41,7 @@ func NewGlobalShortcutHandler(
3941 onAction: onAction,
4042 ctx: ctx,
4143 callbacks: make([]gtk.ShortcutFunc, 0),
44+ registered: make(map[KeyBinding]Action),
4245 }
4346
4447 if h.controller == nil {
@@ -96,6 +99,7 @@ func NewGlobalShortcutHandler(
9699 if cfg != nil {
97100 // Map action names to action constants for global shortcuts
98101 actionMap := map[string]Action{
102+ "toggle_floating_pane": ActionToggleFloatingPane,
99103 "consume_or_expel_left": ActionConsumeOrExpelLeft,
100104 "consume_or_expel_right": ActionConsumeOrExpelRight,
101105 "consume_or_expel_up": ActionConsumeOrExpelUp,
@@ -113,8 +117,25 @@ func NewGlobalShortcutHandler(
113117 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
114118 continue
115119 }
116- h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
117- log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
120+ if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
121+ log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
122+ }
123+ }
124+ }
125+
126+ occupied := make(map[KeyBinding]Action, len(h.registered))
127+ for binding, action := range h.registered {
128+ occupied[binding] = action
129+ }
130+ for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
131+ if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
132+ continue
133+ }
134+ if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
135+ log.Trace().
136+ Str("shortcut", formatBinding(shortcut.Binding)).
137+ Str("url", url).
138+ Msg("registered floating profile global shortcut")
118139 }
119140 }
120141 }
@@ -130,14 +151,24 @@ func NewGlobalShortcutHandler(
130151 }
131152
132153 // registerShortcut creates and registers a single shortcut with the controller.
133-func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
154+func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
155+ binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
156+ if existing, exists := h.registered[binding]; exists {
157+ logging.FromContext(h.ctx).Warn().
158+ Str("existing_action", string(existing)).
159+ Str("new_action", string(action)).
160+ Str("shortcut", formatBinding(binding)).
161+ Msg("global shortcut conflict, skipping")
162+ return false
163+ }
164+
134165 // Create trigger for this key combination
135166 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
136167 if trigger == nil {
137168 logging.FromContext(h.ctx).Error().
138169 Uint("keyval", keyval).
139170 Msg("failed to create keyval trigger")
140- return
171+ return false
141172 }
142173
143174 // Create callback action
@@ -169,7 +200,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
169200 logging.FromContext(h.ctx).Error().
170201 Uint("keyval", keyval).
171202 Msg("failed to create callback action")
172- return
203+ return false
173204 }
174205
175206 // Create the shortcut combining trigger and action
@@ -178,11 +209,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
178209 logging.FromContext(h.ctx).Error().
179210 Uint("keyval", keyval).
180211 Msg("failed to create shortcut")
181- return
212+ return false
182213 }
183214
184215 // Add to controller
185216 h.controller.AddShortcut(shortcut)
217+ h.registered[binding] = action
218+ return true
186219 }
187220
188221 // Detach removes the global shortcut handler from the window.
@@ -191,4 +224,20 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191224 func (h *GlobalShortcutHandler) Detach() {
192225 h.controller = nil
193226 h.callbacks = nil
227+ h.registered = nil
228+}
229+
230+func formatBinding(binding KeyBinding) string {
231+ parts := make([]string, 0, 3)
232+ if binding.Modifiers&ModCtrl != 0 {
233+ parts = append(parts, "ctrl")
234+ }
235+ if binding.Modifiers&ModShift != 0 {
236+ parts = append(parts, "shift")
237+ }
238+ if binding.Modifiers&ModAlt != 0 {
239+ parts = append(parts, "alt")
240+ }
241+ parts = append(parts, strings.ToLower(gdk.KeyvalName(binding.Keyval)))
242+ return strings.Join(parts, "+")
194243 }
@@ -3,6 +3,7 @@ package input
3 3
4 import (4 import (
5 "context"5 "context"
6+ "strings"
6 7
7 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
@@ -18,6 +19,7 @@ type GlobalShortcutHandler struct {
18 controller *gtk.ShortcutController19 controller *gtk.ShortcutController
19 onAction ActionHandler20 onAction ActionHandler
20 ctx context.Context21 ctx context.Context
22+ registered map[KeyBinding]Action
21 23
22 // Keep references to callbacks to prevent GC from collecting them24 // Keep references to callbacks to prevent GC from collecting them
23 callbacks []gtk.ShortcutFunc25 callbacks []gtk.ShortcutFunc
@@ -39,6 +41,7 @@ func NewGlobalShortcutHandler(
39 onAction: onAction,41 onAction: onAction,
40 ctx: ctx,42 ctx: ctx,
41 callbacks: make([]gtk.ShortcutFunc, 0),43 callbacks: make([]gtk.ShortcutFunc, 0),
44+ registered: make(map[KeyBinding]Action),
42 }45 }
43 46
44 if h.controller == nil {47 if h.controller == nil {
@@ -96,6 +99,7 @@ func NewGlobalShortcutHandler(
96 if cfg != nil {99 if cfg != nil {
97 // Map action names to action constants for global shortcuts100 // Map action names to action constants for global shortcuts
98 actionMap := map[string]Action{101 actionMap := map[string]Action{
102+ "toggle_floating_pane": ActionToggleFloatingPane,
99 "consume_or_expel_left": ActionConsumeOrExpelLeft,103 "consume_or_expel_left": ActionConsumeOrExpelLeft,
100 "consume_or_expel_right": ActionConsumeOrExpelRight,104 "consume_or_expel_right": ActionConsumeOrExpelRight,
101 "consume_or_expel_up": ActionConsumeOrExpelUp,105 "consume_or_expel_up": ActionConsumeOrExpelUp,
@@ -113,8 +117,25 @@ func NewGlobalShortcutHandler(
113 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")117 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
114 continue118 continue
115 }119 }
116- h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)120+ if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
117- log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")121+ log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
122+ }
123+ }
124+ }
125+
126+ occupied := make(map[KeyBinding]Action, len(h.registered))
127+ for binding, action := range h.registered {
128+ occupied[binding] = action
129+ }
130+ for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
131+ if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
132+ continue
133+ }
134+ if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
135+ log.Trace().
136+ Str("shortcut", formatBinding(shortcut.Binding)).
137+ Str("url", url).
138+ Msg("registered floating profile global shortcut")
118 }139 }
119 }140 }
120 }141 }
@@ -130,14 +151,24 @@ func NewGlobalShortcutHandler(
130 }151 }
131 152
132 // registerShortcut creates and registers a single shortcut with the controller.153 // registerShortcut creates and registers a single shortcut with the controller.
133-func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {154+func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
155+ binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
156+ if existing, exists := h.registered[binding]; exists {
157+ logging.FromContext(h.ctx).Warn().
158+ Str("existing_action", string(existing)).
159+ Str("new_action", string(action)).
160+ Str("shortcut", formatBinding(binding)).
161+ Msg("global shortcut conflict, skipping")
162+ return false
163+ }
164+
134 // Create trigger for this key combination165 // Create trigger for this key combination
135 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)166 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
136 if trigger == nil {167 if trigger == nil {
137 logging.FromContext(h.ctx).Error().168 logging.FromContext(h.ctx).Error().
138 Uint("keyval", keyval).169 Uint("keyval", keyval).
139 Msg("failed to create keyval trigger")170 Msg("failed to create keyval trigger")
140- return171+ return false
141 }172 }
142 173
143 // Create callback action174 // Create callback action
@@ -169,7 +200,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
169 logging.FromContext(h.ctx).Error().200 logging.FromContext(h.ctx).Error().
170 Uint("keyval", keyval).201 Uint("keyval", keyval).
171 Msg("failed to create callback action")202 Msg("failed to create callback action")
172- return203+ return false
173 }204 }
174 205
175 // Create the shortcut combining trigger and action206 // Create the shortcut combining trigger and action
@@ -178,11 +209,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
178 logging.FromContext(h.ctx).Error().209 logging.FromContext(h.ctx).Error().
179 Uint("keyval", keyval).210 Uint("keyval", keyval).
180 Msg("failed to create shortcut")211 Msg("failed to create shortcut")
181- return212+ return false
182 }213 }
183 214
184 // Add to controller215 // Add to controller
185 h.controller.AddShortcut(shortcut)216 h.controller.AddShortcut(shortcut)
217+ h.registered[binding] = action
218+ return true
186 }219 }
187 220
188 // Detach removes the global shortcut handler from the window.221 // Detach removes the global shortcut handler from the window.
@@ -191,4 +224,20 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191 func (h *GlobalShortcutHandler) Detach() {224 func (h *GlobalShortcutHandler) Detach() {
192 h.controller = nil225 h.controller = nil
193 h.callbacks = nil226 h.callbacks = nil
227+ h.registered = nil
228+}
229+
230+func formatBinding(binding KeyBinding) string {
231+ parts := make([]string, 0, 3)
232+ if binding.Modifiers&ModCtrl != 0 {
233+ parts = append(parts, "ctrl")
234+ }
235+ if binding.Modifiers&ModShift != 0 {
236+ parts = append(parts, "shift")
237+ }
238+ if binding.Modifiers&ModAlt != 0 {
239+ parts = append(parts, "alt")
240+ }
241+ parts = append(parts, strings.ToLower(gdk.KeyvalName(binding.Keyval)))
242+ return strings.Join(parts, "+")
194 }243 }
M internal/ui/input/shortcuts.go +172 -7
@@ -3,6 +3,7 @@ package input
33
44 import (
55 "context"
6+ "sort"
67 "strings"
78
89 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -178,13 +179,14 @@ const (
178179 ActionZoomReset Action = "zoom_reset"
179180
180181 // UI
181- ActionOpenOmnibox Action = "open_omnibox"
182- ActionOpenFind Action = "open_find"
183- ActionFindNext Action = "find_next"
184- ActionFindPrev Action = "find_prev"
185- ActionCloseFind Action = "close_find"
186- ActionOpenDevTools Action = "open_devtools"
187- ActionToggleFullscreen Action = "toggle_fullscreen"
182+ ActionOpenOmnibox Action = "open_omnibox"
183+ ActionOpenFind Action = "open_find"
184+ ActionFindNext Action = "find_next"
185+ ActionFindPrev Action = "find_prev"
186+ ActionCloseFind Action = "close_find"
187+ ActionOpenDevTools Action = "open_devtools"
188+ ActionToggleFullscreen Action = "toggle_fullscreen"
189+ ActionToggleFloatingPane Action = "toggle_floating_pane"
188190
189191 // Clipboard
190192 ActionCopyURL Action = "copy_url"
@@ -196,6 +198,9 @@ const (
196198 ActionQuit Action = "quit"
197199 )
198200
201+const floatingProfileActionPrefix = "open_floating_profile:"
202+const floatingProfileActionSeparator = "|"
203+
199204 // ShortcutTable maps KeyBinding to Action.
200205 type ShortcutTable map[KeyBinding]Action
201206
@@ -248,6 +253,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Conf
248253 s.registerStandardShortcuts()
249254 s.registerPaneNavigationShortcuts()
250255 s.registerTabSwitchShortcuts()
256+ s.registerFloatingProfileShortcuts(ctx, cfg)
251257 }
252258
253259 // buildTabModeShortcuts populates tab mode shortcuts from config.
@@ -327,6 +333,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
327333 // However, these standard browser shortcuts ARE global:
328334 // Map action names to action constants
329335 actionMap := map[string]Action{
336+ "toggle_floating_pane": ActionToggleFloatingPane,
330337 "close_pane": ActionClosePane,
331338 "next_tab": ActionNextTab,
332339 "previous_tab": ActionPreviousTab,
@@ -349,6 +356,111 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
349356 }
350357 }
351358
359+func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {
360+ occupied := make(map[KeyBinding]Action, len(s.Global))
361+ for binding, action := range s.Global {
362+ occupied[binding] = action
363+ }
364+
365+ for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
366+ s.Global[shortcut.Binding] = shortcut.Action
367+ }
368+}
369+
370+type floatingProfileShortcut struct {
371+ Binding KeyBinding
372+ Action Action
373+}
374+
375+func collectFloatingProfileShortcuts(
376+ ctx context.Context,
377+ cfg *config.Config,
378+ occupied map[KeyBinding]Action,
379+) []floatingProfileShortcut {
380+ if cfg == nil {
381+ return nil
382+ }
383+
384+ log := logging.FromContext(ctx)
385+ reserveGlobalOnlyShortcutBindings(occupied)
386+ result := make([]floatingProfileShortcut, 0)
387+ profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))
388+ for profileName := range cfg.Workspace.FloatingPane.Profiles {
389+ profileNames = append(profileNames, profileName)
390+ }
391+ sort.Strings(profileNames)
392+
393+ for _, profileName := range profileNames {
394+ profile := cfg.Workspace.FloatingPane.Profiles[profileName]
395+ url := strings.TrimSpace(profile.URL)
396+ if url == "" {
397+ log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
398+ continue
399+ }
400+
401+ action := NewFloatingProfileAction(profileName, url)
402+ keys := append([]string(nil), profile.Keys...)
403+ sort.Strings(keys)
404+ for _, keyStr := range keys {
405+ binding, ok := ParseKeyString(keyStr)
406+ if !ok {
407+ log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
408+ continue
409+ }
410+
411+ if existing, exists := occupied[binding]; exists {
412+ log.Warn().
413+ Str("profile", profileName).
414+ Str("shortcut", keyStr).
415+ Str("existing_action", string(existing)).
416+ Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
417+ continue
418+ }
419+
420+ occupied[binding] = action
421+ result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
422+ }
423+ }
424+
425+ return result
426+}
427+
428+func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
429+ if occupied == nil {
430+ return
431+ }
432+
433+ tabIndexActions := []Action{
434+ ActionSwitchTabIndex1,
435+ ActionSwitchTabIndex2,
436+ ActionSwitchTabIndex3,
437+ ActionSwitchTabIndex4,
438+ ActionSwitchTabIndex5,
439+ ActionSwitchTabIndex6,
440+ ActionSwitchTabIndex7,
441+ ActionSwitchTabIndex8,
442+ ActionSwitchTabIndex9,
443+ }
444+
445+ for i, action := range tabIndexActions {
446+ binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
447+ if _, exists := occupied[binding]; exists {
448+ continue
449+ }
450+ occupied[binding] = action
451+ }
452+
453+ altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
454+ if _, exists := occupied[altZero]; !exists {
455+ occupied[altZero] = ActionSwitchTabIndex10
456+ }
457+
458+ altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
459+ if _, exists := occupied[altTab]; !exists {
460+ occupied[altTab] = ActionSwitchLastTab
461+ }
462+}
463+
352464 func (s *ShortcutSet) registerStandardShortcuts() {
353465 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
354466 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
@@ -428,6 +540,8 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
428540 }
429541
430542 var configActionToAction = map[string]Action{
543+ "toggle_floating_pane": ActionToggleFloatingPane,
544+
431545 // Tab actions
432546 "new-tab": ActionNewTab,
433547 "close-tab": ActionCloseTab,
@@ -478,6 +592,57 @@ var configActionToAction = map[string]Action{
478592 "session-manager": ActionOpenSessionManager,
479593 }
480594
595+// FloatingProfileTarget carries the session identity and URL for a floating profile action.
596+type FloatingProfileTarget struct {
597+ SessionID string
598+ URL string
599+}
600+
601+// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
602+func NewFloatingProfileAction(profileName, url string) Action {
603+ sessionID := strings.TrimSpace(profileName)
604+ trimmedURL := strings.TrimSpace(url)
605+ if sessionID == "" {
606+ sessionID = trimmedURL
607+ }
608+ return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
609+}
610+
611+// ParseFloatingProfileTarget extracts the session identity and target URL.
612+func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
613+ value := string(action)
614+ if !strings.HasPrefix(value, floatingProfileActionPrefix) {
615+ return FloatingProfileTarget{}, false
616+ }
617+
618+ payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
619+ sessionID := ""
620+ url := payload
621+ if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
622+ sessionID = strings.TrimSpace(parts[0])
623+ url = parts[1]
624+ }
625+
626+ url = strings.TrimSpace(url)
627+ if url == "" {
628+ return FloatingProfileTarget{}, false
629+ }
630+ if sessionID == "" {
631+ sessionID = url
632+ }
633+
634+ return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
635+}
636+
637+// ParseFloatingProfileAction extracts the target URL from a floating profile action.
638+func ParseFloatingProfileAction(action Action) (string, bool) {
639+ target, ok := ParseFloatingProfileTarget(action)
640+ if !ok {
641+ return "", false
642+ }
643+ return target.URL, true
644+}
645+
481646 // mapConfigAction maps config action names to Action constants.
482647 func mapConfigAction(configAction string) Action {
483648 if configAction == "cancel" || configAction == "confirm" {
@@ -3,6 +3,7 @@ package input
3 3
4 import (4 import (
5 "context"5 "context"
6+ "sort"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -178,13 +179,14 @@ const (
178 ActionZoomReset Action = "zoom_reset"179 ActionZoomReset Action = "zoom_reset"
179 180
180 // UI181 // UI
181- ActionOpenOmnibox Action = "open_omnibox"182+ ActionOpenOmnibox Action = "open_omnibox"
182- ActionOpenFind Action = "open_find"183+ ActionOpenFind Action = "open_find"
183- ActionFindNext Action = "find_next"184+ ActionFindNext Action = "find_next"
184- ActionFindPrev Action = "find_prev"185+ ActionFindPrev Action = "find_prev"
185- ActionCloseFind Action = "close_find"186+ ActionCloseFind Action = "close_find"
186- ActionOpenDevTools Action = "open_devtools"187+ ActionOpenDevTools Action = "open_devtools"
187- ActionToggleFullscreen Action = "toggle_fullscreen"188+ ActionToggleFullscreen Action = "toggle_fullscreen"
189+ ActionToggleFloatingPane Action = "toggle_floating_pane"
188 190
189 // Clipboard191 // Clipboard
190 ActionCopyURL Action = "copy_url"192 ActionCopyURL Action = "copy_url"
@@ -196,6 +198,9 @@ const (
196 ActionQuit Action = "quit"198 ActionQuit Action = "quit"
197 )199 )
198 200
201+const floatingProfileActionPrefix = "open_floating_profile:"
202+const floatingProfileActionSeparator = "|"
203+
199 // ShortcutTable maps KeyBinding to Action.204 // ShortcutTable maps KeyBinding to Action.
200 type ShortcutTable map[KeyBinding]Action205 type ShortcutTable map[KeyBinding]Action
201 206
@@ -248,6 +253,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Conf
248 s.registerStandardShortcuts()253 s.registerStandardShortcuts()
249 s.registerPaneNavigationShortcuts()254 s.registerPaneNavigationShortcuts()
250 s.registerTabSwitchShortcuts()255 s.registerTabSwitchShortcuts()
256+ s.registerFloatingProfileShortcuts(ctx, cfg)
251 }257 }
252 258
253 // buildTabModeShortcuts populates tab mode shortcuts from config.259 // buildTabModeShortcuts populates tab mode shortcuts from config.
@@ -327,6 +333,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
327 // However, these standard browser shortcuts ARE global:333 // However, these standard browser shortcuts ARE global:
328 // Map action names to action constants334 // Map action names to action constants
329 actionMap := map[string]Action{335 actionMap := map[string]Action{
336+ "toggle_floating_pane": ActionToggleFloatingPane,
330 "close_pane": ActionClosePane,337 "close_pane": ActionClosePane,
331 "next_tab": ActionNextTab,338 "next_tab": ActionNextTab,
332 "previous_tab": ActionPreviousTab,339 "previous_tab": ActionPreviousTab,
@@ -349,6 +356,111 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
349 }356 }
350 }357 }
351 358
359+func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {
360+ occupied := make(map[KeyBinding]Action, len(s.Global))
361+ for binding, action := range s.Global {
362+ occupied[binding] = action
363+ }
364+
365+ for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
366+ s.Global[shortcut.Binding] = shortcut.Action
367+ }
368+}
369+
370+type floatingProfileShortcut struct {
371+ Binding KeyBinding
372+ Action Action
373+}
374+
375+func collectFloatingProfileShortcuts(
376+ ctx context.Context,
377+ cfg *config.Config,
378+ occupied map[KeyBinding]Action,
379+) []floatingProfileShortcut {
380+ if cfg == nil {
381+ return nil
382+ }
383+
384+ log := logging.FromContext(ctx)
385+ reserveGlobalOnlyShortcutBindings(occupied)
386+ result := make([]floatingProfileShortcut, 0)
387+ profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))
388+ for profileName := range cfg.Workspace.FloatingPane.Profiles {
389+ profileNames = append(profileNames, profileName)
390+ }
391+ sort.Strings(profileNames)
392+
393+ for _, profileName := range profileNames {
394+ profile := cfg.Workspace.FloatingPane.Profiles[profileName]
395+ url := strings.TrimSpace(profile.URL)
396+ if url == "" {
397+ log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
398+ continue
399+ }
400+
401+ action := NewFloatingProfileAction(profileName, url)
402+ keys := append([]string(nil), profile.Keys...)
403+ sort.Strings(keys)
404+ for _, keyStr := range keys {
405+ binding, ok := ParseKeyString(keyStr)
406+ if !ok {
407+ log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
408+ continue
409+ }
410+
411+ if existing, exists := occupied[binding]; exists {
412+ log.Warn().
413+ Str("profile", profileName).
414+ Str("shortcut", keyStr).
415+ Str("existing_action", string(existing)).
416+ Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
417+ continue
418+ }
419+
420+ occupied[binding] = action
421+ result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
422+ }
423+ }
424+
425+ return result
426+}
427+
428+func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
429+ if occupied == nil {
430+ return
431+ }
432+
433+ tabIndexActions := []Action{
434+ ActionSwitchTabIndex1,
435+ ActionSwitchTabIndex2,
436+ ActionSwitchTabIndex3,
437+ ActionSwitchTabIndex4,
438+ ActionSwitchTabIndex5,
439+ ActionSwitchTabIndex6,
440+ ActionSwitchTabIndex7,
441+ ActionSwitchTabIndex8,
442+ ActionSwitchTabIndex9,
443+ }
444+
445+ for i, action := range tabIndexActions {
446+ binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
447+ if _, exists := occupied[binding]; exists {
448+ continue
449+ }
450+ occupied[binding] = action
451+ }
452+
453+ altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
454+ if _, exists := occupied[altZero]; !exists {
455+ occupied[altZero] = ActionSwitchTabIndex10
456+ }
457+
458+ altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
459+ if _, exists := occupied[altTab]; !exists {
460+ occupied[altTab] = ActionSwitchLastTab
461+ }
462+}
463+
352 func (s *ShortcutSet) registerStandardShortcuts() {464 func (s *ShortcutSet) registerStandardShortcuts() {
353 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox465 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
354 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind466 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
@@ -428,6 +540,8 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
428 }540 }
429 541
430 var configActionToAction = map[string]Action{542 var configActionToAction = map[string]Action{
543+ "toggle_floating_pane": ActionToggleFloatingPane,
544+
431 // Tab actions545 // Tab actions
432 "new-tab": ActionNewTab,546 "new-tab": ActionNewTab,
433 "close-tab": ActionCloseTab,547 "close-tab": ActionCloseTab,
@@ -478,6 +592,57 @@ var configActionToAction = map[string]Action{
478 "session-manager": ActionOpenSessionManager,592 "session-manager": ActionOpenSessionManager,
479 }593 }
480 594
595+// FloatingProfileTarget carries the session identity and URL for a floating profile action.
596+type FloatingProfileTarget struct {
597+ SessionID string
598+ URL string
599+}
600+
601+// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
602+func NewFloatingProfileAction(profileName, url string) Action {
603+ sessionID := strings.TrimSpace(profileName)
604+ trimmedURL := strings.TrimSpace(url)
605+ if sessionID == "" {
606+ sessionID = trimmedURL
607+ }
608+ return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
609+}
610+
611+// ParseFloatingProfileTarget extracts the session identity and target URL.
612+func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
613+ value := string(action)
614+ if !strings.HasPrefix(value, floatingProfileActionPrefix) {
615+ return FloatingProfileTarget{}, false
616+ }
617+
618+ payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
619+ sessionID := ""
620+ url := payload
621+ if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
622+ sessionID = strings.TrimSpace(parts[0])
623+ url = parts[1]
624+ }
625+
626+ url = strings.TrimSpace(url)
627+ if url == "" {
628+ return FloatingProfileTarget{}, false
629+ }
630+ if sessionID == "" {
631+ sessionID = url
632+ }
633+
634+ return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
635+}
636+
637+// ParseFloatingProfileAction extracts the target URL from a floating profile action.
638+func ParseFloatingProfileAction(action Action) (string, bool) {
639+ target, ok := ParseFloatingProfileTarget(action)
640+ if !ok {
641+ return "", false
642+ }
643+ return target.URL, true
644+}
645+
481 // mapConfigAction maps config action names to Action constants.646 // mapConfigAction maps config action names to Action constants.
482 func mapConfigAction(configAction string) Action {647 func mapConfigAction(configAction string) Action {
483 if configAction == "cancel" || configAction == "confirm" {648 if configAction == "cancel" || configAction == "confirm" {
M internal/ui/input/shortcuts_test.go +7 -0
@@ -375,3 +375,10 @@ func TestShouldAutoExitMode(t *testing.T) {
375375 }
376376 }
377377 }
378+
379+func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
380+ action := mapConfigAction("toggle_floating_pane")
381+ if action != ActionToggleFloatingPane {
382+ t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)
383+ }
384+}
@@ -375,3 +375,10 @@ func TestShouldAutoExitMode(t *testing.T) {
375 }375 }
376 }376 }
377 }377 }
378+
379+func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
380+ action := mapConfigAction("toggle_floating_pane")
381+ if action != ActionToggleFloatingPane {
382+ t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)
383+ }
384+}
M internal/ui/layout/gtk_widgets.go +9 -0
@@ -31,6 +31,7 @@ func (w *gtkWidget) Show() { w.inner.Show() }
3131 func (w *gtkWidget) Hide() { w.inner.Hide() }
3232 func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }
3333 func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }
34+func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }
3435 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
3536 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
3637 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
@@ -87,6 +88,7 @@ func (p *gtkPaned) Show() { p.inner.Show() }
8788 func (p *gtkPaned) Hide() { p.inner.Hide() }
8889 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }
8990 func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible() }
91+func (p *gtkPaned) SetOpacity(opacity float64) { p.inner.SetOpacity(opacity) }
9092 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
9193 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
9294 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
@@ -209,6 +211,7 @@ func (b *gtkBox) Show() { b.inner.Show() }
209211 func (b *gtkBox) Hide() { b.inner.Hide() }
210212 func (b *gtkBox) SetVisible(visible bool) { b.inner.SetVisible(visible) }
211213 func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }
214+func (b *gtkBox) SetOpacity(opacity float64) { b.inner.SetOpacity(opacity) }
212215 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
213216 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
214217 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
@@ -315,6 +318,7 @@ func (o *gtkOverlay) Show() { o.inner.Show() }
315318 func (o *gtkOverlay) Hide() { o.inner.Hide() }
316319 func (o *gtkOverlay) SetVisible(visible bool) { o.inner.SetVisible(visible) }
317320 func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible() }
321+func (o *gtkOverlay) SetOpacity(opacity float64) { o.inner.SetOpacity(opacity) }
318322 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }
319323 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
320324 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
@@ -429,6 +433,7 @@ func (l *gtkLabel) Show() { l.inner.Show() }
429433 func (l *gtkLabel) Hide() { l.inner.Hide() }
430434 func (l *gtkLabel) SetVisible(visible bool) { l.inner.SetVisible(visible) }
431435 func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible() }
436+func (l *gtkLabel) SetOpacity(opacity float64) { l.inner.SetOpacity(opacity) }
432437 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
433438 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
434439 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
@@ -492,6 +497,7 @@ func (btn *gtkButton) Show() { btn.inner.Show() }
492497 func (btn *gtkButton) Hide() { btn.inner.Hide() }
493498 func (btn *gtkButton) SetVisible(visible bool) { btn.inner.SetVisible(visible) }
494499 func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisible() }
500+func (btn *gtkButton) SetOpacity(opacity float64) { btn.inner.SetOpacity(opacity) }
495501 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }
496502 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
497503 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
@@ -575,6 +581,7 @@ func (img *gtkImage) Show() { img.inner.Show() }
575581 func (img *gtkImage) Hide() { img.inner.Hide() }
576582 func (img *gtkImage) SetVisible(visible bool) { img.inner.SetVisible(visible) }
577583 func (img *gtkImage) IsVisible() bool { return img.inner.GetVisible() }
584+func (img *gtkImage) SetOpacity(opacity float64) { img.inner.SetOpacity(opacity) }
578585 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }
579586 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
580587 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
@@ -647,6 +654,7 @@ func (p *gtkProgressBar) Show() { p.inner.Show() }
647654 func (p *gtkProgressBar) Hide() { p.inner.Hide() }
648655 func (p *gtkProgressBar) SetVisible(visible bool) { p.inner.SetVisible(visible) }
649656 func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisible() }
657+func (p *gtkProgressBar) SetOpacity(opacity float64) { p.inner.SetOpacity(opacity) }
650658 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }
651659 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }
652660 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
@@ -706,6 +714,7 @@ func (s *gtkSpinner) Show() { s.inner.Show() }
706714 func (s *gtkSpinner) Hide() { s.inner.Hide() }
707715 func (s *gtkSpinner) SetVisible(visible bool) { s.inner.SetVisible(visible) }
708716 func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible() }
717+func (s *gtkSpinner) SetOpacity(opacity float64) { s.inner.SetOpacity(opacity) }
709718 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
710719 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
711720 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
@@ -31,6 +31,7 @@ func (w *gtkWidget) Show() { w.inner.Show() }
31 func (w *gtkWidget) Hide() { w.inner.Hide() }31 func (w *gtkWidget) Hide() { w.inner.Hide() }
32 func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }32 func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }
33 func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }33 func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }
34+func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }
34 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }35 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
35 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }36 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
36 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }37 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
@@ -87,6 +88,7 @@ func (p *gtkPaned) Show() { p.inner.Show() }
87 func (p *gtkPaned) Hide() { p.inner.Hide() }88 func (p *gtkPaned) Hide() { p.inner.Hide() }
88 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }89 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }
89 func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible() }90 func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible() }
91+func (p *gtkPaned) SetOpacity(opacity float64) { p.inner.SetOpacity(opacity) }
90 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }92 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
91 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }93 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
92 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }94 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
@@ -209,6 +211,7 @@ func (b *gtkBox) Show() { b.inner.Show() }
209 func (b *gtkBox) Hide() { b.inner.Hide() }211 func (b *gtkBox) Hide() { b.inner.Hide() }
210 func (b *gtkBox) SetVisible(visible bool) { b.inner.SetVisible(visible) }212 func (b *gtkBox) SetVisible(visible bool) { b.inner.SetVisible(visible) }
211 func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }213 func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }
214+func (b *gtkBox) SetOpacity(opacity float64) { b.inner.SetOpacity(opacity) }
212 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }215 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
213 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }216 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
214 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }217 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
@@ -315,6 +318,7 @@ func (o *gtkOverlay) Show() { o.inner.Show() }
315 func (o *gtkOverlay) Hide() { o.inner.Hide() }318 func (o *gtkOverlay) Hide() { o.inner.Hide() }
316 func (o *gtkOverlay) SetVisible(visible bool) { o.inner.SetVisible(visible) }319 func (o *gtkOverlay) SetVisible(visible bool) { o.inner.SetVisible(visible) }
317 func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible() }320 func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible() }
321+func (o *gtkOverlay) SetOpacity(opacity float64) { o.inner.SetOpacity(opacity) }
318 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }322 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }
319 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }323 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
320 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }324 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
@@ -429,6 +433,7 @@ func (l *gtkLabel) Show() { l.inner.Show() }
429 func (l *gtkLabel) Hide() { l.inner.Hide() }433 func (l *gtkLabel) Hide() { l.inner.Hide() }
430 func (l *gtkLabel) SetVisible(visible bool) { l.inner.SetVisible(visible) }434 func (l *gtkLabel) SetVisible(visible bool) { l.inner.SetVisible(visible) }
431 func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible() }435 func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible() }
436+func (l *gtkLabel) SetOpacity(opacity float64) { l.inner.SetOpacity(opacity) }
432 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }437 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
433 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }438 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
434 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }439 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
@@ -492,6 +497,7 @@ func (btn *gtkButton) Show() { btn.inner.Show() }
492 func (btn *gtkButton) Hide() { btn.inner.Hide() }497 func (btn *gtkButton) Hide() { btn.inner.Hide() }
493 func (btn *gtkButton) SetVisible(visible bool) { btn.inner.SetVisible(visible) }498 func (btn *gtkButton) SetVisible(visible bool) { btn.inner.SetVisible(visible) }
494 func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisible() }499 func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisible() }
500+func (btn *gtkButton) SetOpacity(opacity float64) { btn.inner.SetOpacity(opacity) }
495 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }501 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }
496 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }502 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
497 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }503 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
@@ -575,6 +581,7 @@ func (img *gtkImage) Show() { img.inner.Show() }
575 func (img *gtkImage) Hide() { img.inner.Hide() }581 func (img *gtkImage) Hide() { img.inner.Hide() }
576 func (img *gtkImage) SetVisible(visible bool) { img.inner.SetVisible(visible) }582 func (img *gtkImage) SetVisible(visible bool) { img.inner.SetVisible(visible) }
577 func (img *gtkImage) IsVisible() bool { return img.inner.GetVisible() }583 func (img *gtkImage) IsVisible() bool { return img.inner.GetVisible() }
584+func (img *gtkImage) SetOpacity(opacity float64) { img.inner.SetOpacity(opacity) }
578 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }585 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }
579 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }586 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
580 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }587 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
@@ -647,6 +654,7 @@ func (p *gtkProgressBar) Show() { p.inner.Show() }
647 func (p *gtkProgressBar) Hide() { p.inner.Hide() }654 func (p *gtkProgressBar) Hide() { p.inner.Hide() }
648 func (p *gtkProgressBar) SetVisible(visible bool) { p.inner.SetVisible(visible) }655 func (p *gtkProgressBar) SetVisible(visible bool) { p.inner.SetVisible(visible) }
649 func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisible() }656 func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisible() }
657+func (p *gtkProgressBar) SetOpacity(opacity float64) { p.inner.SetOpacity(opacity) }
650 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }658 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }
651 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }659 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }
652 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }660 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
@@ -706,6 +714,7 @@ func (s *gtkSpinner) Show() { s.inner.Show() }
706 func (s *gtkSpinner) Hide() { s.inner.Hide() }714 func (s *gtkSpinner) Hide() { s.inner.Hide() }
707 func (s *gtkSpinner) SetVisible(visible bool) { s.inner.SetVisible(visible) }715 func (s *gtkSpinner) SetVisible(visible bool) { s.inner.SetVisible(visible) }
708 func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible() }716 func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible() }
717+func (s *gtkSpinner) SetOpacity(opacity float64) { s.inner.SetOpacity(opacity) }
709 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }718 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
710 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }719 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
711 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }720 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
M internal/ui/layout/mocks/mocks.go +360 -0
@@ -1028,6 +1028,46 @@ func (_c *MockWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *MockW
10281028 return _c
10291029 }
10301030
1031+// SetOpacity provides a mock function for the type MockWidget
1032+func (_mock *MockWidget) SetOpacity(opacity float64) {
1033+ _mock.Called(opacity)
1034+ return
1035+}
1036+
1037+// MockWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
1038+type MockWidget_SetOpacity_Call struct {
1039+ *mock.Call
1040+}
1041+
1042+// SetOpacity is a helper method to define mock.On call
1043+// - opacity float64
1044+func (_e *MockWidget_Expecter) SetOpacity(opacity interface{}) *MockWidget_SetOpacity_Call {
1045+ return &MockWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
1046+}
1047+
1048+func (_c *MockWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockWidget_SetOpacity_Call {
1049+ _c.Call.Run(func(args mock.Arguments) {
1050+ var arg0 float64
1051+ if args[0] != nil {
1052+ arg0 = args[0].(float64)
1053+ }
1054+ run(
1055+ arg0,
1056+ )
1057+ })
1058+ return _c
1059+}
1060+
1061+func (_c *MockWidget_SetOpacity_Call) Return() *MockWidget_SetOpacity_Call {
1062+ _c.Call.Return()
1063+ return _c
1064+}
1065+
1066+func (_c *MockWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockWidget_SetOpacity_Call {
1067+ _c.Run(run)
1068+ return _c
1069+}
1070+
10311071 // SetSizeRequest provides a mock function for the type MockWidget
10321072 func (_mock *MockWidget) SetSizeRequest(width int, height int) {
10331073 _mock.Called(width, height)
@@ -2746,6 +2786,46 @@ func (_c *MockPanedWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *
27462786 return _c
27472787 }
27482788
2789+// SetOpacity provides a mock function for the type MockPanedWidget
2790+func (_mock *MockPanedWidget) SetOpacity(opacity float64) {
2791+ _mock.Called(opacity)
2792+ return
2793+}
2794+
2795+// MockPanedWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
2796+type MockPanedWidget_SetOpacity_Call struct {
2797+ *mock.Call
2798+}
2799+
2800+// SetOpacity is a helper method to define mock.On call
2801+// - opacity float64
2802+func (_e *MockPanedWidget_Expecter) SetOpacity(opacity interface{}) *MockPanedWidget_SetOpacity_Call {
2803+ return &MockPanedWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
2804+}
2805+
2806+func (_c *MockPanedWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockPanedWidget_SetOpacity_Call {
2807+ _c.Call.Run(func(args mock.Arguments) {
2808+ var arg0 float64
2809+ if args[0] != nil {
2810+ arg0 = args[0].(float64)
2811+ }
2812+ run(
2813+ arg0,
2814+ )
2815+ })
2816+ return _c
2817+}
2818+
2819+func (_c *MockPanedWidget_SetOpacity_Call) Return() *MockPanedWidget_SetOpacity_Call {
2820+ _c.Call.Return()
2821+ return _c
2822+}
2823+
2824+func (_c *MockPanedWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockPanedWidget_SetOpacity_Call {
2825+ _c.Run(run)
2826+ return _c
2827+}
2828+
27492829 // SetPosition provides a mock function for the type MockPanedWidget
27502830 func (_mock *MockPanedWidget) SetPosition(position int) {
27512831 _mock.Called(position)
@@ -4579,6 +4659,46 @@ func (_c *MockBoxWidget_SetHomogeneous_Call) RunAndReturn(run func(homogeneous b
45794659 return _c
45804660 }
45814661
4662+// SetOpacity provides a mock function for the type MockBoxWidget
4663+func (_mock *MockBoxWidget) SetOpacity(opacity float64) {
4664+ _mock.Called(opacity)
4665+ return
4666+}
4667+
4668+// MockBoxWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
4669+type MockBoxWidget_SetOpacity_Call struct {
4670+ *mock.Call
4671+}
4672+
4673+// SetOpacity is a helper method to define mock.On call
4674+// - opacity float64
4675+func (_e *MockBoxWidget_Expecter) SetOpacity(opacity interface{}) *MockBoxWidget_SetOpacity_Call {
4676+ return &MockBoxWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
4677+}
4678+
4679+func (_c *MockBoxWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockBoxWidget_SetOpacity_Call {
4680+ _c.Call.Run(func(args mock.Arguments) {
4681+ var arg0 float64
4682+ if args[0] != nil {
4683+ arg0 = args[0].(float64)
4684+ }
4685+ run(
4686+ arg0,
4687+ )
4688+ })
4689+ return _c
4690+}
4691+
4692+func (_c *MockBoxWidget_SetOpacity_Call) Return() *MockBoxWidget_SetOpacity_Call {
4693+ _c.Call.Return()
4694+ return _c
4695+}
4696+
4697+func (_c *MockBoxWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockBoxWidget_SetOpacity_Call {
4698+ _c.Run(run)
4699+ return _c
4700+}
4701+
45824702 // SetOrientation provides a mock function for the type MockBoxWidget
45834703 func (_mock *MockBoxWidget) SetOrientation(orientation layout.Orientation) {
45844704 _mock.Called(orientation)
@@ -6188,6 +6308,46 @@ func (_c *MockOverlayWidget_SetMeasureOverlay_Call) RunAndReturn(run func(overla
61886308 return _c
61896309 }
61906310
6311+// SetOpacity provides a mock function for the type MockOverlayWidget
6312+func (_mock *MockOverlayWidget) SetOpacity(opacity float64) {
6313+ _mock.Called(opacity)
6314+ return
6315+}
6316+
6317+// MockOverlayWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
6318+type MockOverlayWidget_SetOpacity_Call struct {
6319+ *mock.Call
6320+}
6321+
6322+// SetOpacity is a helper method to define mock.On call
6323+// - opacity float64
6324+func (_e *MockOverlayWidget_Expecter) SetOpacity(opacity interface{}) *MockOverlayWidget_SetOpacity_Call {
6325+ return &MockOverlayWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
6326+}
6327+
6328+func (_c *MockOverlayWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockOverlayWidget_SetOpacity_Call {
6329+ _c.Call.Run(func(args mock.Arguments) {
6330+ var arg0 float64
6331+ if args[0] != nil {
6332+ arg0 = args[0].(float64)
6333+ }
6334+ run(
6335+ arg0,
6336+ )
6337+ })
6338+ return _c
6339+}
6340+
6341+func (_c *MockOverlayWidget_SetOpacity_Call) Return() *MockOverlayWidget_SetOpacity_Call {
6342+ _c.Call.Return()
6343+ return _c
6344+}
6345+
6346+func (_c *MockOverlayWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockOverlayWidget_SetOpacity_Call {
6347+ _c.Run(run)
6348+ return _c
6349+}
6350+
61916351 // SetSizeRequest provides a mock function for the type MockOverlayWidget
61926352 func (_mock *MockOverlayWidget) SetSizeRequest(width int, height int) {
61936353 _mock.Called(width, height)
@@ -7521,6 +7681,46 @@ func (_c *MockLabelWidget_SetMaxWidthChars_Call) RunAndReturn(run func(nChars in
75217681 return _c
75227682 }
75237683
7684+// SetOpacity provides a mock function for the type MockLabelWidget
7685+func (_mock *MockLabelWidget) SetOpacity(opacity float64) {
7686+ _mock.Called(opacity)
7687+ return
7688+}
7689+
7690+// MockLabelWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
7691+type MockLabelWidget_SetOpacity_Call struct {
7692+ *mock.Call
7693+}
7694+
7695+// SetOpacity is a helper method to define mock.On call
7696+// - opacity float64
7697+func (_e *MockLabelWidget_Expecter) SetOpacity(opacity interface{}) *MockLabelWidget_SetOpacity_Call {
7698+ return &MockLabelWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
7699+}
7700+
7701+func (_c *MockLabelWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockLabelWidget_SetOpacity_Call {
7702+ _c.Call.Run(func(args mock.Arguments) {
7703+ var arg0 float64
7704+ if args[0] != nil {
7705+ arg0 = args[0].(float64)
7706+ }
7707+ run(
7708+ arg0,
7709+ )
7710+ })
7711+ return _c
7712+}
7713+
7714+func (_c *MockLabelWidget_SetOpacity_Call) Return() *MockLabelWidget_SetOpacity_Call {
7715+ _c.Call.Return()
7716+ return _c
7717+}
7718+
7719+func (_c *MockLabelWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockLabelWidget_SetOpacity_Call {
7720+ _c.Run(run)
7721+ return _c
7722+}
7723+
75247724 // SetSizeRequest provides a mock function for the type MockLabelWidget
75257725 func (_mock *MockLabelWidget) SetSizeRequest(width int, height int) {
75267726 _mock.Called(width, height)
@@ -9031,6 +9231,46 @@ func (_c *MockButtonWidget_SetLabel_Call) RunAndReturn(run func(label string)) *
90319231 return _c
90329232 }
90339233
9234+// SetOpacity provides a mock function for the type MockButtonWidget
9235+func (_mock *MockButtonWidget) SetOpacity(opacity float64) {
9236+ _mock.Called(opacity)
9237+ return
9238+}
9239+
9240+// MockButtonWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
9241+type MockButtonWidget_SetOpacity_Call struct {
9242+ *mock.Call
9243+}
9244+
9245+// SetOpacity is a helper method to define mock.On call
9246+// - opacity float64
9247+func (_e *MockButtonWidget_Expecter) SetOpacity(opacity interface{}) *MockButtonWidget_SetOpacity_Call {
9248+ return &MockButtonWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
9249+}
9250+
9251+func (_c *MockButtonWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockButtonWidget_SetOpacity_Call {
9252+ _c.Call.Run(func(args mock.Arguments) {
9253+ var arg0 float64
9254+ if args[0] != nil {
9255+ arg0 = args[0].(float64)
9256+ }
9257+ run(
9258+ arg0,
9259+ )
9260+ })
9261+ return _c
9262+}
9263+
9264+func (_c *MockButtonWidget_SetOpacity_Call) Return() *MockButtonWidget_SetOpacity_Call {
9265+ _c.Call.Return()
9266+ return _c
9267+}
9268+
9269+func (_c *MockButtonWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockButtonWidget_SetOpacity_Call {
9270+ _c.Run(run)
9271+ return _c
9272+}
9273+
90349274 // SetSizeRequest provides a mock function for the type MockButtonWidget
90359275 func (_mock *MockButtonWidget) SetSizeRequest(width int, height int) {
90369276 _mock.Called(width, height)
@@ -10353,6 +10593,46 @@ func (_c *MockImageWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *
1035310593 return _c
1035410594 }
1035510595
10596+// SetOpacity provides a mock function for the type MockImageWidget
10597+func (_mock *MockImageWidget) SetOpacity(opacity float64) {
10598+ _mock.Called(opacity)
10599+ return
10600+}
10601+
10602+// MockImageWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
10603+type MockImageWidget_SetOpacity_Call struct {
10604+ *mock.Call
10605+}
10606+
10607+// SetOpacity is a helper method to define mock.On call
10608+// - opacity float64
10609+func (_e *MockImageWidget_Expecter) SetOpacity(opacity interface{}) *MockImageWidget_SetOpacity_Call {
10610+ return &MockImageWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
10611+}
10612+
10613+func (_c *MockImageWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockImageWidget_SetOpacity_Call {
10614+ _c.Call.Run(func(args mock.Arguments) {
10615+ var arg0 float64
10616+ if args[0] != nil {
10617+ arg0 = args[0].(float64)
10618+ }
10619+ run(
10620+ arg0,
10621+ )
10622+ })
10623+ return _c
10624+}
10625+
10626+func (_c *MockImageWidget_SetOpacity_Call) Return() *MockImageWidget_SetOpacity_Call {
10627+ _c.Call.Return()
10628+ return _c
10629+}
10630+
10631+func (_c *MockImageWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockImageWidget_SetOpacity_Call {
10632+ _c.Run(run)
10633+ return _c
10634+}
10635+
1035610636 // SetPixelSize provides a mock function for the type MockImageWidget
1035710637 func (_mock *MockImageWidget) SetPixelSize(pixelSize int) {
1035810638 _mock.Called(pixelSize)
@@ -11646,6 +11926,46 @@ func (_c *MockProgressBarWidget_SetHexpand_Call) RunAndReturn(run func(expand bo
1164611926 return _c
1164711927 }
1164811928
11929+// SetOpacity provides a mock function for the type MockProgressBarWidget
11930+func (_mock *MockProgressBarWidget) SetOpacity(opacity float64) {
11931+ _mock.Called(opacity)
11932+ return
11933+}
11934+
11935+// MockProgressBarWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
11936+type MockProgressBarWidget_SetOpacity_Call struct {
11937+ *mock.Call
11938+}
11939+
11940+// SetOpacity is a helper method to define mock.On call
11941+// - opacity float64
11942+func (_e *MockProgressBarWidget_Expecter) SetOpacity(opacity interface{}) *MockProgressBarWidget_SetOpacity_Call {
11943+ return &MockProgressBarWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
11944+}
11945+
11946+func (_c *MockProgressBarWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockProgressBarWidget_SetOpacity_Call {
11947+ _c.Call.Run(func(args mock.Arguments) {
11948+ var arg0 float64
11949+ if args[0] != nil {
11950+ arg0 = args[0].(float64)
11951+ }
11952+ run(
11953+ arg0,
11954+ )
11955+ })
11956+ return _c
11957+}
11958+
11959+func (_c *MockProgressBarWidget_SetOpacity_Call) Return() *MockProgressBarWidget_SetOpacity_Call {
11960+ _c.Call.Return()
11961+ return _c
11962+}
11963+
11964+func (_c *MockProgressBarWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockProgressBarWidget_SetOpacity_Call {
11965+ _c.Run(run)
11966+ return _c
11967+}
11968+
1164911969 // SetSizeRequest provides a mock function for the type MockProgressBarWidget
1165011970 func (_mock *MockProgressBarWidget) SetSizeRequest(width int, height int) {
1165111971 _mock.Called(width, height)
@@ -12859,6 +13179,46 @@ func (_c *MockSpinnerWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
1285913179 return _c
1286013180 }
1286113181
13182+// SetOpacity provides a mock function for the type MockSpinnerWidget
13183+func (_mock *MockSpinnerWidget) SetOpacity(opacity float64) {
13184+ _mock.Called(opacity)
13185+ return
13186+}
13187+
13188+// MockSpinnerWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
13189+type MockSpinnerWidget_SetOpacity_Call struct {
13190+ *mock.Call
13191+}
13192+
13193+// SetOpacity is a helper method to define mock.On call
13194+// - opacity float64
13195+func (_e *MockSpinnerWidget_Expecter) SetOpacity(opacity interface{}) *MockSpinnerWidget_SetOpacity_Call {
13196+ return &MockSpinnerWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
13197+}
13198+
13199+func (_c *MockSpinnerWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockSpinnerWidget_SetOpacity_Call {
13200+ _c.Call.Run(func(args mock.Arguments) {
13201+ var arg0 float64
13202+ if args[0] != nil {
13203+ arg0 = args[0].(float64)
13204+ }
13205+ run(
13206+ arg0,
13207+ )
13208+ })
13209+ return _c
13210+}
13211+
13212+func (_c *MockSpinnerWidget_SetOpacity_Call) Return() *MockSpinnerWidget_SetOpacity_Call {
13213+ _c.Call.Return()
13214+ return _c
13215+}
13216+
13217+func (_c *MockSpinnerWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockSpinnerWidget_SetOpacity_Call {
13218+ _c.Run(run)
13219+ return _c
13220+}
13221+
1286213222 // SetSizeRequest provides a mock function for the type MockSpinnerWidget
1286313223 func (_mock *MockSpinnerWidget) SetSizeRequest(width int, height int) {
1286413224 _mock.Called(width, height)
@@ -1028,6 +1028,46 @@ func (_c *MockWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *MockW
1028 return _c1028 return _c
1029 }1029 }
1030 1030
1031+// SetOpacity provides a mock function for the type MockWidget
1032+func (_mock *MockWidget) SetOpacity(opacity float64) {
1033+ _mock.Called(opacity)
1034+ return
1035+}
1036+
1037+// MockWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
1038+type MockWidget_SetOpacity_Call struct {
1039+ *mock.Call
1040+}
1041+
1042+// SetOpacity is a helper method to define mock.On call
1043+// - opacity float64
1044+func (_e *MockWidget_Expecter) SetOpacity(opacity interface{}) *MockWidget_SetOpacity_Call {
1045+ return &MockWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
1046+}
1047+
1048+func (_c *MockWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockWidget_SetOpacity_Call {
1049+ _c.Call.Run(func(args mock.Arguments) {
1050+ var arg0 float64
1051+ if args[0] != nil {
1052+ arg0 = args[0].(float64)
1053+ }
1054+ run(
1055+ arg0,
1056+ )
1057+ })
1058+ return _c
1059+}
1060+
1061+func (_c *MockWidget_SetOpacity_Call) Return() *MockWidget_SetOpacity_Call {
1062+ _c.Call.Return()
1063+ return _c
1064+}
1065+
1066+func (_c *MockWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockWidget_SetOpacity_Call {
1067+ _c.Run(run)
1068+ return _c
1069+}
1070+
1031 // SetSizeRequest provides a mock function for the type MockWidget1071 // SetSizeRequest provides a mock function for the type MockWidget
1032 func (_mock *MockWidget) SetSizeRequest(width int, height int) {1072 func (_mock *MockWidget) SetSizeRequest(width int, height int) {
1033 _mock.Called(width, height)1073 _mock.Called(width, height)
@@ -2746,6 +2786,46 @@ func (_c *MockPanedWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *
2746 return _c2786 return _c
2747 }2787 }
2748 2788
2789+// SetOpacity provides a mock function for the type MockPanedWidget
2790+func (_mock *MockPanedWidget) SetOpacity(opacity float64) {
2791+ _mock.Called(opacity)
2792+ return
2793+}
2794+
2795+// MockPanedWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
2796+type MockPanedWidget_SetOpacity_Call struct {
2797+ *mock.Call
2798+}
2799+
2800+// SetOpacity is a helper method to define mock.On call
2801+// - opacity float64
2802+func (_e *MockPanedWidget_Expecter) SetOpacity(opacity interface{}) *MockPanedWidget_SetOpacity_Call {
2803+ return &MockPanedWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
2804+}
2805+
2806+func (_c *MockPanedWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockPanedWidget_SetOpacity_Call {
2807+ _c.Call.Run(func(args mock.Arguments) {
2808+ var arg0 float64
2809+ if args[0] != nil {
2810+ arg0 = args[0].(float64)
2811+ }
2812+ run(
2813+ arg0,
2814+ )
2815+ })
2816+ return _c
2817+}
2818+
2819+func (_c *MockPanedWidget_SetOpacity_Call) Return() *MockPanedWidget_SetOpacity_Call {
2820+ _c.Call.Return()
2821+ return _c
2822+}
2823+
2824+func (_c *MockPanedWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockPanedWidget_SetOpacity_Call {
2825+ _c.Run(run)
2826+ return _c
2827+}
2828+
2749 // SetPosition provides a mock function for the type MockPanedWidget2829 // SetPosition provides a mock function for the type MockPanedWidget
2750 func (_mock *MockPanedWidget) SetPosition(position int) {2830 func (_mock *MockPanedWidget) SetPosition(position int) {
2751 _mock.Called(position)2831 _mock.Called(position)
@@ -4579,6 +4659,46 @@ func (_c *MockBoxWidget_SetHomogeneous_Call) RunAndReturn(run func(homogeneous b
4579 return _c4659 return _c
4580 }4660 }
4581 4661
4662+// SetOpacity provides a mock function for the type MockBoxWidget
4663+func (_mock *MockBoxWidget) SetOpacity(opacity float64) {
4664+ _mock.Called(opacity)
4665+ return
4666+}
4667+
4668+// MockBoxWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
4669+type MockBoxWidget_SetOpacity_Call struct {
4670+ *mock.Call
4671+}
4672+
4673+// SetOpacity is a helper method to define mock.On call
4674+// - opacity float64
4675+func (_e *MockBoxWidget_Expecter) SetOpacity(opacity interface{}) *MockBoxWidget_SetOpacity_Call {
4676+ return &MockBoxWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
4677+}
4678+
4679+func (_c *MockBoxWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockBoxWidget_SetOpacity_Call {
4680+ _c.Call.Run(func(args mock.Arguments) {
4681+ var arg0 float64
4682+ if args[0] != nil {
4683+ arg0 = args[0].(float64)
4684+ }
4685+ run(
4686+ arg0,
4687+ )
4688+ })
4689+ return _c
4690+}
4691+
4692+func (_c *MockBoxWidget_SetOpacity_Call) Return() *MockBoxWidget_SetOpacity_Call {
4693+ _c.Call.Return()
4694+ return _c
4695+}
4696+
4697+func (_c *MockBoxWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockBoxWidget_SetOpacity_Call {
4698+ _c.Run(run)
4699+ return _c
4700+}
4701+
4582 // SetOrientation provides a mock function for the type MockBoxWidget4702 // SetOrientation provides a mock function for the type MockBoxWidget
4583 func (_mock *MockBoxWidget) SetOrientation(orientation layout.Orientation) {4703 func (_mock *MockBoxWidget) SetOrientation(orientation layout.Orientation) {
4584 _mock.Called(orientation)4704 _mock.Called(orientation)
@@ -6188,6 +6308,46 @@ func (_c *MockOverlayWidget_SetMeasureOverlay_Call) RunAndReturn(run func(overla
6188 return _c6308 return _c
6189 }6309 }
6190 6310
6311+// SetOpacity provides a mock function for the type MockOverlayWidget
6312+func (_mock *MockOverlayWidget) SetOpacity(opacity float64) {
6313+ _mock.Called(opacity)
6314+ return
6315+}
6316+
6317+// MockOverlayWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
6318+type MockOverlayWidget_SetOpacity_Call struct {
6319+ *mock.Call
6320+}
6321+
6322+// SetOpacity is a helper method to define mock.On call
6323+// - opacity float64
6324+func (_e *MockOverlayWidget_Expecter) SetOpacity(opacity interface{}) *MockOverlayWidget_SetOpacity_Call {
6325+ return &MockOverlayWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
6326+}
6327+
6328+func (_c *MockOverlayWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockOverlayWidget_SetOpacity_Call {
6329+ _c.Call.Run(func(args mock.Arguments) {
6330+ var arg0 float64
6331+ if args[0] != nil {
6332+ arg0 = args[0].(float64)
6333+ }
6334+ run(
6335+ arg0,
6336+ )
6337+ })
6338+ return _c
6339+}
6340+
6341+func (_c *MockOverlayWidget_SetOpacity_Call) Return() *MockOverlayWidget_SetOpacity_Call {
6342+ _c.Call.Return()
6343+ return _c
6344+}
6345+
6346+func (_c *MockOverlayWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockOverlayWidget_SetOpacity_Call {
6347+ _c.Run(run)
6348+ return _c
6349+}
6350+
6191 // SetSizeRequest provides a mock function for the type MockOverlayWidget6351 // SetSizeRequest provides a mock function for the type MockOverlayWidget
6192 func (_mock *MockOverlayWidget) SetSizeRequest(width int, height int) {6352 func (_mock *MockOverlayWidget) SetSizeRequest(width int, height int) {
6193 _mock.Called(width, height)6353 _mock.Called(width, height)
@@ -7521,6 +7681,46 @@ func (_c *MockLabelWidget_SetMaxWidthChars_Call) RunAndReturn(run func(nChars in
7521 return _c7681 return _c
7522 }7682 }
7523 7683
7684+// SetOpacity provides a mock function for the type MockLabelWidget
7685+func (_mock *MockLabelWidget) SetOpacity(opacity float64) {
7686+ _mock.Called(opacity)
7687+ return
7688+}
7689+
7690+// MockLabelWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
7691+type MockLabelWidget_SetOpacity_Call struct {
7692+ *mock.Call
7693+}
7694+
7695+// SetOpacity is a helper method to define mock.On call
7696+// - opacity float64
7697+func (_e *MockLabelWidget_Expecter) SetOpacity(opacity interface{}) *MockLabelWidget_SetOpacity_Call {
7698+ return &MockLabelWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
7699+}
7700+
7701+func (_c *MockLabelWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockLabelWidget_SetOpacity_Call {
7702+ _c.Call.Run(func(args mock.Arguments) {
7703+ var arg0 float64
7704+ if args[0] != nil {
7705+ arg0 = args[0].(float64)
7706+ }
7707+ run(
7708+ arg0,
7709+ )
7710+ })
7711+ return _c
7712+}
7713+
7714+func (_c *MockLabelWidget_SetOpacity_Call) Return() *MockLabelWidget_SetOpacity_Call {
7715+ _c.Call.Return()
7716+ return _c
7717+}
7718+
7719+func (_c *MockLabelWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockLabelWidget_SetOpacity_Call {
7720+ _c.Run(run)
7721+ return _c
7722+}
7723+
7524 // SetSizeRequest provides a mock function for the type MockLabelWidget7724 // SetSizeRequest provides a mock function for the type MockLabelWidget
7525 func (_mock *MockLabelWidget) SetSizeRequest(width int, height int) {7725 func (_mock *MockLabelWidget) SetSizeRequest(width int, height int) {
7526 _mock.Called(width, height)7726 _mock.Called(width, height)
@@ -9031,6 +9231,46 @@ func (_c *MockButtonWidget_SetLabel_Call) RunAndReturn(run func(label string)) *
9031 return _c9231 return _c
9032 }9232 }
9033 9233
9234+// SetOpacity provides a mock function for the type MockButtonWidget
9235+func (_mock *MockButtonWidget) SetOpacity(opacity float64) {
9236+ _mock.Called(opacity)
9237+ return
9238+}
9239+
9240+// MockButtonWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
9241+type MockButtonWidget_SetOpacity_Call struct {
9242+ *mock.Call
9243+}
9244+
9245+// SetOpacity is a helper method to define mock.On call
9246+// - opacity float64
9247+func (_e *MockButtonWidget_Expecter) SetOpacity(opacity interface{}) *MockButtonWidget_SetOpacity_Call {
9248+ return &MockButtonWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
9249+}
9250+
9251+func (_c *MockButtonWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockButtonWidget_SetOpacity_Call {
9252+ _c.Call.Run(func(args mock.Arguments) {
9253+ var arg0 float64
9254+ if args[0] != nil {
9255+ arg0 = args[0].(float64)
9256+ }
9257+ run(
9258+ arg0,
9259+ )
9260+ })
9261+ return _c
9262+}
9263+
9264+func (_c *MockButtonWidget_SetOpacity_Call) Return() *MockButtonWidget_SetOpacity_Call {
9265+ _c.Call.Return()
9266+ return _c
9267+}
9268+
9269+func (_c *MockButtonWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockButtonWidget_SetOpacity_Call {
9270+ _c.Run(run)
9271+ return _c
9272+}
9273+
9034 // SetSizeRequest provides a mock function for the type MockButtonWidget9274 // SetSizeRequest provides a mock function for the type MockButtonWidget
9035 func (_mock *MockButtonWidget) SetSizeRequest(width int, height int) {9275 func (_mock *MockButtonWidget) SetSizeRequest(width int, height int) {
9036 _mock.Called(width, height)9276 _mock.Called(width, height)
@@ -10353,6 +10593,46 @@ func (_c *MockImageWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *
10353 return _c10593 return _c
10354 }10594 }
10355 10595
10596+// SetOpacity provides a mock function for the type MockImageWidget
10597+func (_mock *MockImageWidget) SetOpacity(opacity float64) {
10598+ _mock.Called(opacity)
10599+ return
10600+}
10601+
10602+// MockImageWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
10603+type MockImageWidget_SetOpacity_Call struct {
10604+ *mock.Call
10605+}
10606+
10607+// SetOpacity is a helper method to define mock.On call
10608+// - opacity float64
10609+func (_e *MockImageWidget_Expecter) SetOpacity(opacity interface{}) *MockImageWidget_SetOpacity_Call {
10610+ return &MockImageWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
10611+}
10612+
10613+func (_c *MockImageWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockImageWidget_SetOpacity_Call {
10614+ _c.Call.Run(func(args mock.Arguments) {
10615+ var arg0 float64
10616+ if args[0] != nil {
10617+ arg0 = args[0].(float64)
10618+ }
10619+ run(
10620+ arg0,
10621+ )
10622+ })
10623+ return _c
10624+}
10625+
10626+func (_c *MockImageWidget_SetOpacity_Call) Return() *MockImageWidget_SetOpacity_Call {
10627+ _c.Call.Return()
10628+ return _c
10629+}
10630+
10631+func (_c *MockImageWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockImageWidget_SetOpacity_Call {
10632+ _c.Run(run)
10633+ return _c
10634+}
10635+
10356 // SetPixelSize provides a mock function for the type MockImageWidget10636 // SetPixelSize provides a mock function for the type MockImageWidget
10357 func (_mock *MockImageWidget) SetPixelSize(pixelSize int) {10637 func (_mock *MockImageWidget) SetPixelSize(pixelSize int) {
10358 _mock.Called(pixelSize)10638 _mock.Called(pixelSize)
@@ -11646,6 +11926,46 @@ func (_c *MockProgressBarWidget_SetHexpand_Call) RunAndReturn(run func(expand bo
11646 return _c11926 return _c
11647 }11927 }
11648 11928
11929+// SetOpacity provides a mock function for the type MockProgressBarWidget
11930+func (_mock *MockProgressBarWidget) SetOpacity(opacity float64) {
11931+ _mock.Called(opacity)
11932+ return
11933+}
11934+
11935+// MockProgressBarWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
11936+type MockProgressBarWidget_SetOpacity_Call struct {
11937+ *mock.Call
11938+}
11939+
11940+// SetOpacity is a helper method to define mock.On call
11941+// - opacity float64
11942+func (_e *MockProgressBarWidget_Expecter) SetOpacity(opacity interface{}) *MockProgressBarWidget_SetOpacity_Call {
11943+ return &MockProgressBarWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
11944+}
11945+
11946+func (_c *MockProgressBarWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockProgressBarWidget_SetOpacity_Call {
11947+ _c.Call.Run(func(args mock.Arguments) {
11948+ var arg0 float64
11949+ if args[0] != nil {
11950+ arg0 = args[0].(float64)
11951+ }
11952+ run(
11953+ arg0,
11954+ )
11955+ })
11956+ return _c
11957+}
11958+
11959+func (_c *MockProgressBarWidget_SetOpacity_Call) Return() *MockProgressBarWidget_SetOpacity_Call {
11960+ _c.Call.Return()
11961+ return _c
11962+}
11963+
11964+func (_c *MockProgressBarWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockProgressBarWidget_SetOpacity_Call {
11965+ _c.Run(run)
11966+ return _c
11967+}
11968+
11649 // SetSizeRequest provides a mock function for the type MockProgressBarWidget11969 // SetSizeRequest provides a mock function for the type MockProgressBarWidget
11650 func (_mock *MockProgressBarWidget) SetSizeRequest(width int, height int) {11970 func (_mock *MockProgressBarWidget) SetSizeRequest(width int, height int) {
11651 _mock.Called(width, height)11971 _mock.Called(width, height)
@@ -12859,6 +13179,46 @@ func (_c *MockSpinnerWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
12859 return _c13179 return _c
12860 }13180 }
12861 13181
13182+// SetOpacity provides a mock function for the type MockSpinnerWidget
13183+func (_mock *MockSpinnerWidget) SetOpacity(opacity float64) {
13184+ _mock.Called(opacity)
13185+ return
13186+}
13187+
13188+// MockSpinnerWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
13189+type MockSpinnerWidget_SetOpacity_Call struct {
13190+ *mock.Call
13191+}
13192+
13193+// SetOpacity is a helper method to define mock.On call
13194+// - opacity float64
13195+func (_e *MockSpinnerWidget_Expecter) SetOpacity(opacity interface{}) *MockSpinnerWidget_SetOpacity_Call {
13196+ return &MockSpinnerWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
13197+}
13198+
13199+func (_c *MockSpinnerWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockSpinnerWidget_SetOpacity_Call {
13200+ _c.Call.Run(func(args mock.Arguments) {
13201+ var arg0 float64
13202+ if args[0] != nil {
13203+ arg0 = args[0].(float64)
13204+ }
13205+ run(
13206+ arg0,
13207+ )
13208+ })
13209+ return _c
13210+}
13211+
13212+func (_c *MockSpinnerWidget_SetOpacity_Call) Return() *MockSpinnerWidget_SetOpacity_Call {
13213+ _c.Call.Return()
13214+ return _c
13215+}
13216+
13217+func (_c *MockSpinnerWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockSpinnerWidget_SetOpacity_Call {
13218+ _c.Run(run)
13219+ return _c
13220+}
13221+
12862 // SetSizeRequest provides a mock function for the type MockSpinnerWidget13222 // SetSizeRequest provides a mock function for the type MockSpinnerWidget
12863 func (_mock *MockSpinnerWidget) SetSizeRequest(width int, height int) {13223 func (_mock *MockSpinnerWidget) SetSizeRequest(width int, height int) {
12864 _mock.Called(width, height)13224 _mock.Called(width, height)
M internal/ui/layout/widgets.go +1 -0
@@ -23,6 +23,7 @@ type Widget interface {
2323 Hide()
2424 SetVisible(visible bool)
2525 IsVisible() bool
26+ SetOpacity(opacity float64)
2627
2728 // Focus
2829 GrabFocus() bool
@@ -23,6 +23,7 @@ type Widget interface {
23 Hide()23 Hide()
24 SetVisible(visible bool)24 SetVisible(visible bool)
25 IsVisible() bool25 IsVisible() bool
26+ SetOpacity(opacity float64)
26 27
27 // Focus28 // Focus
28 GrabFocus() bool29 GrabFocus() bool
M internal/ui/theme/css.go +39 -0
@@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
132132 sb.WriteString(generatePermissionPopupCSS(p))
133133 sb.WriteString("\n")
134134
135+ // Floating pane styling
136+ sb.WriteString(generateFloatingPaneCSS(p))
137+ sb.WriteString("\n")
138+
135139 // WebRTC permission activity indicator styling
136140 sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))
137141 sb.WriteString("\n")
@@ -968,3 +972,38 @@ func generateLinkStatusCSS(p Palette) string {
968972 }
969973 `
970974 }
975+
976+func generateFloatingPaneCSS(p Palette) string {
977+ _ = p
978+ return `/* ===== Floating Pane Styling ===== */
979+.floating-pane-outer {
980+ /* Positioning handled in Go via overlay alignment and margins. */
981+}
982+
983+.floating-pane-container {
984+ background-color: var(--surface-variant);
985+ border: 0.0625em solid var(--border);
986+ border-radius: 0.25em;
987+ box-shadow: 0 0.5em 1.5em alpha(black, 0.25);
988+}
989+
990+.floating-pane-container.pane-active {
991+ border-color: var(--accent);
992+}
993+
994+.single-pane .floating-pane-container.pane-active {
995+ border-color: var(--accent);
996+}
997+
998+.floating-pane-omnibox {
999+ background-color: shade(var(--surface-variant), 1.08);
1000+ border-bottom: 0.0625em solid var(--border);
1001+ padding: 0.375em 0.5em;
1002+}
1003+
1004+.floating-pane-content {
1005+ background-color: var(--bg);
1006+ min-height: 12em;
1007+}
1008+`
1009+}
@@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
132 sb.WriteString(generatePermissionPopupCSS(p))132 sb.WriteString(generatePermissionPopupCSS(p))
133 sb.WriteString("\n")133 sb.WriteString("\n")
134 134
135+ // Floating pane styling
136+ sb.WriteString(generateFloatingPaneCSS(p))
137+ sb.WriteString("\n")
138+
135 // WebRTC permission activity indicator styling139 // WebRTC permission activity indicator styling
136 sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))140 sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))
137 sb.WriteString("\n")141 sb.WriteString("\n")
@@ -968,3 +972,38 @@ func generateLinkStatusCSS(p Palette) string {
968 }972 }
969 `973 `
970 }974 }
975+
976+func generateFloatingPaneCSS(p Palette) string {
977+ _ = p
978+ return `/* ===== Floating Pane Styling ===== */
979+.floating-pane-outer {
980+ /* Positioning handled in Go via overlay alignment and margins. */
981+}
982+
983+.floating-pane-container {
984+ background-color: var(--surface-variant);
985+ border: 0.0625em solid var(--border);
986+ border-radius: 0.25em;
987+ box-shadow: 0 0.5em 1.5em alpha(black, 0.25);
988+}
989+
990+.floating-pane-container.pane-active {
991+ border-color: var(--accent);
992+}
993+
994+.single-pane .floating-pane-container.pane-active {
995+ border-color: var(--accent);
996+}
997+
998+.floating-pane-omnibox {
999+ background-color: shade(var(--surface-variant), 1.08);
1000+ border-bottom: 0.0625em solid var(--border);
1001+ padding: 0.375em 0.5em;
1002+}
1003+
1004+.floating-pane-content {
1005+ background-color: var(--bg);
1006+ min-height: 12em;
1007+}
1008+`
1009+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close