detached 677167c

feat(ui): unify mode borders and keybinding legend (#448)

* feat(ui): unify mode borders and keybinding legend

* fix(config): expose mode legend choices in schema

* fix(ui): clear stale mode frame on workspace teardown

* fix(ui): draw mode border above web content and tint legend with mode color

* feat(ui): linger mode legend on hover and add vim mode color

* fix(ui): dismiss lingering mode legend from anywhere in the window

* fix(ui): correct vim frame color doc and legend flash aliases
commit
677167cec536cb6e171b873152cd7b165260a82a
author
bnema · · 2026-09-23 19:02 UTC
committer
GitHub · · 2026-09-23 19:02 UTC
parent
1e77cc4
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
33 files · +1464 -455
M docs/config/index.md +16 -6
@@ -482,7 +482,7 @@ Notes:
482482 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
483483 - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
484484 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
485-- `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
485+- `workspace.styling.vim_mode_color` colors the Vim frame; `workspace.styling.transition_duration` controls its pulse. The mode legend and toast use the workspace styling options below.
486486 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
487487
488488 ### Resize Mode
@@ -615,13 +615,18 @@ desc = "Open floating pane on GitHub"
615615 |-----|------|---------|-------------|
616616 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
617617 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
618-| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
619-| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue); also reused for Vim mode pane-local accent, indicator, and pulse |
618+| `workspace.styling.mode_border_width` | int | `4` | Reserved; mode frame borders currently use fixed em widths (Vim is thinner) |
619+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) |
620620 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
621621 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
622+| `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color |
622623 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
623-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show bottom-left mode toaster when modal modes are active; Vim Mode keeps a persistent `VIM MODE` indicator until exit or toggle off |
624-| `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) |
624+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |
625+| `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend sits on the active mode border in that mode's color |
626+| `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the legend when set to `delay` (>= 0) |
627+| `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. |
628+| `workspace.styling.mode_legend_animations` | bool | `true` | Animate the legend and its keycap feedback |
629+| `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms |
625630
626631 **Example:**
627632
@@ -630,11 +635,16 @@ desc = "Open floating pane on GitHub"
630635 border_width = 1
631636 border_color = "@theme_selected_bg_color"
632637 mode_border_width = 4
633-pane_mode_color = "#4A90E2" # Blue for pane mode and Vim mode local accent
638+pane_mode_color = "#4A90E2" # Blue for pane mode
634639 tab_mode_color = "#FFA500" # Orange for tab mode
635640 session_mode_color = "#9B59B6" # Purple for session mode
641+vim_mode_color = "#F7768E" # Vim mode
636642 resize_mode_color = "#00D4AA" # Teal for resize mode
637643 mode_indicator_toaster_enabled = true
644+mode_legend = "delay"
645+mode_legend_delay_ms = 500
646+mode_legend_linger = true
647+mode_legend_animations = true
638648 transition_duration = 120
639649 ```
640650
@@ -482,7 +482,7 @@ Notes:
482 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.482 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
483 - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.483 - Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
484 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.484 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
485-- `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.485+- `workspace.styling.vim_mode_color` colors the Vim frame; `workspace.styling.transition_duration` controls its pulse. The mode legend and toast use the workspace styling options below.
486 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.486 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
487 487
488 ### Resize Mode488 ### Resize Mode
@@ -615,13 +615,18 @@ desc = "Open floating pane on GitHub"
615 |-----|------|---------|-------------|615 |-----|------|---------|-------------|
616 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |616 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
617 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |617 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
618-| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |618+| `workspace.styling.mode_border_width` | int | `4` | Reserved; mode frame borders currently use fixed em widths (Vim is thinner) |
619-| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue); also reused for Vim mode pane-local accent, indicator, and pulse |619+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) |
620 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |620 | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
621 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |621 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
622+| `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color |
622 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |623 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
623-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show bottom-left mode toaster when modal modes are active; Vim Mode keeps a persistent `VIM MODE` indicator until exit or toggle off |624+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |
624-| `workspace.styling.transition_duration` | int | `120` | Border and pulse transition duration (ms) |625+| `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend sits on the active mode border in that mode's color |
626+| `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the legend when set to `delay` (>= 0) |
627+| `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. |
628+| `workspace.styling.mode_legend_animations` | bool | `true` | Animate the legend and its keycap feedback |
629+| `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms |
625 630
626 **Example:**631 **Example:**
627 632
@@ -630,11 +635,16 @@ desc = "Open floating pane on GitHub"
630 border_width = 1635 border_width = 1
631 border_color = "@theme_selected_bg_color"636 border_color = "@theme_selected_bg_color"
632 mode_border_width = 4637 mode_border_width = 4
633-pane_mode_color = "#4A90E2" # Blue for pane mode and Vim mode local accent638+pane_mode_color = "#4A90E2" # Blue for pane mode
634 tab_mode_color = "#FFA500" # Orange for tab mode639 tab_mode_color = "#FFA500" # Orange for tab mode
635 session_mode_color = "#9B59B6" # Purple for session mode640 session_mode_color = "#9B59B6" # Purple for session mode
641+vim_mode_color = "#F7768E" # Vim mode
636 resize_mode_color = "#00D4AA" # Teal for resize mode642 resize_mode_color = "#00D4AA" # Teal for resize mode
637 mode_indicator_toaster_enabled = true643 mode_indicator_toaster_enabled = true
644+mode_legend = "delay"
645+mode_legend_delay_ms = 500
646+mode_legend_linger = true
647+mode_legend_animations = true
638 transition_duration = 120648 transition_duration = 120
639 ```649 ```
640 650
M docs/reference/configuration.md +7 -2
@@ -133,12 +133,17 @@
133133 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
134134 | `workspace.styling.border_width` | int | `1` | |
135135 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
136-| `workspace.styling.mode_border_width` | int | `4` | |
136+| `workspace.styling.mode_border_width` | int | `4` | Reserved; mode frame border widths are fixed |
137137 | `workspace.styling.pane_mode_color` | string | `#4A90E2` | |
138138 | `workspace.styling.tab_mode_color` | string | `#FFA500` | |
139139 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |
140+| `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend |
140141 | `workspace.styling.resize_mode_color` | string | `#00D4AA` | |
141-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | |
142+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |
143+| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` |
144+| `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` |
145+| `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. |
146+| `workspace.styling.mode_legend_animations` | bool | `true` | |
142147 | `workspace.styling.transition_duration` | int | `120` | |
143148 | `session.auto_restore` | bool | `false` | |
144149 | `session.snapshot_interval_ms` | int | `5000` | |
@@ -133,12 +133,17 @@
133 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |133 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
134 | `workspace.styling.border_width` | int | `1` | |134 | `workspace.styling.border_width` | int | `1` | |
135 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |135 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
136-| `workspace.styling.mode_border_width` | int | `4` | |136+| `workspace.styling.mode_border_width` | int | `4` | Reserved; mode frame border widths are fixed |
137 | `workspace.styling.pane_mode_color` | string | `#4A90E2` | |137 | `workspace.styling.pane_mode_color` | string | `#4A90E2` | |
138 | `workspace.styling.tab_mode_color` | string | `#FFA500` | |138 | `workspace.styling.tab_mode_color` | string | `#FFA500` | |
139 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |139 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |
140+| `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend |
140 | `workspace.styling.resize_mode_color` | string | `#00D4AA` | |141 | `workspace.styling.resize_mode_color` | string | `#00D4AA` | |
141-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | |142+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |
143+| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` |
144+| `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` |
145+| `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. |
146+| `workspace.styling.mode_legend_animations` | bool | `true` | |
142 | `workspace.styling.transition_duration` | int | `120` | |147 | `workspace.styling.transition_duration` | int | `120` | |
143 | `session.auto_restore` | bool | `false` | |148 | `session.auto_restore` | bool | `false` | |
144 | `session.snapshot_interval_ms` | int | `5000` | |149 | `session.snapshot_interval_ms` | int | `5000` | |
M docs/reference/keybindings.md +1 -1
@@ -55,7 +55,7 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
5555
5656 ## Vim Mode (`Ctrl+Y`)
5757
58-Vim Mode is an explicit Vim-style navigation mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `VIM MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. `Ctrl+Y` remains available when an editable page control is already focused.
58+Vim Mode is an explicit Vim-style navigation mode for the active pane only. Its frame uses `workspace.styling.vim_mode_color`, a bottom-left `VIM MODE` toast stays visible while the mode is active, and a shortcut legend appears after 500 ms. Set `workspace.styling.mode_legend` to `always` or `off` to change the legend timing. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. `Ctrl+Y` remains available when an editable page control is already focused.
5959
6060 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.
6161
@@ -55,7 +55,7 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
55 55
56 ## Vim Mode (`Ctrl+Y`)56 ## Vim Mode (`Ctrl+Y`)
57 57
58-Vim Mode is an explicit Vim-style navigation mode for the active pane only. It applies a pane-local accent from `workspace.styling.pane_mode_color`, and when `workspace.styling.mode_indicator_toaster_enabled` is true, shows a persistent bottom-left `VIM MODE` toaster while the mode is active; the toaster hides on exit, when the mode is toggled off, or when the toaster is disabled in config. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. `Ctrl+Y` remains available when an editable page control is already focused.58+Vim Mode is an explicit Vim-style navigation mode for the active pane only. Its frame uses `workspace.styling.vim_mode_color`, a bottom-left `VIM MODE` toast stays visible while the mode is active, and a shortcut legend appears after 500 ms. Set `workspace.styling.mode_legend` to `always` or `off` to change the legend timing. The mode exits automatically when focus moves into the omnibox, find bar, overlays, or an editable element inside the page. The default `timeout_ms` is `0`, so Vim Mode does not auto-time out unless you configure one. Arrow keys continue to flow through the browser engine's native page-navigation path while Vim Mode is active, while other app-level shortcuts stay suspended until you leave the mode. `Ctrl+Y` remains available when an editable page control is already focused.
59 59
60 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.60 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.
61 61
M internal/application/usecase/resolve_theme.go +1 -0
@@ -111,6 +111,7 @@ func ResolveThemeInputFromConfig(
111111 if styling != nil {
112112 modeColors := entity.ThemeModeColors{
113113 PaneMode: styling.PaneModeColor,
114+ VimMode: styling.VimModeColor,
114115 TabMode: styling.TabModeColor,
115116 SessionMode: styling.SessionModeColor,
116117 ResizeMode: styling.ResizeModeColor,
@@ -111,6 +111,7 @@ func ResolveThemeInputFromConfig(
111 if styling != nil {111 if styling != nil {
112 modeColors := entity.ThemeModeColors{112 modeColors := entity.ThemeModeColors{
113 PaneMode: styling.PaneModeColor,113 PaneMode: styling.PaneModeColor,
114+ VimMode: styling.VimModeColor,
114 TabMode: styling.TabModeColor,115 TabMode: styling.TabModeColor,
115 SessionMode: styling.SessionModeColor,116 SessionMode: styling.SessionModeColor,
116 ResizeMode: styling.ResizeModeColor,117 ResizeMode: styling.ResizeModeColor,
M internal/application/usecase/resolve_theme_test.go +10 -1
@@ -1317,6 +1317,7 @@ func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testi
13171317 TabModeColor: "#222222",
13181318 SessionModeColor: "#333333",
13191319 ResizeModeColor: "#444444",
1320+ VimModeColor: "#abcdef",
13201321 }
13211322 preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"}
13221323
@@ -1340,7 +1341,7 @@ func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testi
13401341 if input.UIScale != 1.25 {
13411342 t.Errorf("expected UI scale 1.25, got %f", input.UIScale)
13421343 }
1343- if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" {
1344+ if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" || input.ModeColors.VimMode != "#abcdef" {
13441345 t.Fatalf("expected mode colors from styling, got %+v", input.ModeColors)
13451346 }
13461347 }
@@ -1363,3 +1364,11 @@ func TestResolveThemeInputFromConfig_AllowsNilConfig(t *testing.T) {
13631364 input.LightPalette, input.DarkPalette, input.Fonts, input.ModeColors)
13641365 }
13651366 }
1367+
1368+func TestResolveThemeInputFromConfigVimColorFallback(t *testing.T) {
1369+ input := ResolveThemeInputFromConfig(nil, 1, &entity.WorkspaceStylingConfig{}, port.ColorSchemePreference{})
1370+ colors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors())
1371+ if colors.VimMode != "#F7768E" {
1372+ t.Fatalf("expected Vim fallback, got %q", colors.VimMode)
1373+ }
1374+}
@@ -1317,6 +1317,7 @@ func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testi
1317 TabModeColor: "#222222",1317 TabModeColor: "#222222",
1318 SessionModeColor: "#333333",1318 SessionModeColor: "#333333",
1319 ResizeModeColor: "#444444",1319 ResizeModeColor: "#444444",
1320+ VimModeColor: "#abcdef",
1320 }1321 }
1321 preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"}1322 preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"}
1322 1323
@@ -1340,7 +1341,7 @@ func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testi
1340 if input.UIScale != 1.25 {1341 if input.UIScale != 1.25 {
1341 t.Errorf("expected UI scale 1.25, got %f", input.UIScale)1342 t.Errorf("expected UI scale 1.25, got %f", input.UIScale)
1342 }1343 }
1343- if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" {1344+ if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" || input.ModeColors.VimMode != "#abcdef" {
1344 t.Fatalf("expected mode colors from styling, got %+v", input.ModeColors)1345 t.Fatalf("expected mode colors from styling, got %+v", input.ModeColors)
1345 }1346 }
1346 }1347 }
@@ -1363,3 +1364,11 @@ func TestResolveThemeInputFromConfig_AllowsNilConfig(t *testing.T) {
1363 input.LightPalette, input.DarkPalette, input.Fonts, input.ModeColors)1364 input.LightPalette, input.DarkPalette, input.Fonts, input.ModeColors)
1364 }1365 }
1365 }1366 }
1367+
1368+func TestResolveThemeInputFromConfigVimColorFallback(t *testing.T) {
1369+ input := ResolveThemeInputFromConfig(nil, 1, &entity.WorkspaceStylingConfig{}, port.ColorSchemePreference{})
1370+ colors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors())
1371+ if colors.VimMode != "#F7768E" {
1372+ t.Fatalf("expected Vim fallback, got %q", colors.VimMode)
1373+ }
1374+}
M internal/domain/entity/config_types.go +6 -1
@@ -164,9 +164,14 @@ type WorkspaceStylingConfig struct {
164164 PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
165165 TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
166166 SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
167+ VimModeColor string `mapstructure:"vim_mode_color" yaml:"vim_mode_color" toml:"vim_mode_color" json:"vim_mode_color"` //nolint:lll // struct tags must stay on one line
167168 ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
168169
169- ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
170+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
171+ ModeLegend string `mapstructure:"mode_legend" yaml:"mode_legend" toml:"mode_legend" json:"mode_legend"`
172+ ModeLegendDelayMs int `mapstructure:"mode_legend_delay_ms" yaml:"mode_legend_delay_ms" toml:"mode_legend_delay_ms" json:"mode_legend_delay_ms"` //nolint:lll // struct tags must stay on one line
173+ ModeLegendLinger bool `mapstructure:"mode_legend_linger" yaml:"mode_legend_linger" toml:"mode_legend_linger" json:"mode_legend_linger"` //nolint:lll // struct tags must stay on one line
174+ ModeLegendAnimations bool `mapstructure:"mode_legend_animations" yaml:"mode_legend_animations" toml:"mode_legend_animations" json:"mode_legend_animations"` //nolint:lll // struct tags must stay on one line
170175
171176 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
172177 }
@@ -164,9 +164,14 @@ type WorkspaceStylingConfig struct {
164 PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line164 PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
165 TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line165 TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
166 SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line166 SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
167+ VimModeColor string `mapstructure:"vim_mode_color" yaml:"vim_mode_color" toml:"vim_mode_color" json:"vim_mode_color"` //nolint:lll // struct tags must stay on one line
167 ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line168 ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
168 169
169- ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line170+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
171+ ModeLegend string `mapstructure:"mode_legend" yaml:"mode_legend" toml:"mode_legend" json:"mode_legend"`
172+ ModeLegendDelayMs int `mapstructure:"mode_legend_delay_ms" yaml:"mode_legend_delay_ms" toml:"mode_legend_delay_ms" json:"mode_legend_delay_ms"` //nolint:lll // struct tags must stay on one line
173+ ModeLegendLinger bool `mapstructure:"mode_legend_linger" yaml:"mode_legend_linger" toml:"mode_legend_linger" json:"mode_legend_linger"` //nolint:lll // struct tags must stay on one line
174+ ModeLegendAnimations bool `mapstructure:"mode_legend_animations" yaml:"mode_legend_animations" toml:"mode_legend_animations" json:"mode_legend_animations"` //nolint:lll // struct tags must stay on one line
170 175
171 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line176 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
172 }177 }
M internal/domain/entity/theme.go +6 -0
@@ -40,6 +40,7 @@ type ThemeFonts struct {
4040 // All values must be CSS-safe hex colors (e.g., "#4A90E2").
4141 type ThemeModeColors struct {
4242 PaneMode string
43+ VimMode string
4344 TabMode string
4445 SessionMode string
4546 ResizeMode string
@@ -104,6 +105,7 @@ func DefaultThemeFonts() ThemeFonts {
104105 func DefaultThemeModeColors() ThemeModeColors {
105106 return ThemeModeColors{
106107 PaneMode: "#4A90E2",
108+ VimMode: "#F7768E",
107109 TabMode: "#FFA500",
108110 SessionMode: "#9B59B6",
109111 ResizeMode: "#00D4AA",
@@ -266,6 +268,7 @@ func ValidateModeColorsHex(colors *ThemeModeColors, prefix string) []ThemeWarnin
266268 }
267269 var warnings []ThemeWarning
268270 checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode)
271+ checkRequiredHex(&warnings, prefix, "vim_mode", colors.VimMode)
269272 checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode)
270273 checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode)
271274 checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode)
@@ -286,6 +289,9 @@ func MergeModeColors(src *ThemeModeColors, defaults ThemeModeColors) ThemeModeCo
286289 if result.PaneMode == "" {
287290 result.PaneMode = defaults.PaneMode
288291 }
292+ if result.VimMode == "" {
293+ result.VimMode = defaults.VimMode
294+ }
289295 if result.TabMode == "" {
290296 result.TabMode = defaults.TabMode
291297 }
@@ -40,6 +40,7 @@ type ThemeFonts struct {
40 // All values must be CSS-safe hex colors (e.g., "#4A90E2").40 // All values must be CSS-safe hex colors (e.g., "#4A90E2").
41 type ThemeModeColors struct {41 type ThemeModeColors struct {
42 PaneMode string42 PaneMode string
43+ VimMode string
43 TabMode string44 TabMode string
44 SessionMode string45 SessionMode string
45 ResizeMode string46 ResizeMode string
@@ -104,6 +105,7 @@ func DefaultThemeFonts() ThemeFonts {
104 func DefaultThemeModeColors() ThemeModeColors {105 func DefaultThemeModeColors() ThemeModeColors {
105 return ThemeModeColors{106 return ThemeModeColors{
106 PaneMode: "#4A90E2",107 PaneMode: "#4A90E2",
108+ VimMode: "#F7768E",
107 TabMode: "#FFA500",109 TabMode: "#FFA500",
108 SessionMode: "#9B59B6",110 SessionMode: "#9B59B6",
109 ResizeMode: "#00D4AA",111 ResizeMode: "#00D4AA",
@@ -266,6 +268,7 @@ func ValidateModeColorsHex(colors *ThemeModeColors, prefix string) []ThemeWarnin
266 }268 }
267 var warnings []ThemeWarning269 var warnings []ThemeWarning
268 checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode)270 checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode)
271+ checkRequiredHex(&warnings, prefix, "vim_mode", colors.VimMode)
269 checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode)272 checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode)
270 checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode)273 checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode)
271 checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode)274 checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode)
@@ -286,6 +289,9 @@ func MergeModeColors(src *ThemeModeColors, defaults ThemeModeColors) ThemeModeCo
286 if result.PaneMode == "" {289 if result.PaneMode == "" {
287 result.PaneMode = defaults.PaneMode290 result.PaneMode = defaults.PaneMode
288 }291 }
292+ if result.VimMode == "" {
293+ result.VimMode = defaults.VimMode
294+ }
289 if result.TabMode == "" {295 if result.TabMode == "" {
290 result.TabMode = defaults.TabMode296 result.TabMode = defaults.TabMode
291 }297 }
M internal/domain/entity/theme_test.go +7 -0
@@ -130,6 +130,7 @@ func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) {
130130 TabMode: "#FFA500",
131131 SessionMode: "#9B59B6",
132132 ResizeMode: "#00D4AA",
133+ VimMode: "#F7768E",
133134 }
134135
135136 fields := map[string]string{
@@ -137,6 +138,7 @@ func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) {
137138 "TabMode": defaults.TabMode,
138139 "SessionMode": defaults.SessionMode,
139140 "ResizeMode": defaults.ResizeMode,
141+ "VimMode": defaults.VimMode,
140142 }
141143
142144 for name, value := range fields {
@@ -171,6 +173,7 @@ func TestResolvedTheme_StructFieldsExist(t *testing.T) {
171173 TabMode: "#FFA500",
172174 SessionMode: "#9B59B6",
173175 ResizeMode: "#00D4AA",
176+ VimMode: "#F7768E",
174177 },
175178 Warnings: []ThemeWarning{
176179 {Field: "external", Message: "malformed external theme, using last-good"},
@@ -493,6 +496,9 @@ func TestMergeModeColors_FillsEmpties(t *testing.T) {
493496 if result.PaneMode != "#111111" {
494497 t.Errorf("PaneMode should be from src, got %q", result.PaneMode)
495498 }
499+ if result.VimMode != def.VimMode {
500+ t.Errorf("VimMode should use default, got %q", result.VimMode)
501+ }
496502 if result.TabMode != def.TabMode {
497503 t.Errorf("TabMode should be from defaults, got %q", result.TabMode)
498504 }
@@ -505,6 +511,7 @@ func TestValidateModeColorsHex_ReturnsWarnings(t *testing.T) {
505511 TabMode: "not-a-color",
506512 SessionMode: "#333333",
507513 ResizeMode: "",
514+ VimMode: "#F7768E",
508515 }
509516
510517 warnings := ValidateModeColorsHex(colors, "mode_colors")
@@ -130,6 +130,7 @@ func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) {
130 TabMode: "#FFA500",130 TabMode: "#FFA500",
131 SessionMode: "#9B59B6",131 SessionMode: "#9B59B6",
132 ResizeMode: "#00D4AA",132 ResizeMode: "#00D4AA",
133+ VimMode: "#F7768E",
133 }134 }
134 135
135 fields := map[string]string{136 fields := map[string]string{
@@ -137,6 +138,7 @@ func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) {
137 "TabMode": defaults.TabMode,138 "TabMode": defaults.TabMode,
138 "SessionMode": defaults.SessionMode,139 "SessionMode": defaults.SessionMode,
139 "ResizeMode": defaults.ResizeMode,140 "ResizeMode": defaults.ResizeMode,
141+ "VimMode": defaults.VimMode,
140 }142 }
141 143
142 for name, value := range fields {144 for name, value := range fields {
@@ -171,6 +173,7 @@ func TestResolvedTheme_StructFieldsExist(t *testing.T) {
171 TabMode: "#FFA500",173 TabMode: "#FFA500",
172 SessionMode: "#9B59B6",174 SessionMode: "#9B59B6",
173 ResizeMode: "#00D4AA",175 ResizeMode: "#00D4AA",
176+ VimMode: "#F7768E",
174 },177 },
175 Warnings: []ThemeWarning{178 Warnings: []ThemeWarning{
176 {Field: "external", Message: "malformed external theme, using last-good"},179 {Field: "external", Message: "malformed external theme, using last-good"},
@@ -493,6 +496,9 @@ func TestMergeModeColors_FillsEmpties(t *testing.T) {
493 if result.PaneMode != "#111111" {496 if result.PaneMode != "#111111" {
494 t.Errorf("PaneMode should be from src, got %q", result.PaneMode)497 t.Errorf("PaneMode should be from src, got %q", result.PaneMode)
495 }498 }
499+ if result.VimMode != def.VimMode {
500+ t.Errorf("VimMode should use default, got %q", result.VimMode)
501+ }
496 if result.TabMode != def.TabMode {502 if result.TabMode != def.TabMode {
497 t.Errorf("TabMode should be from defaults, got %q", result.TabMode)503 t.Errorf("TabMode should be from defaults, got %q", result.TabMode)
498 }504 }
@@ -505,6 +511,7 @@ func TestValidateModeColorsHex_ReturnsWarnings(t *testing.T) {
505 TabMode: "not-a-color",511 TabMode: "not-a-color",
506 SessionMode: "#333333",512 SessionMode: "#333333",
507 ResizeMode: "",513 ResizeMode: "",
514+ VimMode: "#F7768E",
508 }515 }
509 516
510 warnings := ValidateModeColorsHex(colors, "mode_colors")517 warnings := ValidateModeColorsHex(colors, "mode_colors")
M internal/infrastructure/config/defaults.go +6 -0
@@ -76,6 +76,7 @@ const (
7676 defaultPaneModeColor = "#4A90E2" // Blue for pane mode
7777 defaultTabModeColor = "#FFA500" // Orange for tab mode
7878 defaultSessionModeColor = "#9B59B6" // Purple for session mode
79+ defaultVimModeColor = "#F7768E"
7980 defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
8081
8182 // Mode indicator toaster
@@ -394,7 +395,12 @@ func DefaultConfig() *Config {
394395 TabModeColor: defaultTabModeColor,
395396 SessionModeColor: defaultSessionModeColor,
396397 ResizeModeColor: defaultResizeModeColor,
398+ VimModeColor: defaultVimModeColor,
399+ ModeLegendLinger: true,
397400 ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
401+ ModeLegend: "delay",
402+ ModeLegendDelayMs: 500,
403+ ModeLegendAnimations: true,
398404 TransitionDuration: defaultTransitionDuration,
399405 },
400406 },
@@ -76,6 +76,7 @@ const (
76 defaultPaneModeColor = "#4A90E2" // Blue for pane mode76 defaultPaneModeColor = "#4A90E2" // Blue for pane mode
77 defaultTabModeColor = "#FFA500" // Orange for tab mode77 defaultTabModeColor = "#FFA500" // Orange for tab mode
78 defaultSessionModeColor = "#9B59B6" // Purple for session mode78 defaultSessionModeColor = "#9B59B6" // Purple for session mode
79+ defaultVimModeColor = "#F7768E"
79 defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode80 defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
80 81
81 // Mode indicator toaster82 // Mode indicator toaster
@@ -394,7 +395,12 @@ func DefaultConfig() *Config {
394 TabModeColor: defaultTabModeColor,395 TabModeColor: defaultTabModeColor,
395 SessionModeColor: defaultSessionModeColor,396 SessionModeColor: defaultSessionModeColor,
396 ResizeModeColor: defaultResizeModeColor,397 ResizeModeColor: defaultResizeModeColor,
398+ VimModeColor: defaultVimModeColor,
399+ ModeLegendLinger: true,
397 ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,400 ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
401+ ModeLegend: "delay",
402+ ModeLegendDelayMs: 500,
403+ ModeLegendAnimations: true,
398 TransitionDuration: defaultTransitionDuration,404 TransitionDuration: defaultTransitionDuration,
399 },405 },
400 },406 },
M internal/infrastructure/config/defaults_test.go +5 -0
@@ -15,6 +15,11 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1515 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
1616 assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
1717 assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
18+ assert.Equal(t, "delay", cfg.Workspace.Styling.ModeLegend)
19+ assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
20+ assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
21+ assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
22+ assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
1823
1924 // Engine defaults (replaces old Performance/Privacy sections)
2025 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)
@@ -15,6 +15,11 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
16 assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)16 assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
17 assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)17 assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
18+ assert.Equal(t, "delay", cfg.Workspace.Styling.ModeLegend)
19+ assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
20+ assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
21+ assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
22+ assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
18 23
19 // Engine defaults (replaces old Performance/Privacy sections)24 // Engine defaults (replaces old Performance/Privacy sections)
20 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)25 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)
M internal/infrastructure/config/loader.go +5 -0
@@ -739,7 +739,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
739739 m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
740740 m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
741741 m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
742+ m.viper.SetDefault("workspace.styling.vim_mode_color", defaults.Workspace.Styling.VimModeColor)
743+ m.viper.SetDefault("workspace.styling.mode_legend_linger", defaults.Workspace.Styling.ModeLegendLinger)
742744 m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
745+ m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend)
746+ m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs)
747+ m.viper.SetDefault("workspace.styling.mode_legend_animations", defaults.Workspace.Styling.ModeLegendAnimations)
743748 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
744749 }
745750
@@ -739,7 +739,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
739 m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)739 m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
740 m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)740 m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
741 m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)741 m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
742+ m.viper.SetDefault("workspace.styling.vim_mode_color", defaults.Workspace.Styling.VimModeColor)
743+ m.viper.SetDefault("workspace.styling.mode_legend_linger", defaults.Workspace.Styling.ModeLegendLinger)
742 m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)744 m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
745+ m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend)
746+ m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs)
747+ m.viper.SetDefault("workspace.styling.mode_legend_animations", defaults.Workspace.Styling.ModeLegendAnimations)
743 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)748 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
744 }749 }
745 750
M internal/infrastructure/config/loader_test.go +12 -0
@@ -8,6 +8,18 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11+func TestModeLegendDefaultsThroughLoader(t *testing.T) {
12+ mgr := &Manager{viper: viper.New()}
13+ mgr.setDefaults()
14+ cfg, err := mgr.unmarshalConfig()
15+ require.NoError(t, err)
16+ assert.Equal(t, "delay", cfg.Workspace.Styling.ModeLegend)
17+ assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
18+ assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
19+ assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
20+ assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
21+}
22+
1123 func TestSetEngineDefaults(t *testing.T) {
1224 mgr := &Manager{viper: viper.New()}
1325 mgr.setDefaults()
@@ -8,6 +8,18 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11+func TestModeLegendDefaultsThroughLoader(t *testing.T) {
12+ mgr := &Manager{viper: viper.New()}
13+ mgr.setDefaults()
14+ cfg, err := mgr.unmarshalConfig()
15+ require.NoError(t, err)
16+ assert.Equal(t, "delay", cfg.Workspace.Styling.ModeLegend)
17+ assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
18+ assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
19+ assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
20+ assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
21+}
22+
11 func TestSetEngineDefaults(t *testing.T) {23 func TestSetEngineDefaults(t *testing.T) {
12 mgr := &Manager{viper: viper.New()}24 mgr := &Manager{viper: viper.New()}
13 mgr.setDefaults()25 mgr.setDefaults()
M internal/infrastructure/config/schema_provider.go +34 -2
@@ -669,7 +669,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
669669 Key: "workspace.styling.mode_border_width",
670670 Type: "int",
671671 Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeBorderWidth),
672- Description: "Border width for modal mode indicators",
672+ Description: "Reserved; mode frame borders currently use fixed widths",
673673 Range: ">=0",
674674 Section: SectionWorkspace,
675675 },
@@ -694,6 +694,10 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
694694 Description: "Color for session mode indicator",
695695 Section: SectionWorkspace,
696696 },
697+ {
698+ Key: "workspace.styling.vim_mode_color", Type: "string", Default: defaults.Workspace.Styling.VimModeColor,
699+ Description: "Color for Vim mode indicator", Section: SectionWorkspace,
700+ },
697701 {
698702 Key: "workspace.styling.resize_mode_color",
699703 Type: "string",
@@ -705,7 +709,35 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
705709 Key: "workspace.styling.mode_indicator_toaster_enabled",
706710 Type: "bool",
707711 Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),
708- Description: "Show toaster notification for modal modes",
712+ Description: "Show the mode toast while a mode is active",
713+ Section: SectionWorkspace,
714+ },
715+ {
716+ Key: "workspace.styling.mode_legend",
717+ Type: "string",
718+ Default: defaults.Workspace.Styling.ModeLegend,
719+ Description: "Mode legend visibility",
720+ Values: []string{"always", "delay", "off"},
721+ Section: SectionWorkspace,
722+ },
723+ {
724+ Key: "workspace.styling.mode_legend_delay_ms",
725+ Type: "int",
726+ Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeLegendDelayMs),
727+ Description: "Delay before showing the mode legend in milliseconds",
728+ Range: ">=0",
729+ Section: SectionWorkspace,
730+ },
731+ {
732+ Key: "workspace.styling.mode_legend_linger", Type: "bool", Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendLinger),
733+ Description: "Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click.",
734+ Section: SectionWorkspace,
735+ },
736+ {
737+ Key: "workspace.styling.mode_legend_animations",
738+ Type: "bool",
739+ Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendAnimations),
740+ Description: "Animate the mode legend and pressed keycaps",
709741 Section: SectionWorkspace,
710742 },
711743 {
@@ -669,7 +669,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
669 Key: "workspace.styling.mode_border_width",669 Key: "workspace.styling.mode_border_width",
670 Type: "int",670 Type: "int",
671 Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeBorderWidth),671 Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeBorderWidth),
672- Description: "Border width for modal mode indicators",672+ Description: "Reserved; mode frame borders currently use fixed widths",
673 Range: ">=0",673 Range: ">=0",
674 Section: SectionWorkspace,674 Section: SectionWorkspace,
675 },675 },
@@ -694,6 +694,10 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
694 Description: "Color for session mode indicator",694 Description: "Color for session mode indicator",
695 Section: SectionWorkspace,695 Section: SectionWorkspace,
696 },696 },
697+ {
698+ Key: "workspace.styling.vim_mode_color", Type: "string", Default: defaults.Workspace.Styling.VimModeColor,
699+ Description: "Color for Vim mode indicator", Section: SectionWorkspace,
700+ },
697 {701 {
698 Key: "workspace.styling.resize_mode_color",702 Key: "workspace.styling.resize_mode_color",
699 Type: "string",703 Type: "string",
@@ -705,7 +709,35 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
705 Key: "workspace.styling.mode_indicator_toaster_enabled",709 Key: "workspace.styling.mode_indicator_toaster_enabled",
706 Type: "bool",710 Type: "bool",
707 Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),711 Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),
708- Description: "Show toaster notification for modal modes",712+ Description: "Show the mode toast while a mode is active",
713+ Section: SectionWorkspace,
714+ },
715+ {
716+ Key: "workspace.styling.mode_legend",
717+ Type: "string",
718+ Default: defaults.Workspace.Styling.ModeLegend,
719+ Description: "Mode legend visibility",
720+ Values: []string{"always", "delay", "off"},
721+ Section: SectionWorkspace,
722+ },
723+ {
724+ Key: "workspace.styling.mode_legend_delay_ms",
725+ Type: "int",
726+ Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeLegendDelayMs),
727+ Description: "Delay before showing the mode legend in milliseconds",
728+ Range: ">=0",
729+ Section: SectionWorkspace,
730+ },
731+ {
732+ Key: "workspace.styling.mode_legend_linger", Type: "bool", Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendLinger),
733+ Description: "Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click.",
734+ Section: SectionWorkspace,
735+ },
736+ {
737+ Key: "workspace.styling.mode_legend_animations",
738+ Type: "bool",
739+ Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendAnimations),
740+ Description: "Animate the mode legend and pressed keycaps",
709 Section: SectionWorkspace,741 Section: SectionWorkspace,
710 },742 },
711 {743 {
M internal/infrastructure/config/schema_provider_test.go +22 -0
@@ -9,10 +9,32 @@ import (
99 "strings"
1010 "testing"
1111
12+ "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/spf13/viper"
1314 "github.com/stretchr/testify/require"
1415 )
1516
17+func TestModeLegendSchemaDefaults(t *testing.T) {
18+ keys := make(map[string]entity.ConfigKeyInfo)
19+ for _, key := range NewSchemaProvider().GetSchema() {
20+ keys[key.Key] = key
21+ }
22+ for _, tc := range []struct{ key, kind, value, valueRange string }{
23+ {"workspace.styling.mode_legend", "string", "delay", ""},
24+ {"workspace.styling.mode_legend_delay_ms", "int", "500", ">=0"},
25+ {"workspace.styling.mode_legend_linger", "bool", "true", ""},
26+ {"workspace.styling.vim_mode_color", "string", "#F7768E", ""},
27+ {"workspace.styling.mode_legend_animations", "bool", "true", ""},
28+ } {
29+ entry, ok := keys[tc.key]
30+ require.True(t, ok, "missing schema key %s", tc.key)
31+ require.Equal(t, tc.kind, entry.Type)
32+ require.Equal(t, tc.value, entry.Default)
33+ require.Equal(t, tc.valueRange, entry.Range)
34+ }
35+ require.Equal(t, []string{"always", "delay", "off"}, keys["workspace.styling.mode_legend"].Values)
36+}
37+
1638 func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
1739 docKeys := configurationReferenceKeys(t)
1840
@@ -9,10 +9,32 @@ import (
9 "strings"9 "strings"
10 "testing"10 "testing"
11 11
12+ "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/spf13/viper"13 "github.com/spf13/viper"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
14 )15 )
15 16
17+func TestModeLegendSchemaDefaults(t *testing.T) {
18+ keys := make(map[string]entity.ConfigKeyInfo)
19+ for _, key := range NewSchemaProvider().GetSchema() {
20+ keys[key.Key] = key
21+ }
22+ for _, tc := range []struct{ key, kind, value, valueRange string }{
23+ {"workspace.styling.mode_legend", "string", "delay", ""},
24+ {"workspace.styling.mode_legend_delay_ms", "int", "500", ">=0"},
25+ {"workspace.styling.mode_legend_linger", "bool", "true", ""},
26+ {"workspace.styling.vim_mode_color", "string", "#F7768E", ""},
27+ {"workspace.styling.mode_legend_animations", "bool", "true", ""},
28+ } {
29+ entry, ok := keys[tc.key]
30+ require.True(t, ok, "missing schema key %s", tc.key)
31+ require.Equal(t, tc.kind, entry.Type)
32+ require.Equal(t, tc.value, entry.Default)
33+ require.Equal(t, tc.valueRange, entry.Range)
34+ }
35+ require.Equal(t, []string{"always", "delay", "off"}, keys["workspace.styling.mode_legend"].Values)
36+}
37+
16 func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {38 func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
17 docKeys := configurationReferenceKeys(t)39 docKeys := configurationReferenceKeys(t)
18 40
M internal/infrastructure/config/validation.go +8 -0
@@ -223,6 +223,14 @@ func validateWorkspaceStyling(config *Config) []string {
223223 if config.Workspace.Styling.TransitionDuration < 0 {
224224 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
225225 }
226+ switch config.Workspace.Styling.ModeLegend {
227+ case "always", "delay", "off":
228+ default:
229+ validationErrors = append(validationErrors, "workspace.styling.mode_legend must be always, delay, or off")
230+ }
231+ if config.Workspace.Styling.ModeLegendDelayMs < 0 {
232+ validationErrors = append(validationErrors, "workspace.styling.mode_legend_delay_ms must be non-negative")
233+ }
226234 return validationErrors
227235 }
228236
@@ -223,6 +223,14 @@ func validateWorkspaceStyling(config *Config) []string {
223 if config.Workspace.Styling.TransitionDuration < 0 {223 if config.Workspace.Styling.TransitionDuration < 0 {
224 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")224 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
225 }225 }
226+ switch config.Workspace.Styling.ModeLegend {
227+ case "always", "delay", "off":
228+ default:
229+ validationErrors = append(validationErrors, "workspace.styling.mode_legend must be always, delay, or off")
230+ }
231+ if config.Workspace.Styling.ModeLegendDelayMs < 0 {
232+ validationErrors = append(validationErrors, "workspace.styling.mode_legend_delay_ms must be non-negative")
233+ }
226 return validationErrors234 return validationErrors
227 }235 }
228 236
M internal/infrastructure/config/validation_test.go +26 -0
@@ -10,6 +10,32 @@ import (
1010 "github.com/stretchr/testify/require"
1111 )
1212
13+func TestValidateConfig_ModeLegend(t *testing.T) {
14+ for _, tc := range []struct {
15+ name, mode string
16+ delay int
17+ wantError string
18+ }{
19+ {name: "default", mode: "delay", delay: 500},
20+ {name: "always", mode: "always"},
21+ {name: "off", mode: "off"},
22+ {name: "unknown mode", mode: "sometimes", wantError: "workspace.styling.mode_legend must be always, delay, or off"},
23+ {name: "negative delay", mode: "delay", delay: -1, wantError: "workspace.styling.mode_legend_delay_ms must be non-negative"},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ cfg := DefaultConfig()
27+ cfg.Workspace.Styling.ModeLegend = tc.mode
28+ cfg.Workspace.Styling.ModeLegendDelayMs = tc.delay
29+ err := validateConfig(cfg)
30+ if tc.wantError != "" {
31+ require.EqualError(t, err, "config validation failed:\n - "+tc.wantError)
32+ } else {
33+ require.NoError(t, err)
34+ }
35+ })
36+ }
37+}
38+
1339 func TestValidateConfig_OmniboxStyle(t *testing.T) {
1440 tests := []struct {
1541 style string
@@ -10,6 +10,32 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
13+func TestValidateConfig_ModeLegend(t *testing.T) {
14+ for _, tc := range []struct {
15+ name, mode string
16+ delay int
17+ wantError string
18+ }{
19+ {name: "default", mode: "delay", delay: 500},
20+ {name: "always", mode: "always"},
21+ {name: "off", mode: "off"},
22+ {name: "unknown mode", mode: "sometimes", wantError: "workspace.styling.mode_legend must be always, delay, or off"},
23+ {name: "negative delay", mode: "delay", delay: -1, wantError: "workspace.styling.mode_legend_delay_ms must be non-negative"},
24+ } {
25+ t.Run(tc.name, func(t *testing.T) {
26+ cfg := DefaultConfig()
27+ cfg.Workspace.Styling.ModeLegend = tc.mode
28+ cfg.Workspace.Styling.ModeLegendDelayMs = tc.delay
29+ err := validateConfig(cfg)
30+ if tc.wantError != "" {
31+ require.EqualError(t, err, "config validation failed:\n - "+tc.wantError)
32+ } else {
33+ require.NoError(t, err)
34+ }
35+ })
36+ }
37+}
38+
13 func TestValidateConfig_OmniboxStyle(t *testing.T) {39 func TestValidateConfig_OmniboxStyle(t *testing.T) {
14 tests := []struct {40 tests := []struct {
15 style string41 style string
M internal/ui/app.go +31 -82
@@ -128,8 +128,6 @@ type App struct {
128128 vimNavigationUC *usecase.VimNavigationUseCase
129129 pageEditableFocusByPane map[entity.PaneID]bool
130130
131- resizeModeBorderTarget layout.Widget
132-
133131 // Omnibox configuration (omnibox is created per workspace view)
134132 omniboxCfg component.OmniboxConfig
135133 // Find bar configuration (find bar is created per workspace view)
@@ -1140,6 +1138,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11401138 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &workspaceCfg, &sessionCfg)
11411139 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {
11421140 a.activateBrowserWindow(bw)
1141+ a.modeFrameAction(bw, action)
11431142 if action == input.ActionClosePane {
11441143 if a.closeAndReleaseActiveFloatingPane(actionCtx) {
11451144 return nil
@@ -1149,6 +1148,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11491148 })
11501149 bw.keyboardHandler.SetOnVimScrollLifecycle(func(scrollCtx context.Context, action input.Action, phase input.VimScrollPhase) error {
11511150 a.activateBrowserWindow(bw)
1151+ if phase == input.VimScrollContinuous {
1152+ a.modeFrameAction(bw, action)
1153+ }
11521154 return a.kbDispatcher.DispatchVimScrollLifecycle(scrollCtx, action, phase)
11531155 })
11541156 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
@@ -1161,6 +1163,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11611163 })
11621164 a.bindVimModeSequenceToaster(ctx, bw)
11631165 bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) {
1166+ a.modeFrameAction(bw, input.Action(strings.ReplaceAll(action, "-", "_")))
11641167 a.navigateVimSequenceAction(ctx, bw, action, count)
11651168 })
11661169 bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool {
@@ -4043,14 +4046,6 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
40434046 log := logging.FromContext(ctx)
40444047 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
40454048
4046- if from == input.ModeResize && to != input.ModeResize {
4047- a.clearResizeModeBorder()
4048- }
4049- if to == input.ModeResize {
4050- // Resize mode targets the last-focused browser window's active workspace.
4051- a.applyResizeModeBorder(ctx, a.activeWorkspace())
4052- }
4053-
40544049 if to == input.ModeVim && from != input.ModeVim {
40554050 a.enableAccessibilityForVimMode(ctx, bw)
40564051 }
@@ -4062,12 +4057,7 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
40624057 // Entering Vim Mode accents the active pane; leaving removes the accent.
40634058 a.handleVimModeOwnership(ctx, bw, to, from)
40644059
4065- // Update global border overlay visibility based on mode.
4066- // Note: resize mode border is handled per-pane (stack container), not via global overlay.
4067- // Vim mode explicitly skips the global border overlay.
4068- if bw != nil && bw.borderMgr != nil {
4069- bw.borderMgr.OnModeChange(ctx, from, to)
4070- }
4060+ a.updateModeFrame(bw, to)
40714061
40724062 // Show/hide this window's mode indicator toaster based on mode and config.
40734063 a.updateModeIndicatorToaster(ctx, bw, to)
@@ -4191,16 +4181,8 @@ func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
41914181 if bw == nil || bw.vimModePaneID == "" {
41924182 return
41934183 }
4194- wsView := a.activeWorkspaceViewForBrowserWindow(bw)
4195- if wsView == nil {
4196- return
4197- }
4198- if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
4199- if fast {
4200- pv.TriggerVimModePulseFast()
4201- } else {
4202- pv.TriggerVimModePulse()
4203- }
4184+ if bw.modeFrame != nil {
4185+ bw.modeFrame.pulse(fast)
42044186 }
42054187 }
42064188
@@ -4243,8 +4225,6 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow,
42434225 return
42444226 }
42454227
4246- // Modal mode toasts remain visible until that same window exits its mode.
4247- // The mode class is applied atomically with Show() to avoid visual flicker.
42484228 bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
42494229 component.WithDuration(0),
42504230 component.WithPosition(component.ToastPositionBottomLeft),
@@ -4271,7 +4251,14 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow)
42714251 // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
42724252 // silent reset under ModalState avoids stale pending callbacks.
42734253 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
4274- if a == nil || bw == nil || bw.modeToaster == nil {
4254+ if a == nil || bw == nil {
4255+ return
4256+ }
4257+ if bw.modeFrame != nil {
4258+ cfg := a.runtimeConfigSnapshot().UI.Workspace
4259+ bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
4260+ }
4261+ if bw.modeToaster == nil {
42754262 return
42764263 }
42774264 log := logging.FromContext(ctx)
@@ -4315,49 +4302,6 @@ func getModeToastClass(mode input.Mode) string {
43154302 }
43164303 }
43174304
4318-func (a *App) clearResizeModeBorder() {
4319- if a.resizeModeBorderTarget != nil {
4320- a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
4321- a.resizeModeBorderTarget = nil
4322- }
4323-}
4324-
4325-func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
4326- // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode
4327- // always operates on the last-focused window.
4328- wsView := a.activeWorkspaceView()
4329- if wsView == nil || ws == nil {
4330- a.clearResizeModeBorder()
4331- return
4332- }
4333-
4334- paneID := ws.ActivePaneID
4335- if paneID == "" {
4336- a.clearResizeModeBorder()
4337- return
4338- }
4339-
4340- // Important: wrap the whole stack container when the active pane is in a stack.
4341- target := wsView.GetStackContainerWidget(paneID)
4342- if target == nil {
4343- a.clearResizeModeBorder()
4344- return
4345- }
4346-
4347- if !target.HasCssClass("resize-mode-active") {
4348- target.AddCssClass("resize-mode-active")
4349- }
4350-
4351- if a.resizeModeBorderTarget != target {
4352- if a.resizeModeBorderTarget != nil {
4353- a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
4354- } else {
4355- logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
4356- }
4357- a.resizeModeBorderTarget = target
4358- }
4359-}
4360-
43614305 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
43624306 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
43634307 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
@@ -4415,6 +4359,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
44154359 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
44164360 if bw := a.browserWindowForTab(tab.ID); bw != nil {
44174361 a.transferVimModeOwnershipToPane(ctx, bw, paneID)
4362+ if bw.keyboardHandler != nil {
4363+ a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4364+ }
44184365 }
44194366 })
44204367 }
@@ -4430,9 +4377,6 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
44304377 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
44314378 }
44324379
4433- // Note: Mode borders for tab/pane/session are attached to MainWindow.
4434- // Resize mode border is attached to the active pane's stack container.
4435-
44364380 // Set omnibox config for this workspace view.
44374381 // When the owning browser window is known, bind navigation to the
44384382 // owning window so that the omnibox always navigates in the correct
@@ -4450,13 +4394,14 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
44504394 wsView.SetAutoOpenOnNewPane(a.runtimeConfigSnapshot().UI.Omnibox.AutoOpenOnNewPane)
44514395
44524396 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
4453- if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
4454- // Resize-mode pane focus tracks the last-focused window's workspace.
4455- ws := a.activeWorkspace()
4456- if ws != nil {
4457- ws.ActivePaneID = paneID
4458- }
4459- a.applyResizeModeBorder(ctx, ws)
4397+ if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.keyboardHandler != nil {
4398+ a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4399+ }
4400+ })
4401+
4402+ wsView.SetOnRebuilt(func() {
4403+ if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.keyboardHandler != nil {
4404+ a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
44604405 }
44614406 })
44624407
@@ -4587,6 +4532,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
45874532 // Active tab state is managed by TabList.SetActive; no per-window field needed.
45884533 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
45894534 target.mainWindow.SetContent(gtkWidget)
4535+ if target.keyboardHandler != nil {
4536+ a.retargetModeFrame(target, target.keyboardHandler.Mode())
4537+ }
45904538 } else if a.mainWindow != nil {
45914539 a.mainWindow.SetContent(gtkWidget)
45924540 }
@@ -5556,6 +5504,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
55565504 if bw.keyboardHandler != nil {
55575505 mode = bw.keyboardHandler.Mode()
55585506 }
5507+ a.updateModeFrame(bw, mode)
55595508 a.updateModeIndicatorToaster(ctx, bw, mode)
55605509 }
55615510 }
@@ -128,8 +128,6 @@ type App struct {
128 vimNavigationUC *usecase.VimNavigationUseCase128 vimNavigationUC *usecase.VimNavigationUseCase
129 pageEditableFocusByPane map[entity.PaneID]bool129 pageEditableFocusByPane map[entity.PaneID]bool
130 130
131- resizeModeBorderTarget layout.Widget
132-
133 // Omnibox configuration (omnibox is created per workspace view)131 // Omnibox configuration (omnibox is created per workspace view)
134 omniboxCfg component.OmniboxConfig132 omniboxCfg component.OmniboxConfig
135 // Find bar configuration (find bar is created per workspace view)133 // Find bar configuration (find bar is created per workspace view)
@@ -1140,6 +1138,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1140 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &workspaceCfg, &sessionCfg)1138 bw.keyboardHandler = input.NewKeyboardHandler(ctx, &workspaceCfg, &sessionCfg)
1141 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {1139 bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {
1142 a.activateBrowserWindow(bw)1140 a.activateBrowserWindow(bw)
1141+ a.modeFrameAction(bw, action)
1143 if action == input.ActionClosePane {1142 if action == input.ActionClosePane {
1144 if a.closeAndReleaseActiveFloatingPane(actionCtx) {1143 if a.closeAndReleaseActiveFloatingPane(actionCtx) {
1145 return nil1144 return nil
@@ -1149,6 +1148,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1149 })1148 })
1150 bw.keyboardHandler.SetOnVimScrollLifecycle(func(scrollCtx context.Context, action input.Action, phase input.VimScrollPhase) error {1149 bw.keyboardHandler.SetOnVimScrollLifecycle(func(scrollCtx context.Context, action input.Action, phase input.VimScrollPhase) error {
1151 a.activateBrowserWindow(bw)1150 a.activateBrowserWindow(bw)
1151+ if phase == input.VimScrollContinuous {
1152+ a.modeFrameAction(bw, action)
1153+ }
1152 return a.kbDispatcher.DispatchVimScrollLifecycle(scrollCtx, action, phase)1154 return a.kbDispatcher.DispatchVimScrollLifecycle(scrollCtx, action, phase)
1153 })1155 })
1154 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {1156 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
@@ -1161,6 +1163,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1161 })1163 })
1162 a.bindVimModeSequenceToaster(ctx, bw)1164 a.bindVimModeSequenceToaster(ctx, bw)
1163 bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) {1165 bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) {
1166+ a.modeFrameAction(bw, input.Action(strings.ReplaceAll(action, "-", "_")))
1164 a.navigateVimSequenceAction(ctx, bw, action, count)1167 a.navigateVimSequenceAction(ctx, bw, action, count)
1165 })1168 })
1166 bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool {1169 bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool {
@@ -4043,14 +4046,6 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
4043 log := logging.FromContext(ctx)4046 log := logging.FromContext(ctx)
4044 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")4047 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
4045 4048
4046- if from == input.ModeResize && to != input.ModeResize {
4047- a.clearResizeModeBorder()
4048- }
4049- if to == input.ModeResize {
4050- // Resize mode targets the last-focused browser window's active workspace.
4051- a.applyResizeModeBorder(ctx, a.activeWorkspace())
4052- }
4053-
4054 if to == input.ModeVim && from != input.ModeVim {4049 if to == input.ModeVim && from != input.ModeVim {
4055 a.enableAccessibilityForVimMode(ctx, bw)4050 a.enableAccessibilityForVimMode(ctx, bw)
4056 }4051 }
@@ -4062,12 +4057,7 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
4062 // Entering Vim Mode accents the active pane; leaving removes the accent.4057 // Entering Vim Mode accents the active pane; leaving removes the accent.
4063 a.handleVimModeOwnership(ctx, bw, to, from)4058 a.handleVimModeOwnership(ctx, bw, to, from)
4064 4059
4065- // Update global border overlay visibility based on mode.4060+ a.updateModeFrame(bw, to)
4066- // Note: resize mode border is handled per-pane (stack container), not via global overlay.
4067- // Vim mode explicitly skips the global border overlay.
4068- if bw != nil && bw.borderMgr != nil {
4069- bw.borderMgr.OnModeChange(ctx, from, to)
4070- }
4071 4061
4072 // Show/hide this window's mode indicator toaster based on mode and config.4062 // Show/hide this window's mode indicator toaster based on mode and config.
4073 a.updateModeIndicatorToaster(ctx, bw, to)4063 a.updateModeIndicatorToaster(ctx, bw, to)
@@ -4191,16 +4181,8 @@ func (a *App) triggerVimModePulse(_ context.Context, fast bool) {
4191 if bw == nil || bw.vimModePaneID == "" {4181 if bw == nil || bw.vimModePaneID == "" {
4192 return4182 return
4193 }4183 }
4194- wsView := a.activeWorkspaceViewForBrowserWindow(bw)4184+ if bw.modeFrame != nil {
4195- if wsView == nil {4185+ bw.modeFrame.pulse(fast)
4196- return
4197- }
4198- if pv := wsView.GetPaneView(bw.vimModePaneID); pv != nil {
4199- if fast {
4200- pv.TriggerVimModePulseFast()
4201- } else {
4202- pv.TriggerVimModePulse()
4203- }
4204 }4186 }
4205 }4187 }
4206 4188
@@ -4243,8 +4225,6 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow,
4243 return4225 return
4244 }4226 }
4245 4227
4246- // Modal mode toasts remain visible until that same window exits its mode.
4247- // The mode class is applied atomically with Show() to avoid visual flicker.
4248 bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,4228 bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
4249 component.WithDuration(0),4229 component.WithDuration(0),
4250 component.WithPosition(component.ToastPositionBottomLeft),4230 component.WithPosition(component.ToastPositionBottomLeft),
@@ -4271,7 +4251,14 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow)
4271 // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;4251 // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
4272 // silent reset under ModalState avoids stale pending callbacks.4252 // silent reset under ModalState avoids stale pending callbacks.
4273 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {4253 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
4274- if a == nil || bw == nil || bw.modeToaster == nil {4254+ if a == nil || bw == nil {
4255+ return
4256+ }
4257+ if bw.modeFrame != nil {
4258+ cfg := a.runtimeConfigSnapshot().UI.Workspace
4259+ bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
4260+ }
4261+ if bw.modeToaster == nil {
4275 return4262 return
4276 }4263 }
4277 log := logging.FromContext(ctx)4264 log := logging.FromContext(ctx)
@@ -4315,49 +4302,6 @@ func getModeToastClass(mode input.Mode) string {
4315 }4302 }
4316 }4303 }
4317 4304
4318-func (a *App) clearResizeModeBorder() {
4319- if a.resizeModeBorderTarget != nil {
4320- a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
4321- a.resizeModeBorderTarget = nil
4322- }
4323-}
4324-
4325-func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
4326- // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode
4327- // always operates on the last-focused window.
4328- wsView := a.activeWorkspaceView()
4329- if wsView == nil || ws == nil {
4330- a.clearResizeModeBorder()
4331- return
4332- }
4333-
4334- paneID := ws.ActivePaneID
4335- if paneID == "" {
4336- a.clearResizeModeBorder()
4337- return
4338- }
4339-
4340- // Important: wrap the whole stack container when the active pane is in a stack.
4341- target := wsView.GetStackContainerWidget(paneID)
4342- if target == nil {
4343- a.clearResizeModeBorder()
4344- return
4345- }
4346-
4347- if !target.HasCssClass("resize-mode-active") {
4348- target.AddCssClass("resize-mode-active")
4349- }
4350-
4351- if a.resizeModeBorderTarget != target {
4352- if a.resizeModeBorderTarget != nil {
4353- a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
4354- } else {
4355- logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
4356- }
4357- a.resizeModeBorderTarget = target
4358- }
4359-}
4360-
4361 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.4305 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
4362 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {4306 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
4363 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {4307 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
@@ -4415,6 +4359,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
4415 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")4359 a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated")
4416 if bw := a.browserWindowForTab(tab.ID); bw != nil {4360 if bw := a.browserWindowForTab(tab.ID); bw != nil {
4417 a.transferVimModeOwnershipToPane(ctx, bw, paneID)4361 a.transferVimModeOwnershipToPane(ctx, bw, paneID)
4362+ if bw.keyboardHandler != nil {
4363+ a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4364+ }
4418 }4365 }
4419 })4366 })
4420 }4367 }
@@ -4430,9 +4377,6 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
4430 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)4377 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
4431 }4378 }
4432 4379
4433- // Note: Mode borders for tab/pane/session are attached to MainWindow.
4434- // Resize mode border is attached to the active pane's stack container.
4435-
4436 // Set omnibox config for this workspace view.4380 // Set omnibox config for this workspace view.
4437 // When the owning browser window is known, bind navigation to the4381 // When the owning browser window is known, bind navigation to the
4438 // owning window so that the omnibox always navigates in the correct4382 // owning window so that the omnibox always navigates in the correct
@@ -4450,13 +4394,14 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
4450 wsView.SetAutoOpenOnNewPane(a.runtimeConfigSnapshot().UI.Omnibox.AutoOpenOnNewPane)4394 wsView.SetAutoOpenOnNewPane(a.runtimeConfigSnapshot().UI.Omnibox.AutoOpenOnNewPane)
4451 4395
4452 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {4396 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
4453- if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {4397+ if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.keyboardHandler != nil {
4454- // Resize-mode pane focus tracks the last-focused window's workspace.4398+ a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4455- ws := a.activeWorkspace()4399+ }
4456- if ws != nil {4400+ })
4457- ws.ActivePaneID = paneID4401+
4458- }4402+ wsView.SetOnRebuilt(func() {
4459- a.applyResizeModeBorder(ctx, ws)4403+ if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.keyboardHandler != nil {
4404+ a.retargetModeFrame(bw, bw.keyboardHandler.Mode())
4460 }4405 }
4461 })4406 })
4462 4407
@@ -4587,6 +4532,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
4587 // Active tab state is managed by TabList.SetActive; no per-window field needed.4532 // Active tab state is managed by TabList.SetActive; no per-window field needed.
4588 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {4533 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
4589 target.mainWindow.SetContent(gtkWidget)4534 target.mainWindow.SetContent(gtkWidget)
4535+ if target.keyboardHandler != nil {
4536+ a.retargetModeFrame(target, target.keyboardHandler.Mode())
4537+ }
4590 } else if a.mainWindow != nil {4538 } else if a.mainWindow != nil {
4591 a.mainWindow.SetContent(gtkWidget)4539 a.mainWindow.SetContent(gtkWidget)
4592 }4540 }
@@ -5556,6 +5504,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
5556 if bw.keyboardHandler != nil {5504 if bw.keyboardHandler != nil {
5557 mode = bw.keyboardHandler.Mode()5505 mode = bw.keyboardHandler.Mode()
5558 }5506 }
5507+ a.updateModeFrame(bw, mode)
5559 a.updateModeIndicatorToaster(ctx, bw, mode)5508 a.updateModeIndicatorToaster(ctx, bw, mode)
5560 }5509 }
5561 }5510 }
A internal/ui/app_mode_frame.go +70 -0
@@ -0,0 +1,70 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/input"
5+ "github.com/bnema/dumber/internal/ui/layout"
6+)
7+
8+func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget {
9+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
10+ if wsView == nil {
11+ return nil
12+ }
13+ if mode == input.ModeTab || mode == input.ModeSession {
14+ return wsView.Widget()
15+ }
16+ ws := a.activeWorkspaceForBrowserWindow(bw)
17+ if ws == nil {
18+ return nil
19+ }
20+ if mode == input.ModePane || mode == input.ModeResize {
21+ if stack := wsView.GetStackContainerWidget(ws.ActivePaneID); stack != nil {
22+ return stack
23+ }
24+ }
25+ if mode == input.ModeVim || mode == input.ModePane || mode == input.ModeResize {
26+ return wsView.GetPaneWidget(ws.ActivePaneID)
27+ }
28+ return nil
29+}
30+
31+func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) {
32+ if bw == nil || bw.modeFrame == nil {
33+ return
34+ }
35+ a.setModeFrameTab(bw)
36+ newTarget := a.modeFrameTarget(bw, mode)
37+ // Rebuilds can temporarily lack a pane widget. Drop the old border while
38+ // the next focus/rebuild event retries the target.
39+ bw.modeFrame.setTarget(newTarget, modeFrameClass(mode))
40+ if bw.modeFrame.visible {
41+ bw.modeFrame.refreshGeometry()
42+ }
43+}
44+
45+func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {
46+ if bw == nil || bw.modeFrame == nil {
47+ return
48+ }
49+ a.setModeFrameTab(bw)
50+ cfg := a.runtimeConfigSnapshot().UI
51+ bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling,
52+ cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
53+}
54+
55+func (a *App) setModeFrameTab(bw *browserWindow) {
56+ tabID := bw.modeFrame.tabID
57+ bw.modeFrame.tabID = ""
58+ if tab := a.activeTabForBrowserWindow(bw); tab != nil {
59+ bw.modeFrame.tabID = tab.ID
60+ }
61+ if tabID != bw.modeFrame.tabID {
62+ bw.modeFrame.dismissLinger()
63+ }
64+}
65+
66+func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
67+ if bw != nil && bw.modeFrame != nil {
68+ bw.modeFrame.flash(action)
69+ }
70+}
@@ -0,0 +1,70 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/input"
5+ "github.com/bnema/dumber/internal/ui/layout"
6+)
7+
8+func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget {
9+ wsView := a.activeWorkspaceViewForBrowserWindow(bw)
10+ if wsView == nil {
11+ return nil
12+ }
13+ if mode == input.ModeTab || mode == input.ModeSession {
14+ return wsView.Widget()
15+ }
16+ ws := a.activeWorkspaceForBrowserWindow(bw)
17+ if ws == nil {
18+ return nil
19+ }
20+ if mode == input.ModePane || mode == input.ModeResize {
21+ if stack := wsView.GetStackContainerWidget(ws.ActivePaneID); stack != nil {
22+ return stack
23+ }
24+ }
25+ if mode == input.ModeVim || mode == input.ModePane || mode == input.ModeResize {
26+ return wsView.GetPaneWidget(ws.ActivePaneID)
27+ }
28+ return nil
29+}
30+
31+func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) {
32+ if bw == nil || bw.modeFrame == nil {
33+ return
34+ }
35+ a.setModeFrameTab(bw)
36+ newTarget := a.modeFrameTarget(bw, mode)
37+ // Rebuilds can temporarily lack a pane widget. Drop the old border while
38+ // the next focus/rebuild event retries the target.
39+ bw.modeFrame.setTarget(newTarget, modeFrameClass(mode))
40+ if bw.modeFrame.visible {
41+ bw.modeFrame.refreshGeometry()
42+ }
43+}
44+
45+func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {
46+ if bw == nil || bw.modeFrame == nil {
47+ return
48+ }
49+ a.setModeFrameTab(bw)
50+ cfg := a.runtimeConfigSnapshot().UI
51+ bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling,
52+ cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
53+}
54+
55+func (a *App) setModeFrameTab(bw *browserWindow) {
56+ tabID := bw.modeFrame.tabID
57+ bw.modeFrame.tabID = ""
58+ if tab := a.activeTabForBrowserWindow(bw); tab != nil {
59+ bw.modeFrame.tabID = tab.ID
60+ }
61+ if tabID != bw.modeFrame.tabID {
62+ bw.modeFrame.dismissLinger()
63+ }
64+}
65+
66+func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
67+ if bw != nil && bw.modeFrame != nil {
68+ bw.modeFrame.flash(action)
69+ }
70+}
M internal/ui/app_vim_mode_test.go +40 -71
@@ -17,7 +17,6 @@ import (
1717 "github.com/bnema/dumber/internal/domain/entity"
1818 "github.com/bnema/dumber/internal/ui/component"
1919 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
20- "github.com/bnema/dumber/internal/ui/focus"
2120 "github.com/bnema/dumber/internal/ui/input"
2221 "github.com/bnema/dumber/internal/ui/layout"
2322 "github.com/bnema/dumber/internal/ui/layout/mocks"
@@ -143,20 +142,12 @@ func createTestPaneView(
143142 }
144143
145144 // enterVimMode marks the owning pane with the pane-local visual accent.
146-func enterVimMode(
147- t *testing.T,
148- app *App,
149- overlay *mocks.MockOverlayWidget,
150- bw *browserWindow,
151-) {
145+func enterVimMode(t *testing.T, app *App, bw *browserWindow) {
152146 t.Helper()
153- overlay.EXPECT().AddCssClass("vim-mode-active").Once()
154147 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
155148 }
156149
157-func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {
158- overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
159-}
150+func expectVimModeAccentHidden(_ *mocks.MockOverlayWidget) {}
160151
161152 type accessibilityEnablingWebView struct {
162153 *portmocks.MockWebView
@@ -417,7 +408,7 @@ func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture {
417408 func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
418409 f := newVimModeTestFixture(t)
419410
420- enterVimMode(t, f.app, f.overlay1A, f.bw1)
411+ enterVimMode(t, f.app, f.bw1)
421412
422413 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,
423414 "entering vim mode sets ownership on the active pane")
@@ -434,7 +425,7 @@ func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
434425 func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
435426 f := newVimModeTestFixture(t)
436427
437- enterVimMode(t, f.app, f.overlay2A, f.bw2)
428+ enterVimMode(t, f.app, f.bw2)
438429
439430 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
440431 assert.True(t, f.pv2A.IsVimMode())
@@ -630,7 +621,7 @@ func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) {
630621 f := newVimModeTestFixture(t)
631622
632623 // Enter first
633- enterVimMode(t, f.app, f.overlay1A, f.bw1)
624+ enterVimMode(t, f.app, f.bw1)
634625 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
635626
636627 // Leave — SetVimMode(false) hides indicator and removes the CSS class
@@ -651,7 +642,7 @@ func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
651642 f := newVimModeTestFixture(t)
652643
653644 // Enter vim mode on bw1 pane-a — save label for hide expectation
654- enterVimMode(t, f.app, f.overlay1A, f.bw1)
645+ enterVimMode(t, f.app, f.bw1)
655646 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
656647
657648 // Give bw1 a keyboard handler in vim mode so transfer performs the switch
@@ -661,9 +652,6 @@ func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
661652 // pane before SetVimMode(true) on the new pane. Set up both.
662653 expectVimModeAccentHidden(f.overlay1A)
663654
664- // Set up the pane-local accent for pane-b.
665- f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
666-
667655 // Transfer ownership to pane-b
668656 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
669657
@@ -718,6 +706,12 @@ func TestVimMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) {
718706 // 4. Pulse targeting — normal vs fast
719707 // ============================================================================
720708
709+// attachTestModeFrame uses the pane overlay as the frame's CSS target so the
710+// pulse assertions below observe the classes the frame border receives.
711+func attachTestModeFrame(bw *browserWindow, target layout.Widget) {
712+ bw.modeFrame = &modeFrame{mode: input.ModeVim, target: target, frameClass: "vim-mode-active", borderStyle: target}
713+}
714+
721715 func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {
722716 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
723717 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
@@ -739,25 +733,22 @@ func setUpFastPulse(overlay *mocks.MockOverlayWidget) {
739733 func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {
740734 f := newVimModeTestFixture(t)
741735
742- enterVimMode(t, f.app, f.overlay1A, f.bw1)
736+ enterVimMode(t, f.app, f.bw1)
743737 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
744738
745- // Pulse needs the indicator to exist — it was created lazily during
746- // enterVimMode, but we also need to set up pulse expectations.
747- // (enterVimMode created it, so we just set up pulse mocks.)
739+ attachTestModeFrame(f.bw1, f.overlay1A)
748740 setUpNormalPulse(f.overlay1A)
749-
750741 f.app.triggerVimModePulse(context.Background(), false)
751742 }
752743
753744 func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {
754745 f := newVimModeTestFixture(t)
755746
756- enterVimMode(t, f.app, f.overlay1A, f.bw1)
747+ enterVimMode(t, f.app, f.bw1)
757748 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
758749
750+ attachTestModeFrame(f.bw1, f.overlay1A)
759751 setUpFastPulse(f.overlay1A)
760-
761752 f.app.triggerVimModePulse(context.Background(), true)
762753 }
763754
@@ -775,8 +766,8 @@ func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) {
775766 func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
776767 f := newVimModeTestFixture(t)
777768
778- enterVimMode(t, f.app, f.overlay1A, f.bw1)
779- enterVimMode(t, f.app, f.overlay2A, f.bw2)
769+ enterVimMode(t, f.app, f.bw1)
770+ enterVimMode(t, f.app, f.bw2)
780771
781772 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
782773 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
@@ -796,8 +787,8 @@ func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
796787 func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
797788 f := newVimModeTestFixture(t)
798789
799- enterVimMode(t, f.app, f.overlay1A, f.bw1)
800- enterVimMode(t, f.app, f.overlay2A, f.bw2)
790+ enterVimMode(t, f.app, f.bw1)
791+ enterVimMode(t, f.app, f.bw2)
801792
802793 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
803794
@@ -805,7 +796,6 @@ func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
805796 expectVimModeAccentHidden(f.overlay1A)
806797
807798 // Transfer bw1 to pane-b.
808- f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
809799 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
810800
811801 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)
@@ -820,10 +810,11 @@ func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
820810 func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {
821811 f := newVimModeTestFixture(t)
822812
823- enterVimMode(t, f.app, f.overlay1A, f.bw1)
824- enterVimMode(t, f.app, f.overlay2A, f.bw2)
813+ enterVimMode(t, f.app, f.bw1)
814+ enterVimMode(t, f.app, f.bw2)
825815
826816 // Pulse on bw1 (lastFocusedWindowID = "win-1")
817+ attachTestModeFrame(f.bw1, f.overlay1A)
827818 setUpNormalPulse(f.overlay1A)
828819 f.app.triggerVimModePulse(context.Background(), false)
829820
@@ -866,7 +857,7 @@ func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) {
866857
867858 func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
868859 f := newSingleWindowVimModeFixture(t)
869- enterVimMode(t, f.app, f.overlay1A, f.bw1)
860+ enterVimMode(t, f.app, f.bw1)
870861 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
871862
872863 expectVimModeAccentHidden(f.overlay1A)
@@ -879,7 +870,7 @@ func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
879870
880871 func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
881872 f := newSingleWindowVimModeFixture(t)
882- enterVimMode(t, f.app, f.overlay1A, f.bw1)
873+ enterVimMode(t, f.app, f.bw1)
883874 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
884875
885876 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)
@@ -891,7 +882,7 @@ func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
891882
892883 func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
893884 f := newSingleWindowVimModeFixture(t)
894- enterVimMode(t, f.app, f.overlay1A, f.bw1)
885+ enterVimMode(t, f.app, f.bw1)
895886 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
896887 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
897888 "pane-b": true,
@@ -908,7 +899,7 @@ func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
908899
909900 func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {
910901 factory := mocks.NewMockWidgetFactory(t)
911- pv1, overlay1 := createTestPaneView(t, factory, "pane-a")
902+ pv1, _ := createTestPaneView(t, factory, "pane-a")
912903 pv2, _ := createTestPaneView(t, factory, "pane-x")
913904
914905 ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}
@@ -932,7 +923,7 @@ func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t
932923 lastFocusedWindowID: "win-1",
933924 }
934925
935- enterVimMode(t, app, overlay1, bw1)
926+ enterVimMode(t, app, bw1)
936927 kh := bindVimModeKeyboardHandler(t, app, bw1)
937928
938929 app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)
@@ -944,7 +935,7 @@ func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t
944935
945936 func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
946937 f := newSingleWindowVimModeFixture(t)
947- enterVimMode(t, f.app, f.overlay1A, f.bw1)
938+ enterVimMode(t, f.app, f.bw1)
948939 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
949940
950941 expectVimModeAccentHidden(f.overlay1A)
@@ -956,7 +947,7 @@ func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
956947
957948 func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
958949 f := newSingleWindowVimModeFixture(t)
959- enterVimMode(t, f.app, f.overlay1A, f.bw1)
950+ enterVimMode(t, f.app, f.bw1)
960951 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
961952
962953 expectVimModeAccentHidden(f.overlay1A)
@@ -968,7 +959,7 @@ func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
968959
969960 func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
970961 f := newSingleWindowVimModeFixture(t)
971- enterVimMode(t, f.app, f.overlay1A, f.bw1)
962+ enterVimMode(t, f.app, f.bw1)
972963 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
973964
974965 tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{
@@ -1021,9 +1012,7 @@ func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
10211012 // ============================================================================
10221013
10231014 func TestVimMode_CSSClassConstants(t *testing.T) {
1024- assert.Equal(t, "vim-mode-active", component.VimModeActiveClass)
1025- assert.Equal(t, "vim-mode-pulse", component.VimModePulseClass)
1026- assert.Equal(t, "vim-mode-pulse-fast", component.VimModeFastPulseClass)
1015+ assert.Equal(t, "vim-mode-active", modeFrameClass(input.ModeVim))
10271016 }
10281017
10291018 // ============================================================================
@@ -1202,39 +1191,16 @@ func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
12021191 assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
12031192 }
12041193
1205-// ============================================================================
1206-// BorderManager — Vim mode must NOT use the global border overlay
1207-// ============================================================================
1208-
1209-func TestVimMode_GlobalBorderOverlayStaysOff(t *testing.T) {
1210- mockFactory := mocks.NewMockWidgetFactory(t)
1211- mockBox := mocks.NewMockBoxWidget(t)
1212-
1213- mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
1214- mockBox.EXPECT().SetCanFocus(false).Once()
1215- mockBox.EXPECT().SetCanTarget(false).Once()
1216- mockBox.EXPECT().SetHexpand(true).Once()
1217- mockBox.EXPECT().SetVexpand(true).Once()
1218- mockBox.EXPECT().SetVisible(false).Once()
1219-
1220- bm := focus.NewBorderManager(mockFactory)
1221-
1222- mockBox.EXPECT().SetVisible(false).Once()
1223- bm.OnModeChange(context.Background(), input.ModeNormal, input.ModeVim)
1224-
1225- mockBox.EXPECT().SetVisible(false).Once()
1226- bm.OnModeChange(context.Background(), input.ModeVim, input.ModeNormal)
1227-}
1228-
12291194 // ============================================================================
12301195 // Pulse Debounce
12311196 // ============================================================================
12321197
12331198 func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
12341199 f := newSingleWindowVimModeFixture(t)
1235- enterVimMode(t, f.app, f.overlay1A, f.bw1)
1200+ enterVimMode(t, f.app, f.bw1)
12361201
12371202 // Set up expectations for exactly ONE normal pulse.
1203+ attachTestModeFrame(f.bw1, f.overlay1A)
12381204 setUpNormalPulse(f.overlay1A)
12391205
12401206 // First call fires the pulse (debounce timer is zero).
@@ -1257,9 +1223,10 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
12571223 assert.True(t, f.app.vimModePulseLastTime.IsZero(),
12581224 "fresh app should have zero pulse timer")
12591225
1260- enterVimMode(t, f.app, f.overlay1A, f.bw1)
1226+ enterVimMode(t, f.app, f.bw1)
12611227
12621228 // Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1229+ attachTestModeFrame(f.bw1, f.overlay1A)
12631230 setUpNormalPulse(f.overlay1A)
12641231 f.app.triggerVimModePulse(context.Background(), false)
12651232
@@ -1275,9 +1242,10 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
12751242 // pulse (which would need pulse-cycle alternation handling).
12761243
12771244 f := newSingleWindowVimModeFixture(t)
1278- enterVimMode(t, f.app, f.overlay1A, f.bw1)
1245+ enterVimMode(t, f.app, f.bw1)
12791246
12801247 // Pulse #1 fires and sets the timer.
1248+ attachTestModeFrame(f.bw1, f.overlay1A)
12811249 setUpNormalPulse(f.overlay1A)
12821250 f.app.triggerVimModePulse(context.Background(), false)
12831251 assert.False(t, f.app.vimModePulseLastTime.IsZero(),
@@ -1300,10 +1268,11 @@ func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
13001268 // of time.Time producing a time.Since result much larger than
13011269 // the debounce interval.
13021270 f := newSingleWindowVimModeFixture(t)
1303- enterVimMode(t, f.app, f.overlay1A, f.bw1)
1271+ enterVimMode(t, f.app, f.bw1)
13041272
13051273 // Rapid double pulse — only first should fire regardless of order.
13061274 // Set up just one set of expectations.
1275+ attachTestModeFrame(f.bw1, f.overlay1A)
13071276 setUpNormalPulse(f.overlay1A)
13081277 f.app.triggerVimModePulse(context.Background(), false)
13091278 f.app.triggerVimModePulse(context.Background(), false)
@@ -17,7 +17,6 @@ import (
17 "github.com/bnema/dumber/internal/domain/entity"17 "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/ui/component"18 "github.com/bnema/dumber/internal/ui/component"
19 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"19 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
20- "github.com/bnema/dumber/internal/ui/focus"
21 "github.com/bnema/dumber/internal/ui/input"20 "github.com/bnema/dumber/internal/ui/input"
22 "github.com/bnema/dumber/internal/ui/layout"21 "github.com/bnema/dumber/internal/ui/layout"
23 "github.com/bnema/dumber/internal/ui/layout/mocks"22 "github.com/bnema/dumber/internal/ui/layout/mocks"
@@ -143,20 +142,12 @@ func createTestPaneView(
143 }142 }
144 143
145 // enterVimMode marks the owning pane with the pane-local visual accent.144 // enterVimMode marks the owning pane with the pane-local visual accent.
146-func enterVimMode(145+func enterVimMode(t *testing.T, app *App, bw *browserWindow) {
147- t *testing.T,
148- app *App,
149- overlay *mocks.MockOverlayWidget,
150- bw *browserWindow,
151-) {
152 t.Helper()146 t.Helper()
153- overlay.EXPECT().AddCssClass("vim-mode-active").Once()
154 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)147 app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal)
155 }148 }
156 149
157-func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) {150+func expectVimModeAccentHidden(_ *mocks.MockOverlayWidget) {}
158- overlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
159-}
160 151
161 type accessibilityEnablingWebView struct {152 type accessibilityEnablingWebView struct {
162 *portmocks.MockWebView153 *portmocks.MockWebView
@@ -417,7 +408,7 @@ func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture {
417 func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {408 func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
418 f := newVimModeTestFixture(t)409 f := newVimModeTestFixture(t)
419 410
420- enterVimMode(t, f.app, f.overlay1A, f.bw1)411+ enterVimMode(t, f.app, f.bw1)
421 412
422 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,413 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID,
423 "entering vim mode sets ownership on the active pane")414 "entering vim mode sets ownership on the active pane")
@@ -434,7 +425,7 @@ func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) {
434 func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {425 func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) {
435 f := newVimModeTestFixture(t)426 f := newVimModeTestFixture(t)
436 427
437- enterVimMode(t, f.app, f.overlay2A, f.bw2)428+ enterVimMode(t, f.app, f.bw2)
438 429
439 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)430 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
440 assert.True(t, f.pv2A.IsVimMode())431 assert.True(t, f.pv2A.IsVimMode())
@@ -630,7 +621,7 @@ func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) {
630 f := newVimModeTestFixture(t)621 f := newVimModeTestFixture(t)
631 622
632 // Enter first623 // Enter first
633- enterVimMode(t, f.app, f.overlay1A, f.bw1)624+ enterVimMode(t, f.app, f.bw1)
634 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)625 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
635 626
636 // Leave — SetVimMode(false) hides indicator and removes the CSS class627 // Leave — SetVimMode(false) hides indicator and removes the CSS class
@@ -651,7 +642,7 @@ func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
651 f := newVimModeTestFixture(t)642 f := newVimModeTestFixture(t)
652 643
653 // Enter vim mode on bw1 pane-a — save label for hide expectation644 // Enter vim mode on bw1 pane-a — save label for hide expectation
654- enterVimMode(t, f.app, f.overlay1A, f.bw1)645+ enterVimMode(t, f.app, f.bw1)
655 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)646 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
656 647
657 // Give bw1 a keyboard handler in vim mode so transfer performs the switch648 // Give bw1 a keyboard handler in vim mode so transfer performs the switch
@@ -661,9 +652,6 @@ func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) {
661 // pane before SetVimMode(true) on the new pane. Set up both.652 // pane before SetVimMode(true) on the new pane. Set up both.
662 expectVimModeAccentHidden(f.overlay1A)653 expectVimModeAccentHidden(f.overlay1A)
663 654
664- // Set up the pane-local accent for pane-b.
665- f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
666-
667 // Transfer ownership to pane-b655 // Transfer ownership to pane-b
668 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))656 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
669 657
@@ -718,6 +706,12 @@ func TestVimMode_Transfer_EmptyOwnershipIsNoop(t *testing.T) {
718 // 4. Pulse targeting — normal vs fast706 // 4. Pulse targeting — normal vs fast
719 // ============================================================================707 // ============================================================================
720 708
709+// attachTestModeFrame uses the pane overlay as the frame's CSS target so the
710+// pulse assertions below observe the classes the frame border receives.
711+func attachTestModeFrame(bw *browserWindow, target layout.Widget) {
712+ bw.modeFrame = &modeFrame{mode: input.ModeVim, target: target, frameClass: "vim-mode-active", borderStyle: target}
713+}
714+
721 func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {715 func setUpNormalPulse(overlay *mocks.MockOverlayWidget) {
722 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()716 overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
723 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()717 overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
@@ -739,25 +733,22 @@ func setUpFastPulse(overlay *mocks.MockOverlayWidget) {
739 func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {733 func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) {
740 f := newVimModeTestFixture(t)734 f := newVimModeTestFixture(t)
741 735
742- enterVimMode(t, f.app, f.overlay1A, f.bw1)736+ enterVimMode(t, f.app, f.bw1)
743 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)737 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
744 738
745- // Pulse needs the indicator to exist — it was created lazily during739+ attachTestModeFrame(f.bw1, f.overlay1A)
746- // enterVimMode, but we also need to set up pulse expectations.
747- // (enterVimMode created it, so we just set up pulse mocks.)
748 setUpNormalPulse(f.overlay1A)740 setUpNormalPulse(f.overlay1A)
749-
750 f.app.triggerVimModePulse(context.Background(), false)741 f.app.triggerVimModePulse(context.Background(), false)
751 }742 }
752 743
753 func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {744 func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) {
754 f := newVimModeTestFixture(t)745 f := newVimModeTestFixture(t)
755 746
756- enterVimMode(t, f.app, f.overlay1A, f.bw1)747+ enterVimMode(t, f.app, f.bw1)
757 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)748 require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
758 749
750+ attachTestModeFrame(f.bw1, f.overlay1A)
759 setUpFastPulse(f.overlay1A)751 setUpFastPulse(f.overlay1A)
760-
761 f.app.triggerVimModePulse(context.Background(), true)752 f.app.triggerVimModePulse(context.Background(), true)
762 }753 }
763 754
@@ -775,8 +766,8 @@ func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) {
775 func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {766 func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
776 f := newVimModeTestFixture(t)767 f := newVimModeTestFixture(t)
777 768
778- enterVimMode(t, f.app, f.overlay1A, f.bw1)769+ enterVimMode(t, f.app, f.bw1)
779- enterVimMode(t, f.app, f.overlay2A, f.bw2)770+ enterVimMode(t, f.app, f.bw2)
780 771
781 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)772 assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID)
782 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)773 assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID)
@@ -796,8 +787,8 @@ func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) {
796 func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {787 func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
797 f := newVimModeTestFixture(t)788 f := newVimModeTestFixture(t)
798 789
799- enterVimMode(t, f.app, f.overlay1A, f.bw1)790+ enterVimMode(t, f.app, f.bw1)
800- enterVimMode(t, f.app, f.overlay2A, f.bw2)791+ enterVimMode(t, f.app, f.bw2)
801 792
802 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)793 f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t)
803 794
@@ -805,7 +796,6 @@ func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
805 expectVimModeAccentHidden(f.overlay1A)796 expectVimModeAccentHidden(f.overlay1A)
806 797
807 // Transfer bw1 to pane-b.798 // Transfer bw1 to pane-b.
808- f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once()
809 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))799 f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b"))
810 800
811 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)801 assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID)
@@ -820,10 +810,11 @@ func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) {
820 func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {810 func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) {
821 f := newVimModeTestFixture(t)811 f := newVimModeTestFixture(t)
822 812
823- enterVimMode(t, f.app, f.overlay1A, f.bw1)813+ enterVimMode(t, f.app, f.bw1)
824- enterVimMode(t, f.app, f.overlay2A, f.bw2)814+ enterVimMode(t, f.app, f.bw2)
825 815
826 // Pulse on bw1 (lastFocusedWindowID = "win-1")816 // Pulse on bw1 (lastFocusedWindowID = "win-1")
817+ attachTestModeFrame(f.bw1, f.overlay1A)
827 setUpNormalPulse(f.overlay1A)818 setUpNormalPulse(f.overlay1A)
828 f.app.triggerVimModePulse(context.Background(), false)819 f.app.triggerVimModePulse(context.Background(), false)
829 820
@@ -866,7 +857,7 @@ func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) {
866 857
867 func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {858 func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
868 f := newSingleWindowVimModeFixture(t)859 f := newSingleWindowVimModeFixture(t)
869- enterVimMode(t, f.app, f.overlay1A, f.bw1)860+ enterVimMode(t, f.app, f.bw1)
870 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)861 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
871 862
872 expectVimModeAccentHidden(f.overlay1A)863 expectVimModeAccentHidden(f.overlay1A)
@@ -879,7 +870,7 @@ func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) {
879 870
880 func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {871 func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
881 f := newSingleWindowVimModeFixture(t)872 f := newSingleWindowVimModeFixture(t)
882- enterVimMode(t, f.app, f.overlay1A, f.bw1)873+ enterVimMode(t, f.app, f.bw1)
883 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)874 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
884 875
885 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)876 f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true)
@@ -891,7 +882,7 @@ func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) {
891 882
892 func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {883 func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
893 f := newSingleWindowVimModeFixture(t)884 f := newSingleWindowVimModeFixture(t)
894- enterVimMode(t, f.app, f.overlay1A, f.bw1)885+ enterVimMode(t, f.app, f.bw1)
895 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)886 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
896 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{887 f.app.pageEditableFocusByPane = map[entity.PaneID]bool{
897 "pane-b": true,888 "pane-b": true,
@@ -908,7 +899,7 @@ func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) {
908 899
909 func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {900 func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) {
910 factory := mocks.NewMockWidgetFactory(t)901 factory := mocks.NewMockWidgetFactory(t)
911- pv1, overlay1 := createTestPaneView(t, factory, "pane-a")902+ pv1, _ := createTestPaneView(t, factory, "pane-a")
912 pv2, _ := createTestPaneView(t, factory, "pane-x")903 pv2, _ := createTestPaneView(t, factory, "pane-x")
913 904
914 ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}905 ws1 := &entity.Workspace{ID: "ws-1", ActivePaneID: "pane-a", Root: &entity.PaneNode{ID: "ws-1-pane-a", Pane: entity.NewPane("pane-a")}}
@@ -932,7 +923,7 @@ func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t
932 lastFocusedWindowID: "win-1",923 lastFocusedWindowID: "win-1",
933 }924 }
934 925
935- enterVimMode(t, app, overlay1, bw1)926+ enterVimMode(t, app, bw1)
936 kh := bindVimModeKeyboardHandler(t, app, bw1)927 kh := bindVimModeKeyboardHandler(t, app, bw1)
937 928
938 app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)929 app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true)
@@ -944,7 +935,7 @@ func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t
944 935
945 func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {936 func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
946 f := newSingleWindowVimModeFixture(t)937 f := newSingleWindowVimModeFixture(t)
947- enterVimMode(t, f.app, f.overlay1A, f.bw1)938+ enterVimMode(t, f.app, f.bw1)
948 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)939 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
949 940
950 expectVimModeAccentHidden(f.overlay1A)941 expectVimModeAccentHidden(f.overlay1A)
@@ -956,7 +947,7 @@ func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) {
956 947
957 func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {948 func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
958 f := newSingleWindowVimModeFixture(t)949 f := newSingleWindowVimModeFixture(t)
959- enterVimMode(t, f.app, f.overlay1A, f.bw1)950+ enterVimMode(t, f.app, f.bw1)
960 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)951 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
961 952
962 expectVimModeAccentHidden(f.overlay1A)953 expectVimModeAccentHidden(f.overlay1A)
@@ -968,7 +959,7 @@ func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) {
968 959
969 func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {960 func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) {
970 f := newSingleWindowVimModeFixture(t)961 f := newSingleWindowVimModeFixture(t)
971- enterVimMode(t, f.app, f.overlay1A, f.bw1)962+ enterVimMode(t, f.app, f.bw1)
972 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)963 kh := bindVimModeKeyboardHandler(t, f.app, f.bw1)
973 964
974 tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{965 tab2 := &entity.Tab{ID: "tab-2b", Workspace: &entity.Workspace{
@@ -1021,9 +1012,7 @@ func TestVimMode_ClearEditableFocusStateRemovesStoredBypass(t *testing.T) {
1021 // ============================================================================1012 // ============================================================================
1022 1013
1023 func TestVimMode_CSSClassConstants(t *testing.T) {1014 func TestVimMode_CSSClassConstants(t *testing.T) {
1024- assert.Equal(t, "vim-mode-active", component.VimModeActiveClass)1015+ assert.Equal(t, "vim-mode-active", modeFrameClass(input.ModeVim))
1025- assert.Equal(t, "vim-mode-pulse", component.VimModePulseClass)
1026- assert.Equal(t, "vim-mode-pulse-fast", component.VimModeFastPulseClass)
1027 }1016 }
1028 1017
1029 // ============================================================================1018 // ============================================================================
@@ -1202,39 +1191,16 @@ func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1202 assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")1191 assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1203 }1192 }
1204 1193
1205-// ============================================================================
1206-// BorderManager — Vim mode must NOT use the global border overlay
1207-// ============================================================================
1208-
1209-func TestVimMode_GlobalBorderOverlayStaysOff(t *testing.T) {
1210- mockFactory := mocks.NewMockWidgetFactory(t)
1211- mockBox := mocks.NewMockBoxWidget(t)
1212-
1213- mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBox).Once()
1214- mockBox.EXPECT().SetCanFocus(false).Once()
1215- mockBox.EXPECT().SetCanTarget(false).Once()
1216- mockBox.EXPECT().SetHexpand(true).Once()
1217- mockBox.EXPECT().SetVexpand(true).Once()
1218- mockBox.EXPECT().SetVisible(false).Once()
1219-
1220- bm := focus.NewBorderManager(mockFactory)
1221-
1222- mockBox.EXPECT().SetVisible(false).Once()
1223- bm.OnModeChange(context.Background(), input.ModeNormal, input.ModeVim)
1224-
1225- mockBox.EXPECT().SetVisible(false).Once()
1226- bm.OnModeChange(context.Background(), input.ModeVim, input.ModeNormal)
1227-}
1228-
1229 // ============================================================================1194 // ============================================================================
1230 // Pulse Debounce1195 // Pulse Debounce
1231 // ============================================================================1196 // ============================================================================
1232 1197
1233 func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {1198 func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) {
1234 f := newSingleWindowVimModeFixture(t)1199 f := newSingleWindowVimModeFixture(t)
1235- enterVimMode(t, f.app, f.overlay1A, f.bw1)1200+ enterVimMode(t, f.app, f.bw1)
1236 1201
1237 // Set up expectations for exactly ONE normal pulse.1202 // Set up expectations for exactly ONE normal pulse.
1203+ attachTestModeFrame(f.bw1, f.overlay1A)
1238 setUpNormalPulse(f.overlay1A)1204 setUpNormalPulse(f.overlay1A)
1239 1205
1240 // First call fires the pulse (debounce timer is zero).1206 // First call fires the pulse (debounce timer is zero).
@@ -1257,9 +1223,10 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) {
1257 assert.True(t, f.app.vimModePulseLastTime.IsZero(),1223 assert.True(t, f.app.vimModePulseLastTime.IsZero(),
1258 "fresh app should have zero pulse timer")1224 "fresh app should have zero pulse timer")
1259 1225
1260- enterVimMode(t, f.app, f.overlay1A, f.bw1)1226+ enterVimMode(t, f.app, f.bw1)
1261 1227
1262 // Pulse #1 fires because timer is zero (time.Since(zero) is huge).1228 // Pulse #1 fires because timer is zero (time.Since(zero) is huge).
1229+ attachTestModeFrame(f.bw1, f.overlay1A)
1263 setUpNormalPulse(f.overlay1A)1230 setUpNormalPulse(f.overlay1A)
1264 f.app.triggerVimModePulse(context.Background(), false)1231 f.app.triggerVimModePulse(context.Background(), false)
1265 1232
@@ -1275,9 +1242,10 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) {
1275 // pulse (which would need pulse-cycle alternation handling).1242 // pulse (which would need pulse-cycle alternation handling).
1276 1243
1277 f := newSingleWindowVimModeFixture(t)1244 f := newSingleWindowVimModeFixture(t)
1278- enterVimMode(t, f.app, f.overlay1A, f.bw1)1245+ enterVimMode(t, f.app, f.bw1)
1279 1246
1280 // Pulse #1 fires and sets the timer.1247 // Pulse #1 fires and sets the timer.
1248+ attachTestModeFrame(f.bw1, f.overlay1A)
1281 setUpNormalPulse(f.overlay1A)1249 setUpNormalPulse(f.overlay1A)
1282 f.app.triggerVimModePulse(context.Background(), false)1250 f.app.triggerVimModePulse(context.Background(), false)
1283 assert.False(t, f.app.vimModePulseLastTime.IsZero(),1251 assert.False(t, f.app.vimModePulseLastTime.IsZero(),
@@ -1300,10 +1268,11 @@ func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) {
1300 // of time.Time producing a time.Since result much larger than1268 // of time.Time producing a time.Since result much larger than
1301 // the debounce interval.1269 // the debounce interval.
1302 f := newSingleWindowVimModeFixture(t)1270 f := newSingleWindowVimModeFixture(t)
1303- enterVimMode(t, f.app, f.overlay1A, f.bw1)1271+ enterVimMode(t, f.app, f.bw1)
1304 1272
1305 // Rapid double pulse — only first should fire regardless of order.1273 // Rapid double pulse — only first should fire regardless of order.
1306 // Set up just one set of expectations.1274 // Set up just one set of expectations.
1275+ attachTestModeFrame(f.bw1, f.overlay1A)
1307 setUpNormalPulse(f.overlay1A)1276 setUpNormalPulse(f.overlay1A)
1308 f.app.triggerVimModePulse(context.Background(), false)1277 f.app.triggerVimModePulse(context.Background(), false)
1309 f.app.triggerVimModePulse(context.Background(), false)1278 f.app.triggerVimModePulse(context.Background(), false)
M internal/ui/browser_window.go +15 -14
@@ -14,7 +14,6 @@ import (
1414 "github.com/bnema/dumber/internal/shared/syncdispatch"
1515 "github.com/bnema/dumber/internal/ui/component"
1616 "github.com/bnema/dumber/internal/ui/coordinator"
17- "github.com/bnema/dumber/internal/ui/focus"
1817 "github.com/bnema/dumber/internal/ui/input"
1918 "github.com/bnema/dumber/internal/ui/layout"
2019 "github.com/bnema/dumber/internal/ui/window"
@@ -37,7 +36,7 @@ type browserWindow struct {
3736 appToaster *component.Toaster
3837 modeToaster *component.Toaster
3938 touchpadNavIndicator *component.TouchpadNavigationIndicator
40- borderMgr *focus.BorderManager
39+ modeFrame *modeFrame
4140 sessionManager *component.SessionManager
4241 tabPicker *component.TabPicker
4342 tabPickerWidget layout.Widget
@@ -101,7 +100,10 @@ func (bw *browserWindow) clearShellState() {
101100 bw.appToaster = nil
102101 bw.modeToaster = nil
103102 bw.touchpadNavIndicator = nil
104- bw.borderMgr = nil
103+ if bw.modeFrame != nil {
104+ bw.modeFrame.destroy()
105+ bw.modeFrame = nil
106+ }
105107 bw.sessionManager = nil
106108 bw.tabPicker = nil
107109 bw.tabPickerWidget = nil
@@ -179,15 +181,7 @@ func (bw *browserWindow) initBorderOverlay(a *App) {
179181 return
180182 }
181183
182- bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
183- if bw.borderMgr == nil {
184- return
185- }
186- if widget := bw.borderMgr.Widget(); widget != nil {
187- if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
188- bw.mainWindow.AddOverlay(gtkWidget)
189- }
190- }
184+ bw.modeFrame = newModeFrame(bw.mainWindow.ContentOverlay(), bw.mainWindow.Window())
191185 }
192186
193187 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
@@ -356,6 +350,15 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
356350 }
357351
358352 a.releaseFloatingSessionsForTab(ctx, tab.ID)
353+ if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID {
354+ bw.modeFrame.cancelTimers()
355+ bw.modeFrame.dismissLinger()
356+ bw.modeFrame.generation++
357+ bw.modeFrame.setTarget(nil, "")
358+ bw.modeFrame.tabID = ""
359+ bw.modeFrame.hide()
360+ a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode)
361+ }
359362 if a.contentCoord != nil && tab.Workspace != nil {
360363 for _, pane := range tab.Workspace.AllPanes() {
361364 if pane == nil {
@@ -492,7 +495,6 @@ func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *br
492495 }
493496
494497 func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
495- a.clearResizeModeBorder()
496498 if fallback != nil {
497499 a.activateBrowserWindow(fallback)
498500 return
@@ -523,7 +525,6 @@ func (a *App) cleanupTransientBrowserWindowForDestroy(bw *browserWindow) {
523525 }
524526 }
525527 if bw.mainWindow == a.mainWindow {
526- a.clearResizeModeBorder()
527528 if fallback != nil {
528529 a.activateBrowserWindow(fallback)
529530 return
@@ -14,7 +14,6 @@ import (
14 "github.com/bnema/dumber/internal/shared/syncdispatch"14 "github.com/bnema/dumber/internal/shared/syncdispatch"
15 "github.com/bnema/dumber/internal/ui/component"15 "github.com/bnema/dumber/internal/ui/component"
16 "github.com/bnema/dumber/internal/ui/coordinator"16 "github.com/bnema/dumber/internal/ui/coordinator"
17- "github.com/bnema/dumber/internal/ui/focus"
18 "github.com/bnema/dumber/internal/ui/input"17 "github.com/bnema/dumber/internal/ui/input"
19 "github.com/bnema/dumber/internal/ui/layout"18 "github.com/bnema/dumber/internal/ui/layout"
20 "github.com/bnema/dumber/internal/ui/window"19 "github.com/bnema/dumber/internal/ui/window"
@@ -37,7 +36,7 @@ type browserWindow struct {
37 appToaster *component.Toaster36 appToaster *component.Toaster
38 modeToaster *component.Toaster37 modeToaster *component.Toaster
39 touchpadNavIndicator *component.TouchpadNavigationIndicator38 touchpadNavIndicator *component.TouchpadNavigationIndicator
40- borderMgr *focus.BorderManager39+ modeFrame *modeFrame
41 sessionManager *component.SessionManager40 sessionManager *component.SessionManager
42 tabPicker *component.TabPicker41 tabPicker *component.TabPicker
43 tabPickerWidget layout.Widget42 tabPickerWidget layout.Widget
@@ -101,7 +100,10 @@ func (bw *browserWindow) clearShellState() {
101 bw.appToaster = nil100 bw.appToaster = nil
102 bw.modeToaster = nil101 bw.modeToaster = nil
103 bw.touchpadNavIndicator = nil102 bw.touchpadNavIndicator = nil
104- bw.borderMgr = nil103+ if bw.modeFrame != nil {
104+ bw.modeFrame.destroy()
105+ bw.modeFrame = nil
106+ }
105 bw.sessionManager = nil107 bw.sessionManager = nil
106 bw.tabPicker = nil108 bw.tabPicker = nil
107 bw.tabPickerWidget = nil109 bw.tabPickerWidget = nil
@@ -179,15 +181,7 @@ func (bw *browserWindow) initBorderOverlay(a *App) {
179 return181 return
180 }182 }
181 183
182- bw.borderMgr = focus.NewBorderManager(a.widgetFactory)184+ bw.modeFrame = newModeFrame(bw.mainWindow.ContentOverlay(), bw.mainWindow.Window())
183- if bw.borderMgr == nil {
184- return
185- }
186- if widget := bw.borderMgr.Widget(); widget != nil {
187- if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
188- bw.mainWindow.AddOverlay(gtkWidget)
189- }
190- }
191 }185 }
192 186
193 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {187 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
@@ -356,6 +350,15 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
356 }350 }
357 351
358 a.releaseFloatingSessionsForTab(ctx, tab.ID)352 a.releaseFloatingSessionsForTab(ctx, tab.ID)
353+ if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID {
354+ bw.modeFrame.cancelTimers()
355+ bw.modeFrame.dismissLinger()
356+ bw.modeFrame.generation++
357+ bw.modeFrame.setTarget(nil, "")
358+ bw.modeFrame.tabID = ""
359+ bw.modeFrame.hide()
360+ a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode)
361+ }
359 if a.contentCoord != nil && tab.Workspace != nil {362 if a.contentCoord != nil && tab.Workspace != nil {
360 for _, pane := range tab.Workspace.AllPanes() {363 for _, pane := range tab.Workspace.AllPanes() {
361 if pane == nil {364 if pane == nil {
@@ -492,7 +495,6 @@ func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *br
492 }495 }
493 496
494 func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {497 func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
495- a.clearResizeModeBorder()
496 if fallback != nil {498 if fallback != nil {
497 a.activateBrowserWindow(fallback)499 a.activateBrowserWindow(fallback)
498 return500 return
@@ -523,7 +525,6 @@ func (a *App) cleanupTransientBrowserWindowForDestroy(bw *browserWindow) {
523 }525 }
524 }526 }
525 if bw.mainWindow == a.mainWindow {527 if bw.mainWindow == a.mainWindow {
526- a.clearResizeModeBorder()
527 if fallback != nil {528 if fallback != nil {
528 a.activateBrowserWindow(fallback)529 a.activateBrowserWindow(fallback)
529 return530 return
M internal/ui/browser_window_test.go +7 -10
@@ -15,7 +15,6 @@ import (
1515 "github.com/bnema/dumber/internal/shared/syncdispatch"
1616 "github.com/bnema/dumber/internal/ui/component"
1717 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
18- "github.com/bnema/dumber/internal/ui/focus"
1918 "github.com/bnema/dumber/internal/ui/input"
2019 "github.com/bnema/dumber/internal/ui/layout"
2120 "github.com/bnema/dumber/internal/ui/window"
@@ -36,7 +35,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
3635
3736 setShellField(t, removed, "appToaster", &component.Toaster{})
3837 setShellField(t, removed, "modeToaster", &component.Toaster{})
39- setShellField(t, removed, "borderMgr", &focus.BorderManager{})
38+ setShellField(t, removed, "modeFrame", &modeFrame{})
4039 setShellField(t, removed, "sessionManager", &component.SessionManager{})
4140 setShellField(t, removed, "tabPicker", &component.TabPicker{})
4241 setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
@@ -54,7 +53,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
5453 for _, name := range []string{
5554 "appToaster",
5655 "modeToaster",
57- "borderMgr",
56+ "modeFrame",
5857 "sessionManager",
5958 "tabPicker",
6059 "tabPickerWidget",
@@ -428,22 +427,20 @@ func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
428427 }
429428 }
430429
431-func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
430+func TestBrowserWindow_RemovePromotedWindowKeepsRemainingWindow(t *testing.T) {
432431 mainWindow := &window.MainWindow{}
433432 otherWindow := &window.MainWindow{}
434433 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
435434 remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
436- resizeTarget := &testLayoutWidget{}
437435 app := &App{
438- mainWindow: mainWindow,
439- browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
440- resizeModeBorderTarget: resizeTarget,
436+ mainWindow: mainWindow,
437+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
441438 }
442439
443440 app.removeBrowserWindow(removed.id)
444441
445- if app.resizeModeBorderTarget != nil {
446- t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
442+ if app.mainWindow != otherWindow {
443+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, otherWindow)
447444 }
448445 }
449446
@@ -15,7 +15,6 @@ import (
15 "github.com/bnema/dumber/internal/shared/syncdispatch"15 "github.com/bnema/dumber/internal/shared/syncdispatch"
16 "github.com/bnema/dumber/internal/ui/component"16 "github.com/bnema/dumber/internal/ui/component"
17 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"17 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
18- "github.com/bnema/dumber/internal/ui/focus"
19 "github.com/bnema/dumber/internal/ui/input"18 "github.com/bnema/dumber/internal/ui/input"
20 "github.com/bnema/dumber/internal/ui/layout"19 "github.com/bnema/dumber/internal/ui/layout"
21 "github.com/bnema/dumber/internal/ui/window"20 "github.com/bnema/dumber/internal/ui/window"
@@ -36,7 +35,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
36 35
37 setShellField(t, removed, "appToaster", &component.Toaster{})36 setShellField(t, removed, "appToaster", &component.Toaster{})
38 setShellField(t, removed, "modeToaster", &component.Toaster{})37 setShellField(t, removed, "modeToaster", &component.Toaster{})
39- setShellField(t, removed, "borderMgr", &focus.BorderManager{})38+ setShellField(t, removed, "modeFrame", &modeFrame{})
40 setShellField(t, removed, "sessionManager", &component.SessionManager{})39 setShellField(t, removed, "sessionManager", &component.SessionManager{})
41 setShellField(t, removed, "tabPicker", &component.TabPicker{})40 setShellField(t, removed, "tabPicker", &component.TabPicker{})
42 setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))41 setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
@@ -54,7 +53,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
54 for _, name := range []string{53 for _, name := range []string{
55 "appToaster",54 "appToaster",
56 "modeToaster",55 "modeToaster",
57- "borderMgr",56+ "modeFrame",
58 "sessionManager",57 "sessionManager",
59 "tabPicker",58 "tabPicker",
60 "tabPickerWidget",59 "tabPickerWidget",
@@ -428,22 +427,20 @@ func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
428 }427 }
429 }428 }
430 429
431-func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {430+func TestBrowserWindow_RemovePromotedWindowKeepsRemainingWindow(t *testing.T) {
432 mainWindow := &window.MainWindow{}431 mainWindow := &window.MainWindow{}
433 otherWindow := &window.MainWindow{}432 otherWindow := &window.MainWindow{}
434 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}433 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
435 remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}434 remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
436- resizeTarget := &testLayoutWidget{}
437 app := &App{435 app := &App{
438- mainWindow: mainWindow,436+ mainWindow: mainWindow,
439- browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},437+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
440- resizeModeBorderTarget: resizeTarget,
441 }438 }
442 439
443 app.removeBrowserWindow(removed.id)440 app.removeBrowserWindow(removed.id)
444 441
445- if app.resizeModeBorderTarget != nil {442+ if app.mainWindow != otherWindow {
446- t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)443+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, otherWindow)
447 }444 }
448 }445 }
449 446
M internal/ui/component/pane_view.go +5 -69
@@ -11,31 +11,8 @@ import (
1111 "github.com/bnema/dumber/internal/ui/layout"
1212 )
1313
14-const (
15- // CSS class applied to active pane's border overlay
16- activePaneClass = "pane-active"
17-
18- // vimModeActiveClass is added to the pane overlay when Vim mode is active.
19- // It produces a subtle local border accent using the pane mode color.
20- vimModeActiveClass = "vim-mode-active"
21-
22- // vimModePulseClass triggers a normal scroll pulse on the pane overlay.
23- // Repeated calls must remove-then-add to restart the CSS animation.
24- vimModePulseClass = "vim-mode-pulse"
25-
26- // vimModeFastPulseClass triggers a fast/stronger scroll pulse.
27- vimModeFastPulseClass = "vim-mode-pulse-fast"
28-
29- // Alternate equivalent animations so repeated scrolls reliably restart
30- // the GTK CSS animation.
31- vimModePulseCycleClassA = "vim-mode-pulse-cycle-a"
32- vimModePulseCycleClassB = "vim-mode-pulse-cycle-b"
33-
34- // Exported aliases for tests and external inspection.
35- VimModeActiveClass = vimModeActiveClass
36- VimModePulseClass = vimModePulseClass
37- VimModeFastPulseClass = vimModeFastPulseClass
38-)
14+// CSS class applied to the active pane's border overlay.
15+const activePaneClass = "pane-active"
3916
4017 // PaneView is a container for a single WebView with active state indication.
4118 // It uses an overlay to display a border around the active pane.
@@ -52,9 +29,8 @@ type PaneView struct {
5229 paneID entity.PaneID
5330 isActive bool
5431
55- // Vim Mode pane-local visual state.
56- vimMode bool
57- pulseCycle bool
32+ // Vim Mode input ownership; its border and pulse belong to modeFrame.
33+ vimMode bool
5834
5935 onFocusIn func(paneID entity.PaneID)
6036 onFocusOut func(paneID entity.PaneID)
@@ -546,7 +522,7 @@ func (pv *PaneView) HideLinkStatus() {
546522 }
547523 }
548524
549-// SetVimMode activates or deactivates the pane-local Vim Mode accent.
525+// SetVimMode tracks the pane owning Vim navigation; modeFrame owns the border.
550526 func (pv *PaneView) SetVimMode(active bool) {
551527 pv.mu.Lock()
552528 defer pv.mu.Unlock()
@@ -556,12 +532,6 @@ func (pv *PaneView) SetVimMode(active bool) {
556532 }
557533
558534 pv.vimMode = active
559-
560- if active {
561- pv.overlay.AddCssClass(vimModeActiveClass)
562- } else {
563- pv.overlay.RemoveCssClass(vimModeActiveClass)
564- }
565535 }
566536
567537 // IsVimMode returns whether Vim mode is currently active on this pane.
@@ -572,40 +542,6 @@ func (pv *PaneView) IsVimMode() bool {
572542 return pv.vimMode
573543 }
574544
575-// TriggerVimModePulse triggers a normal scroll pulse on the pane overlay.
576-func (pv *PaneView) TriggerVimModePulse() {
577- pv.triggerOverlayPulse(false)
578-}
579-
580-// TriggerVimModePulseFast triggers a stronger, longer pane-overlay pulse.
581-func (pv *PaneView) TriggerVimModePulseFast() {
582- pv.triggerOverlayPulse(true)
583-}
584-
585-// triggerOverlayPulse adds a transient pulse CSS class to the pane overlay.
586-// fast=true uses the stronger/longer pulse animation. Both pulse classes are
587-// removed first to reliably re-trigger the animation.
588-func (pv *PaneView) triggerOverlayPulse(fast bool) {
589- pv.mu.Lock()
590- defer pv.mu.Unlock()
591-
592- pv.overlay.RemoveCssClass(vimModePulseClass)
593- pv.overlay.RemoveCssClass(vimModeFastPulseClass)
594- pv.overlay.RemoveCssClass(vimModePulseCycleClassA)
595- pv.overlay.RemoveCssClass(vimModePulseCycleClassB)
596- if fast {
597- pv.overlay.AddCssClass(vimModeFastPulseClass)
598- } else {
599- pv.overlay.AddCssClass(vimModePulseClass)
600- }
601- cycle := vimModePulseCycleClassA
602- if pv.pulseCycle {
603- cycle = vimModePulseCycleClassB
604- }
605- pv.pulseCycle = !pv.pulseCycle
606- pv.overlay.AddCssClass(cycle)
607-}
608-
609545 // Cleanup removes the WebView widget from the overlay and clears references.
610546 // This must be called before destroying the WebView to ensure proper GTK cleanup.
611547 // After calling Cleanup, the PaneView should not be reused.
@@ -11,31 +11,8 @@ import (
11 "github.com/bnema/dumber/internal/ui/layout"11 "github.com/bnema/dumber/internal/ui/layout"
12 )12 )
13 13
14-const (14+// CSS class applied to the active pane's border overlay.
15- // CSS class applied to active pane's border overlay15+const activePaneClass = "pane-active"
16- activePaneClass = "pane-active"
17-
18- // vimModeActiveClass is added to the pane overlay when Vim mode is active.
19- // It produces a subtle local border accent using the pane mode color.
20- vimModeActiveClass = "vim-mode-active"
21-
22- // vimModePulseClass triggers a normal scroll pulse on the pane overlay.
23- // Repeated calls must remove-then-add to restart the CSS animation.
24- vimModePulseClass = "vim-mode-pulse"
25-
26- // vimModeFastPulseClass triggers a fast/stronger scroll pulse.
27- vimModeFastPulseClass = "vim-mode-pulse-fast"
28-
29- // Alternate equivalent animations so repeated scrolls reliably restart
30- // the GTK CSS animation.
31- vimModePulseCycleClassA = "vim-mode-pulse-cycle-a"
32- vimModePulseCycleClassB = "vim-mode-pulse-cycle-b"
33-
34- // Exported aliases for tests and external inspection.
35- VimModeActiveClass = vimModeActiveClass
36- VimModePulseClass = vimModePulseClass
37- VimModeFastPulseClass = vimModeFastPulseClass
38-)
39 16
40 // PaneView is a container for a single WebView with active state indication.17 // PaneView is a container for a single WebView with active state indication.
41 // It uses an overlay to display a border around the active pane.18 // It uses an overlay to display a border around the active pane.
@@ -52,9 +29,8 @@ type PaneView struct {
52 paneID entity.PaneID29 paneID entity.PaneID
53 isActive bool30 isActive bool
54 31
55- // Vim Mode pane-local visual state.32+ // Vim Mode input ownership; its border and pulse belong to modeFrame.
56- vimMode bool33+ vimMode bool
57- pulseCycle bool
58 34
59 onFocusIn func(paneID entity.PaneID)35 onFocusIn func(paneID entity.PaneID)
60 onFocusOut func(paneID entity.PaneID)36 onFocusOut func(paneID entity.PaneID)
@@ -546,7 +522,7 @@ func (pv *PaneView) HideLinkStatus() {
546 }522 }
547 }523 }
548 524
549-// SetVimMode activates or deactivates the pane-local Vim Mode accent.525+// SetVimMode tracks the pane owning Vim navigation; modeFrame owns the border.
550 func (pv *PaneView) SetVimMode(active bool) {526 func (pv *PaneView) SetVimMode(active bool) {
551 pv.mu.Lock()527 pv.mu.Lock()
552 defer pv.mu.Unlock()528 defer pv.mu.Unlock()
@@ -556,12 +532,6 @@ func (pv *PaneView) SetVimMode(active bool) {
556 }532 }
557 533
558 pv.vimMode = active534 pv.vimMode = active
559-
560- if active {
561- pv.overlay.AddCssClass(vimModeActiveClass)
562- } else {
563- pv.overlay.RemoveCssClass(vimModeActiveClass)
564- }
565 }535 }
566 536
567 // IsVimMode returns whether Vim mode is currently active on this pane.537 // IsVimMode returns whether Vim mode is currently active on this pane.
@@ -572,40 +542,6 @@ func (pv *PaneView) IsVimMode() bool {
572 return pv.vimMode542 return pv.vimMode
573 }543 }
574 544
575-// TriggerVimModePulse triggers a normal scroll pulse on the pane overlay.
576-func (pv *PaneView) TriggerVimModePulse() {
577- pv.triggerOverlayPulse(false)
578-}
579-
580-// TriggerVimModePulseFast triggers a stronger, longer pane-overlay pulse.
581-func (pv *PaneView) TriggerVimModePulseFast() {
582- pv.triggerOverlayPulse(true)
583-}
584-
585-// triggerOverlayPulse adds a transient pulse CSS class to the pane overlay.
586-// fast=true uses the stronger/longer pulse animation. Both pulse classes are
587-// removed first to reliably re-trigger the animation.
588-func (pv *PaneView) triggerOverlayPulse(fast bool) {
589- pv.mu.Lock()
590- defer pv.mu.Unlock()
591-
592- pv.overlay.RemoveCssClass(vimModePulseClass)
593- pv.overlay.RemoveCssClass(vimModeFastPulseClass)
594- pv.overlay.RemoveCssClass(vimModePulseCycleClassA)
595- pv.overlay.RemoveCssClass(vimModePulseCycleClassB)
596- if fast {
597- pv.overlay.AddCssClass(vimModeFastPulseClass)
598- } else {
599- pv.overlay.AddCssClass(vimModePulseClass)
600- }
601- cycle := vimModePulseCycleClassA
602- if pv.pulseCycle {
603- cycle = vimModePulseCycleClassB
604- }
605- pv.pulseCycle = !pv.pulseCycle
606- pv.overlay.AddCssClass(cycle)
607-}
608-
609 // Cleanup removes the WebView widget from the overlay and clears references.545 // Cleanup removes the WebView widget from the overlay and clears references.
610 // This must be called before destroying the WebView to ensure proper GTK cleanup.546 // This must be called before destroying the WebView to ensure proper GTK cleanup.
611 // After calling Cleanup, the PaneView should not be reused.547 // After calling Cleanup, the PaneView should not be reused.
M internal/ui/component/pane_view_test.go +0 -58
@@ -690,7 +690,6 @@ func TestSetVimMode_TrueAddsPaneAccentWithoutCreatingLabel(t *testing.T) {
690690 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
691691 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
692692
693- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
694693 pv.SetVimMode(true)
695694
696695 require.True(t, pv.IsVimMode())
@@ -704,9 +703,7 @@ func TestSetVimMode_FalseRemovesPaneAccent(t *testing.T) {
704703 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
705704 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
706705
707- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
708706 pv.SetVimMode(true)
709- mockOverlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
710707 pv.SetVimMode(false)
711708
712709 require.False(t, pv.IsVimMode())
@@ -733,66 +730,12 @@ func TestSetVimMode_TrueThenTrueIsIdempotent(t *testing.T) {
733730 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
734731 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
735732
736- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
737733 pv.SetVimMode(true)
738734 pv.SetVimMode(true)
739735
740736 require.True(t, pv.IsVimMode())
741737 }
742738
743-func expectVimModeOverlayPulse(overlay *mocks.MockOverlayWidget, fast bool, cycle string) {
744- overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
745- overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
746- overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
747- overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
748- if fast {
749- overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
750- } else {
751- overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
752- }
753- overlay.EXPECT().AddCssClass(cycle).Once()
754-}
755-
756-func TestTriggerVimModePulse_PulsesOnlyOverlay(t *testing.T) {
757- mockFactory := mocks.NewMockWidgetFactory(t)
758- mockOverlay := mocks.NewMockOverlayWidget(t)
759- mockBorderBox := mocks.NewMockBoxWidget(t)
760- mockWebView := mocks.NewMockWidget(t)
761- setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
762- pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
763-
764- expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
765- pv.TriggerVimModePulse()
766-}
767-
768-func TestTriggerVimModePulseFast_PulsesOnlyOverlay(t *testing.T) {
769- mockFactory := mocks.NewMockWidgetFactory(t)
770- mockOverlay := mocks.NewMockOverlayWidget(t)
771- mockBorderBox := mocks.NewMockBoxWidget(t)
772- mockWebView := mocks.NewMockWidget(t)
773- setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
774- pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
775-
776- expectVimModeOverlayPulse(mockOverlay, true, "vim-mode-pulse-cycle-a")
777- pv.TriggerVimModePulseFast()
778-}
779-
780-func TestTriggerVimModePulse_RepeatedCallsReTriggerOverlayAnimation(t *testing.T) {
781- mockFactory := mocks.NewMockWidgetFactory(t)
782- mockOverlay := mocks.NewMockOverlayWidget(t)
783- mockBorderBox := mocks.NewMockBoxWidget(t)
784- mockWebView := mocks.NewMockWidget(t)
785- setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
786- pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
787-
788- expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
789- pv.TriggerVimModePulse()
790- expectVimModeOverlayPulse(mockOverlay, true, "vim-mode-pulse-cycle-b")
791- pv.TriggerVimModePulseFast()
792- expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
793- pv.TriggerVimModePulse()
794-}
795-
796739 func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) {
797740 mockFactory := mocks.NewMockWidgetFactory(t)
798741 mockOverlay := mocks.NewMockOverlayWidget(t)
@@ -801,7 +744,6 @@ func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) {
801744 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
802745 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
803746
804- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
805747 pv.SetVimMode(true)
806748 mockOverlay.EXPECT().RemoveOverlay(mock.Anything).Once()
807749 mockOverlay.EXPECT().SetChild(nil).Once()
@@ -690,7 +690,6 @@ func TestSetVimMode_TrueAddsPaneAccentWithoutCreatingLabel(t *testing.T) {
690 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)690 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
691 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)691 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
692 692
693- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
694 pv.SetVimMode(true)693 pv.SetVimMode(true)
695 694
696 require.True(t, pv.IsVimMode())695 require.True(t, pv.IsVimMode())
@@ -704,9 +703,7 @@ func TestSetVimMode_FalseRemovesPaneAccent(t *testing.T) {
704 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)703 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
705 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)704 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
706 705
707- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
708 pv.SetVimMode(true)706 pv.SetVimMode(true)
709- mockOverlay.EXPECT().RemoveCssClass("vim-mode-active").Once()
710 pv.SetVimMode(false)707 pv.SetVimMode(false)
711 708
712 require.False(t, pv.IsVimMode())709 require.False(t, pv.IsVimMode())
@@ -733,66 +730,12 @@ func TestSetVimMode_TrueThenTrueIsIdempotent(t *testing.T) {
733 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)730 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
734 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)731 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
735 732
736- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
737 pv.SetVimMode(true)733 pv.SetVimMode(true)
738 pv.SetVimMode(true)734 pv.SetVimMode(true)
739 735
740 require.True(t, pv.IsVimMode())736 require.True(t, pv.IsVimMode())
741 }737 }
742 738
743-func expectVimModeOverlayPulse(overlay *mocks.MockOverlayWidget, fast bool, cycle string) {
744- overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once()
745- overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once()
746- overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-a").Once()
747- overlay.EXPECT().RemoveCssClass("vim-mode-pulse-cycle-b").Once()
748- if fast {
749- overlay.EXPECT().AddCssClass("vim-mode-pulse-fast").Once()
750- } else {
751- overlay.EXPECT().AddCssClass("vim-mode-pulse").Once()
752- }
753- overlay.EXPECT().AddCssClass(cycle).Once()
754-}
755-
756-func TestTriggerVimModePulse_PulsesOnlyOverlay(t *testing.T) {
757- mockFactory := mocks.NewMockWidgetFactory(t)
758- mockOverlay := mocks.NewMockOverlayWidget(t)
759- mockBorderBox := mocks.NewMockBoxWidget(t)
760- mockWebView := mocks.NewMockWidget(t)
761- setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
762- pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
763-
764- expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
765- pv.TriggerVimModePulse()
766-}
767-
768-func TestTriggerVimModePulseFast_PulsesOnlyOverlay(t *testing.T) {
769- mockFactory := mocks.NewMockWidgetFactory(t)
770- mockOverlay := mocks.NewMockOverlayWidget(t)
771- mockBorderBox := mocks.NewMockBoxWidget(t)
772- mockWebView := mocks.NewMockWidget(t)
773- setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
774- pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
775-
776- expectVimModeOverlayPulse(mockOverlay, true, "vim-mode-pulse-cycle-a")
777- pv.TriggerVimModePulseFast()
778-}
779-
780-func TestTriggerVimModePulse_RepeatedCallsReTriggerOverlayAnimation(t *testing.T) {
781- mockFactory := mocks.NewMockWidgetFactory(t)
782- mockOverlay := mocks.NewMockOverlayWidget(t)
783- mockBorderBox := mocks.NewMockBoxWidget(t)
784- mockWebView := mocks.NewMockWidget(t)
785- setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
786- pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
787-
788- expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
789- pv.TriggerVimModePulse()
790- expectVimModeOverlayPulse(mockOverlay, true, "vim-mode-pulse-cycle-b")
791- pv.TriggerVimModePulseFast()
792- expectVimModeOverlayPulse(mockOverlay, false, "vim-mode-pulse-cycle-a")
793- pv.TriggerVimModePulse()
794-}
795-
796 func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) {739 func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) {
797 mockFactory := mocks.NewMockWidgetFactory(t)740 mockFactory := mocks.NewMockWidgetFactory(t)
798 mockOverlay := mocks.NewMockOverlayWidget(t)741 mockOverlay := mocks.NewMockOverlayWidget(t)
@@ -801,7 +744,6 @@ func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) {
801 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)744 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
802 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)745 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
803 746
804- mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once()
805 pv.SetVimMode(true)747 pv.SetVimMode(true)
806 mockOverlay.EXPECT().RemoveOverlay(mock.Anything).Once()748 mockOverlay.EXPECT().RemoveOverlay(mock.Anything).Once()
807 mockOverlay.EXPECT().SetChild(nil).Once()749 mockOverlay.EXPECT().SetChild(nil).Once()
M internal/ui/component/workspace_view.go +16 -2
@@ -53,6 +53,7 @@ type WorkspaceView struct {
5353 onActivePaneChanged func(paneID entity.PaneID)
5454 onWebViewAttached func(paneID entity.PaneID)
5555 onSplitRatioDragged func(nodeID string, ratio float64)
56+ onRebuilt func()
5657
5758 // Hover suppression for keyboard navigation (Issue #89)
5859 // Prevents hover focus from overriding keyboard-initiated focus changes
@@ -194,13 +195,19 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
194195 }
195196
196197 // SetWorkspace sets the workspace to render and builds the widget tree.
197-func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) error {
198+func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) (buildErr error) {
198199 if ws == nil {
199200 return ErrNilWorkspace
200201 }
201202
202203 wv.mu.Lock()
203- defer wv.mu.Unlock()
204+ defer func() {
205+ callback := wv.onRebuilt
206+ wv.mu.Unlock()
207+ if buildErr == nil && callback != nil {
208+ callback()
209+ }
210+ }()
204211
205212 // Release previous views before dropping their ownership. This also detaches
206213 // hover controllers and callback closures before their widgets are rebuilt.
@@ -452,6 +459,13 @@ func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio flo
452459 wv.onSplitRatioDragged = fn
453460 }
454461
462+// SetOnRebuilt notifies visual overlays when their target widget was replaced.
463+func (wv *WorkspaceView) SetOnRebuilt(fn func()) {
464+ wv.mu.Lock()
465+ defer wv.mu.Unlock()
466+ wv.onRebuilt = fn
467+}
468+
455469 // Rebuild rebuilds the widget tree from the current workspace.
456470 // Use this after structural changes like splits or closes.
457471 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
@@ -53,6 +53,7 @@ type WorkspaceView struct {
53 onActivePaneChanged func(paneID entity.PaneID)53 onActivePaneChanged func(paneID entity.PaneID)
54 onWebViewAttached func(paneID entity.PaneID)54 onWebViewAttached func(paneID entity.PaneID)
55 onSplitRatioDragged func(nodeID string, ratio float64)55 onSplitRatioDragged func(nodeID string, ratio float64)
56+ onRebuilt func()
56 57
57 // Hover suppression for keyboard navigation (Issue #89)58 // Hover suppression for keyboard navigation (Issue #89)
58 // Prevents hover focus from overriding keyboard-initiated focus changes59 // Prevents hover focus from overriding keyboard-initiated focus changes
@@ -194,13 +195,19 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
194 }195 }
195 196
196 // SetWorkspace sets the workspace to render and builds the widget tree.197 // SetWorkspace sets the workspace to render and builds the widget tree.
197-func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) error {198+func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) (buildErr error) {
198 if ws == nil {199 if ws == nil {
199 return ErrNilWorkspace200 return ErrNilWorkspace
200 }201 }
201 202
202 wv.mu.Lock()203 wv.mu.Lock()
203- defer wv.mu.Unlock()204+ defer func() {
205+ callback := wv.onRebuilt
206+ wv.mu.Unlock()
207+ if buildErr == nil && callback != nil {
208+ callback()
209+ }
210+ }()
204 211
205 // Release previous views before dropping their ownership. This also detaches212 // Release previous views before dropping their ownership. This also detaches
206 // hover controllers and callback closures before their widgets are rebuilt.213 // hover controllers and callback closures before their widgets are rebuilt.
@@ -452,6 +459,13 @@ func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio flo
452 wv.onSplitRatioDragged = fn459 wv.onSplitRatioDragged = fn
453 }460 }
454 461
462+// SetOnRebuilt notifies visual overlays when their target widget was replaced.
463+func (wv *WorkspaceView) SetOnRebuilt(fn func()) {
464+ wv.mu.Lock()
465+ defer wv.mu.Unlock()
466+ wv.onRebuilt = fn
467+}
468+
455 // Rebuild rebuilds the widget tree from the current workspace.469 // Rebuild rebuilds the widget tree from the current workspace.
456 // Use this after structural changes like splits or closes.470 // Use this after structural changes like splits or closes.
457 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {471 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
D internal/ui/focus/borders.go +0 -98
@@ -1,98 +0,0 @@
1-package focus
2-
3-import (
4- "context"
5-
6- "github.com/bnema/dumber/internal/logging"
7- "github.com/bnema/dumber/internal/ui/input"
8- "github.com/bnema/dumber/internal/ui/layout"
9-)
10-
11-// CSS classes for mode borders.
12-const (
13- paneModeClass = "pane-mode-active"
14- tabModeClass = "tab-mode-active"
15- sessionModeClass = "session-mode-active"
16-)
17-
18-// BorderManager manages mode indicator borders on the workspace container.
19-// It creates a transparent overlay box that displays colored borders when
20-// the user enters tab mode or pane mode.
21-type BorderManager struct {
22- borderOverlay layout.BoxWidget
23- currentMode input.Mode
24-}
25-
26-// NewBorderManager creates a border manager.
27-// It creates a border overlay widget that should be added to the workspace container.
28-func NewBorderManager(factory layout.WidgetFactory) *BorderManager {
29- // Create overlay box for mode borders
30- borderBox := factory.NewBox(layout.OrientationVertical, 0)
31- borderBox.SetCanFocus(false)
32- borderBox.SetCanTarget(false) // Don't intercept pointer events
33- borderBox.SetHexpand(true)
34- borderBox.SetVexpand(true)
35- borderBox.SetVisible(false) // Hidden in normal mode
36-
37- return &BorderManager{
38- borderOverlay: borderBox,
39- currentMode: input.ModeNormal,
40- }
41-}
42-
43-// Widget returns the border overlay widget for adding to workspace overlay.
44-func (bm *BorderManager) Widget() layout.Widget {
45- return bm.borderOverlay
46-}
47-
48-// OnModeChange updates border visibility based on mode.
49-func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) {
50- log := logging.FromContext(ctx)
51- log.Debug().
52- Str("from", from.String()).
53- Str("to", to.String()).
54- Msg("border manager: mode changed")
55-
56- // Remove old mode class
57- switch from {
58- case input.ModePane:
59- bm.borderOverlay.RemoveCssClass(paneModeClass)
60- case input.ModeTab:
61- bm.borderOverlay.RemoveCssClass(tabModeClass)
62- case input.ModeSession:
63- bm.borderOverlay.RemoveCssClass(sessionModeClass)
64- case input.ModeVim:
65- // Vim mode never activates the global border overlay.
66- // No class to remove.
67- }
68-
69- // Add new mode class and set visibility
70- switch to {
71- case input.ModePane:
72- bm.borderOverlay.AddCssClass(paneModeClass)
73- bm.borderOverlay.SetVisible(true)
74- case input.ModeTab:
75- bm.borderOverlay.AddCssClass(tabModeClass)
76- bm.borderOverlay.SetVisible(true)
77- case input.ModeSession:
78- bm.borderOverlay.AddCssClass(sessionModeClass)
79- bm.borderOverlay.SetVisible(true)
80- case input.ModeVim:
81- // Vim mode must not activate the workspace-wide border overlay.
82- // The pane-local vim-mode-active CSS class and indicator are
83- // managed by PaneView.SetVimMode / app handleModeChange.
84- bm.borderOverlay.SetVisible(false)
85- case input.ModeNormal:
86- bm.borderOverlay.SetVisible(false)
87- default:
88- // Resize mode is handled by per-pane stack border, not global overlay.
89- bm.borderOverlay.SetVisible(false)
90- }
91-
92- bm.currentMode = to
93-}
94-
95-// CurrentMode returns the current input mode.
96-func (bm *BorderManager) CurrentMode() input.Mode {
97- return bm.currentMode
98-}
@@ -1,98 +0,0 @@
1-package focus
2-
3-import (
4- "context"
5-
6- "github.com/bnema/dumber/internal/logging"
7- "github.com/bnema/dumber/internal/ui/input"
8- "github.com/bnema/dumber/internal/ui/layout"
9-)
10-
11-// CSS classes for mode borders.
12-const (
13- paneModeClass = "pane-mode-active"
14- tabModeClass = "tab-mode-active"
15- sessionModeClass = "session-mode-active"
16-)
17-
18-// BorderManager manages mode indicator borders on the workspace container.
19-// It creates a transparent overlay box that displays colored borders when
20-// the user enters tab mode or pane mode.
21-type BorderManager struct {
22- borderOverlay layout.BoxWidget
23- currentMode input.Mode
24-}
25-
26-// NewBorderManager creates a border manager.
27-// It creates a border overlay widget that should be added to the workspace container.
28-func NewBorderManager(factory layout.WidgetFactory) *BorderManager {
29- // Create overlay box for mode borders
30- borderBox := factory.NewBox(layout.OrientationVertical, 0)
31- borderBox.SetCanFocus(false)
32- borderBox.SetCanTarget(false) // Don't intercept pointer events
33- borderBox.SetHexpand(true)
34- borderBox.SetVexpand(true)
35- borderBox.SetVisible(false) // Hidden in normal mode
36-
37- return &BorderManager{
38- borderOverlay: borderBox,
39- currentMode: input.ModeNormal,
40- }
41-}
42-
43-// Widget returns the border overlay widget for adding to workspace overlay.
44-func (bm *BorderManager) Widget() layout.Widget {
45- return bm.borderOverlay
46-}
47-
48-// OnModeChange updates border visibility based on mode.
49-func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) {
50- log := logging.FromContext(ctx)
51- log.Debug().
52- Str("from", from.String()).
53- Str("to", to.String()).
54- Msg("border manager: mode changed")
55-
56- // Remove old mode class
57- switch from {
58- case input.ModePane:
59- bm.borderOverlay.RemoveCssClass(paneModeClass)
60- case input.ModeTab:
61- bm.borderOverlay.RemoveCssClass(tabModeClass)
62- case input.ModeSession:
63- bm.borderOverlay.RemoveCssClass(sessionModeClass)
64- case input.ModeVim:
65- // Vim mode never activates the global border overlay.
66- // No class to remove.
67- }
68-
69- // Add new mode class and set visibility
70- switch to {
71- case input.ModePane:
72- bm.borderOverlay.AddCssClass(paneModeClass)
73- bm.borderOverlay.SetVisible(true)
74- case input.ModeTab:
75- bm.borderOverlay.AddCssClass(tabModeClass)
76- bm.borderOverlay.SetVisible(true)
77- case input.ModeSession:
78- bm.borderOverlay.AddCssClass(sessionModeClass)
79- bm.borderOverlay.SetVisible(true)
80- case input.ModeVim:
81- // Vim mode must not activate the workspace-wide border overlay.
82- // The pane-local vim-mode-active CSS class and indicator are
83- // managed by PaneView.SetVimMode / app handleModeChange.
84- bm.borderOverlay.SetVisible(false)
85- case input.ModeNormal:
86- bm.borderOverlay.SetVisible(false)
87- default:
88- // Resize mode is handled by per-pane stack border, not global overlay.
89- bm.borderOverlay.SetVisible(false)
90- }
91-
92- bm.currentMode = to
93-}
94-
95-// CurrentMode returns the current input mode.
96-func (bm *BorderManager) CurrentMode() input.Mode {
97- return bm.currentMode
98-}
A internal/ui/mode_frame.go +638 -0
@@ -0,0 +1,638 @@
1+package ui
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/ui/input"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gobject"
12+ "github.com/bnema/puregotk/v4/graphene"
13+ "github.com/bnema/puregotk/v4/gtk"
14+)
15+
16+const (
17+ legendColumnSpacing = 8
18+ legendMaxWidth = 700
19+ legendCompactWidth = 460
20+ legendTwoColumnWidth = 650
21+ legendInset = 16
22+ legendHeaderSpace = 80
23+ legendRefreshMs = 100
24+)
25+
26+// modeFrame owns the per-window mode border and its non-interactive legend.
27+// GTK access is restricted to the main thread, as with the rest of the UI.
28+type modeFrame struct {
29+ mode input.Mode
30+ tabID entity.TabID
31+ target layout.Widget
32+ frameClass string
33+ root *gtk.Box
34+ border *gtk.Box
35+ borderStyle cssClassTarget // border's CSS classes; a seam for tests
36+ borderRect func() (x, y, width, height int, ok bool)
37+ panel *gtk.Box
38+ content *gtk.FlowBox
39+ scroller *gtk.ScrolledWindow
40+ heading *gtk.Label
41+ rows map[string][]*gtk.Widget
42+ keycaps map[string][]*gtk.Widget
43+ pending string
44+ visible bool
45+ style string
46+ modeClass string
47+ config entity.WorkspaceStylingConfig
48+ showTimer uint
49+ flashTimer uint
50+ generation uint64
51+ placement func() (x, y, width, height int, ok bool)
52+ refreshSource uint
53+ lastColumns uint
54+ lastPanelWidth int
55+ lastMaxHeight int
56+ overlay *gtk.Overlay
57+ window *gtk.ApplicationWindow
58+ pressInFlight bool
59+ pressIdle uint
60+ positionHandlerID uint
61+ pulseCycle bool
62+ lingering bool
63+ pointerX, pointerY float64
64+ pointerInside bool
65+ motionController *gtk.EventControllerMotion
66+ keyController *gtk.EventControllerKey
67+ clickController *gtk.GestureClick
68+ motionCb func(gtk.EventControllerMotion, float64, float64)
69+ enterCb func(gtk.EventControllerMotion, float64, float64)
70+ leaveCb func(gtk.EventControllerMotion)
71+ keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
72+ clickCb func(gtk.GestureClick, int, float64, float64)
73+}
74+
75+func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
76+ if overlay == nil || window == nil {
77+ return nil
78+ }
79+ f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
80+ f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
81+ f.root.SetHalign(gtk.AlignStartValue)
82+ f.root.SetValign(gtk.AlignStartValue)
83+ f.root.SetCanFocus(false)
84+ f.root.SetCanTarget(false)
85+ f.root.SetVisible(false)
86+ f.root.AddCssClass("mode-legend-anchor")
87+ f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
88+ f.panel.SetCanFocus(false)
89+ f.panel.SetCanTarget(false)
90+ f.panel.AddCssClass("omnibox-container")
91+ f.panel.AddCssClass("mode-legend-panel")
92+ f.root.Append(&f.panel.Widget)
93+ title := ""
94+ f.heading = gtk.NewLabel(&title)
95+ f.heading.SetCanTarget(false)
96+ f.heading.SetCanFocus(false)
97+ f.heading.SetHalign(gtk.AlignStartValue)
98+ f.heading.AddCssClass("mode-legend-title")
99+ f.panel.Append(&f.heading.Widget)
100+ f.initLegendContent()
101+ // The border is its own overlay: inset shadows on pane containers are
102+ // painted below web content and would be invisible.
103+ f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
104+ f.border.SetCanFocus(false)
105+ f.border.SetCanTarget(false)
106+ f.border.SetVisible(false)
107+ f.border.AddCssClass("mode-frame-border")
108+ f.borderStyle = f.border
109+ overlay.AddOverlay(&f.border.Widget)
110+ overlay.SetClipOverlay(&f.border.Widget, false)
111+ overlay.SetMeasureOverlay(&f.border.Widget, false)
112+ overlay.AddOverlay(&f.root.Widget)
113+ overlay.SetClipOverlay(&f.root.Widget, false)
114+ overlay.SetMeasureOverlay(&f.root.Widget, false)
115+ // A single positioned overlay tracks the active pane without reparenting it.
116+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
117+ placement := f.placement
118+ if f.border != nil && widgetPtr == f.border.GoPointer() {
119+ placement = f.borderRect
120+ } else if widgetPtr != f.root.GoPointer() {
121+ return false
122+ }
123+ if placement == nil {
124+ return false
125+ }
126+ x, y, w, h, ok := placement()
127+ if !ok {
128+ return false
129+ }
130+ return writeOverlayAllocation(allocation, x, y, w, h)
131+ }
132+ f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
133+ f.installDismissControllers()
134+ return f
135+}
136+
137+func (f *modeFrame) installDismissControllers() {
138+ overlay := f.overlay
139+ window := f.window
140+ f.motionController = gtk.NewEventControllerMotion()
141+ f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
142+ f.pointerX, f.pointerY, f.pointerInside = x, y, true
143+ }
144+ f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
145+ f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
146+ f.pointerX, f.pointerY, f.pointerInside = x, y, true
147+ }
148+ f.motionController.ConnectEnter(&f.enterCb)
149+ f.motionController.ConnectMotion(&f.motionCb)
150+ f.motionController.ConnectLeave(&f.leaveCb)
151+ overlay.AddController(&f.motionController.EventController)
152+ f.keyController = gtk.NewEventControllerKey()
153+ f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
154+ f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
155+ f.dismissLinger()
156+ return false
157+ }
158+ f.keyController.ConnectKeyPressed(&f.keyCb)
159+ window.AddController(&f.keyController.EventController)
160+ f.clickController = gtk.NewGestureClick()
161+ f.clickController.SetButton(0)
162+ f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
163+ f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
164+ f.dismissLinger()
165+ f.pressInFlight = true
166+ if f.pressIdle != 0 {
167+ glib.SourceRemove(f.pressIdle)
168+ }
169+ cb := glib.SourceFunc(func(_ uintptr) bool {
170+ f.pressInFlight = false
171+ f.pressIdle = 0
172+ return false
173+ })
174+ f.pressIdle = glib.IdleAdd(&cb, 0)
175+ }
176+ f.clickController.ConnectPressed(&f.clickCb)
177+ window.AddController(&f.clickController.EventController)
178+}
179+
180+func (f *modeFrame) initLegendContent() {
181+ f.content = gtk.NewFlowBox()
182+ f.content.SetCanFocus(false)
183+ f.content.SetCanTarget(false)
184+ f.content.SetOrientation(gtk.OrientationHorizontalValue)
185+ f.content.SetSelectionMode(gtk.SelectionNoneValue)
186+ f.content.SetMinChildrenPerLine(1)
187+ f.content.SetMaxChildrenPerLine(3)
188+ f.content.SetColumnSpacing(legendColumnSpacing)
189+ f.content.SetRowSpacing(4)
190+ f.content.AddCssClass("mode-legend-content")
191+ f.scroller = gtk.NewScrolledWindow()
192+ f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
193+ f.scroller.SetPropagateNaturalHeight(true)
194+ f.scroller.SetHasFrame(false)
195+ f.scroller.SetCanFocus(false)
196+ f.scroller.SetCanTarget(false)
197+ f.scroller.SetChild(&f.content.Widget)
198+ f.panel.Append(&f.scroller.Widget)
199+}
200+
201+func (f *modeFrame) destroy() {
202+ if f == nil {
203+ return
204+ }
205+ f.cancelTimers()
206+ f.dismissLinger()
207+ f.stopGeometryRefresh()
208+ if f.pressIdle != 0 {
209+ glib.SourceRemove(f.pressIdle)
210+ f.pressIdle = 0
211+ }
212+ if f.overlay != nil {
213+ f.overlay.RemoveController(&f.motionController.EventController)
214+ }
215+ if f.window != nil {
216+ f.window.RemoveController(&f.keyController.EventController)
217+ f.window.RemoveController(&f.clickController.EventController)
218+ }
219+ f.setTarget(nil, "")
220+ if f.overlay != nil && f.positionHandlerID != 0 {
221+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
222+ f.positionHandlerID = 0
223+ }
224+ f.placement = nil
225+ f.borderRect = nil
226+ if f.overlay != nil && f.root != nil {
227+ f.overlay.RemoveOverlay(&f.root.Widget)
228+ }
229+ if f.overlay != nil && f.border != nil {
230+ f.overlay.RemoveOverlay(&f.border.Widget)
231+ }
232+ f.overlay = nil
233+ f.window = nil
234+ f.root = nil
235+ f.border = nil
236+ f.borderStyle = nil
237+}
238+
239+type cssClassTarget interface {
240+ AddCssClass(string)
241+ RemoveCssClass(string)
242+}
243+
244+func (f *modeFrame) setTarget(target layout.Widget, class string) {
245+ if f == nil {
246+ return
247+ }
248+ if target == f.target && class == f.frameClass {
249+ return
250+ }
251+ if f.borderStyle != nil && f.frameClass != "" {
252+ f.borderStyle.RemoveCssClass(f.frameClass)
253+ for _, pulseClass := range vimPulseClasses {
254+ f.borderStyle.RemoveCssClass(pulseClass)
255+ }
256+ }
257+ f.target, f.frameClass = target, class
258+ if f.borderStyle != nil && target != nil && class != "" {
259+ f.borderStyle.AddCssClass(class)
260+ }
261+ if f.border == nil {
262+ return
263+ }
264+ if target != nil && class != "" {
265+ f.startGeometryRefresh()
266+ return
267+ }
268+ f.stopGeometryRefresh()
269+ f.borderRect = nil
270+ f.border.SetVisible(false)
271+}
272+
273+var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
274+
275+func (f *modeFrame) pulse(fast bool) {
276+ if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
277+ return
278+ }
279+ for _, class := range vimPulseClasses {
280+ f.borderStyle.RemoveCssClass(class)
281+ }
282+ class := "vim-mode-pulse"
283+ if fast {
284+ class = "vim-mode-pulse-fast"
285+ }
286+ f.borderStyle.AddCssClass(class)
287+ cycle := "vim-mode-pulse-cycle-a"
288+ if f.pulseCycle {
289+ cycle = "vim-mode-pulse-cycle-b"
290+ }
291+ f.pulseCycle = !f.pulseCycle
292+ f.borderStyle.AddCssClass(cycle)
293+}
294+
295+func modeFrameClass(mode input.Mode) string {
296+ switch mode {
297+ case input.ModePane:
298+ return "pane-mode-active"
299+ case input.ModeResize:
300+ return "resize-mode-active"
301+ case input.ModeVim:
302+ return "vim-mode-active"
303+ case input.ModeTab:
304+ return "tab-mode-active"
305+ case input.ModeSession:
306+ return "session-mode-active"
307+ default:
308+ return ""
309+ }
310+}
311+
312+func (f *modeFrame) setMode(
313+ mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
314+ style string, actions map[string]entity.ActionBinding,
315+) {
316+ if f == nil {
317+ return
318+ }
319+ f.cancelTimers()
320+ // Normal-mode refreshes must preserve an already lingering legend.
321+ keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
322+ linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
323+ cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
324+ if !keepLinger {
325+ f.dismissLinger()
326+ }
327+ f.generation++
328+ f.mode, f.config = mode, cfg
329+ f.pending = ""
330+ f.pulseCycle = false
331+ if linger {
332+ f.lingering = true
333+ f.visible = false
334+ f.clearLegendFeedback()
335+ f.panel.AddCssClass("mode-legend-lingering")
336+ } else if !keepLinger {
337+ f.hide()
338+ }
339+ f.setTarget(target, modeFrameClass(mode))
340+ f.setLegendStyle(style, mode, linger || keepLinger)
341+ if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
342+ if !linger && !keepLinger {
343+ f.content.RemoveAll()
344+ }
345+ f.rows = nil
346+ f.keycaps = nil
347+ return
348+ }
349+ f.build(actions)
350+ if cfg.ModeLegendAnimations {
351+ f.panel.RemoveCssClass("mode-legend-no-motion")
352+ } else {
353+ f.panel.AddCssClass("mode-legend-no-motion")
354+ }
355+ if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
356+ generation := f.generation
357+ cb := glib.SourceFunc(func(_ uintptr) bool {
358+ f.showTimer = 0
359+ if generation == f.generation {
360+ f.show()
361+ }
362+ return false
363+ })
364+ f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
365+ return
366+ }
367+ f.show()
368+}
369+
370+func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
371+ if f.style != style {
372+ if f.style != "" {
373+ f.panel.RemoveCssClass("omnibox-style-" + f.style)
374+ }
375+ f.style = style
376+ if style != "" {
377+ f.panel.AddCssClass("omnibox-style-" + style)
378+ }
379+ }
380+ if f.modeClass != "" && !preserveModeClass {
381+ f.panel.RemoveCssClass(f.modeClass)
382+ }
383+ if !preserveModeClass {
384+ f.modeClass = "mode-legend-" + mode.String()
385+ f.panel.AddCssClass(f.modeClass)
386+ }
387+}
388+
389+func (f *modeFrame) clearLegendFeedback() {
390+ for _, rows := range f.rows {
391+ for _, row := range rows {
392+ row.RemoveCssClass("mode-legend-dim")
393+ }
394+ }
395+ for _, caps := range f.keycaps {
396+ for _, cap := range caps {
397+ cap.RemoveCssClass("mode-legend-flash")
398+ }
399+ }
400+}
401+
402+func (f *modeFrame) cancelTimers() {
403+ if f == nil {
404+ return
405+ }
406+ if f.showTimer != 0 {
407+ glib.SourceRemove(f.showTimer)
408+ f.showTimer = 0
409+ }
410+ if f.flashTimer != 0 {
411+ glib.SourceRemove(f.flashTimer)
412+ f.flashTimer = 0
413+ }
414+}
415+
416+func pointInRect(x, y float64, rx, ry, width, height int) bool {
417+ return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
418+}
419+
420+func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
421+ if !enabled || !inside || placement == nil {
422+ return false
423+ }
424+ rx, ry, w, h, ok := placement()
425+ return ok && pointInRect(x, y, rx, ry, w, h)
426+}
427+
428+func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
429+ return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
430+}
431+
432+func (f *modeFrame) dismissLinger() {
433+ if f == nil || !f.lingering {
434+ return
435+ }
436+ f.lingering = false
437+ f.panel.RemoveCssClass("mode-legend-lingering")
438+ f.hide()
439+}
440+
441+func (f *modeFrame) hide() {
442+ if f == nil || f.root == nil {
443+ return
444+ }
445+ f.visible = false
446+ f.placement = nil
447+ f.resetLegendHeight()
448+ f.root.SetVisible(false)
449+}
450+
451+func (f *modeFrame) stopGeometryRefresh() {
452+ if f == nil || f.refreshSource == 0 {
453+ return
454+ }
455+ glib.SourceRemove(f.refreshSource)
456+ f.refreshSource = 0
457+}
458+
459+func (f *modeFrame) show() {
460+ if f == nil || f.mode == input.ModeNormal || f.target == nil {
461+ return
462+ }
463+ f.visible = true
464+ f.startGeometryRefresh()
465+}
466+
467+// Measure outside GTK allocation while the legend is active, including window
468+// resizes and splitter drags after the initial layout has settled.
469+func (f *modeFrame) startGeometryRefresh() {
470+ f.stopGeometryRefresh()
471+ f.refreshGeometry()
472+ cb := glib.SourceFunc(func(_ uintptr) bool {
473+ if f.target == nil || f.root == nil || f.lingering {
474+ f.refreshSource = 0
475+ return false
476+ }
477+ f.refreshGeometry()
478+ return true
479+ })
480+ f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
481+}
482+
483+func (f *modeFrame) resetLegendHeight() {
484+ if f.lastMaxHeight != 0 {
485+ f.scroller.SetSizeRequest(-1, -1)
486+ f.scroller.SetPropagateNaturalHeight(true)
487+ f.lastMaxHeight = 0
488+ }
489+}
490+
491+// clearPlacement hides both the border and the legend: the target is gone.
492+func (f *modeFrame) clearPlacement() {
493+ if f.borderRect != nil {
494+ f.borderRect = nil
495+ f.border.SetVisible(false)
496+ }
497+ f.clearLegend()
498+}
499+
500+// clearLegend hides only the legend; the border keeps tracking its target.
501+func (f *modeFrame) clearLegend() {
502+ if f.placement == nil {
503+ return
504+ }
505+ f.placement = nil
506+ f.root.SetVisible(false)
507+ f.root.QueueAllocate()
508+}
509+
510+func (f *modeFrame) measureLegendHeight(panelWidth int) int {
511+ maxHeight := f.overlay.GetAllocatedHeight() - legendInset
512+ if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
513+ f.resetLegendHeight()
514+ }
515+ // Measure the panel: GTK reports 0 for the root while it is still hidden.
516+ _, naturalHeight := 0, 0
517+ f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
518+ if naturalHeight > maxHeight && maxHeight > 0 {
519+ if f.lastMaxHeight != maxHeight {
520+ f.scroller.SetPropagateNaturalHeight(false)
521+ f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
522+ f.lastMaxHeight = maxHeight
523+ }
524+ return maxHeight
525+ }
526+ return naturalHeight
527+}
528+
529+// refreshGeometry measures the legend outside GTK's positioning callback.
530+// A narrow target falls back to the window width, keeping the legend reachable.
531+func (f *modeFrame) refreshGeometry() {
532+ if f.lingering {
533+ return
534+ }
535+ previous := f.placement
536+ if f.target == nil || f.overlay == nil {
537+ f.clearPlacement()
538+ return
539+ }
540+ overlayWidget := &f.overlay.Widget
541+ point := &graphene.Point{X: 0, Y: 0}
542+ out := &graphene.Point{}
543+ target := f.target.GtkWidget()
544+ if target == nil || !target.ComputePoint(overlayWidget, point, out) {
545+ f.clearPlacement()
546+ return
547+ }
548+ width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
549+ if width <= 0 || height <= 0 {
550+ f.clearPlacement()
551+ return
552+ }
553+ x, bottom := int(out.X), int(out.Y)+height
554+ f.placeBorder(x, int(out.Y), width, height)
555+ if !f.visible {
556+ f.clearLegend()
557+ return
558+ }
559+ // Keep every keycap visible even in a narrow split. No webview resize.
560+ panelWidth := min(width-legendInset, legendMaxWidth)
561+ columns := uint(3)
562+ if panelWidth < legendCompactWidth {
563+ panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
564+ x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
565+ columns = 1
566+ } else {
567+ if panelWidth < legendTwoColumnWidth {
568+ columns = 2
569+ }
570+ x += (width - panelWidth) / 2
571+ }
572+ if panelWidth <= 0 {
573+ f.clearLegend()
574+ return
575+ }
576+ f.sizeLegendColumns(columns, panelWidth)
577+ naturalHeight := f.measureLegendHeight(panelWidth)
578+ if naturalHeight <= 0 {
579+ f.clearLegend()
580+ return
581+ }
582+ y := bottom - naturalHeight - 4
583+ if y < 0 {
584+ y = 0
585+ }
586+ f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
587+ f.root.SetVisible(true)
588+ if previous != nil {
589+ oldX, oldY, oldWidth, oldHeight, _ := previous()
590+ if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
591+ return
592+ }
593+ }
594+ f.root.QueueAllocate()
595+}
596+
597+func (f *modeFrame) placeBorder(x, y, width, height int) {
598+ if f.borderRect != nil {
599+ if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
600+ return
601+ }
602+ }
603+ f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
604+ f.border.SetVisible(true)
605+ f.border.QueueAllocate()
606+}
607+
608+func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
609+ if columns != f.lastColumns {
610+ f.content.SetMaxChildrenPerLine(columns)
611+ f.lastColumns = columns
612+ }
613+ if width != f.lastPanelWidth {
614+ f.panel.SetSizeRequest(width, -1)
615+ f.lastPanelWidth = width
616+ }
617+}
618+
619+func modeLegendGroup(mode input.Mode, name string) string {
620+ switch {
621+ case name == "confirm" || name == "cancel":
622+ return "EXIT"
623+ case strings.HasPrefix(name, "split-"):
624+ return "SPLIT"
625+ case strings.HasPrefix(name, "focus-"):
626+ return "FOCUS"
627+ case strings.HasPrefix(name, "resize-"):
628+ return "RESIZE"
629+ case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
630+ return "SCROLL"
631+ case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
632+ return "JUMP"
633+ case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"):
634+ return "SWITCH"
635+ default:
636+ return "MANAGE"
637+ }
638+}
@@ -0,0 +1,638 @@
1+package ui
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/ui/input"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/bnema/puregotk/v4/gdk"
10+ "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gobject"
12+ "github.com/bnema/puregotk/v4/graphene"
13+ "github.com/bnema/puregotk/v4/gtk"
14+)
15+
16+const (
17+ legendColumnSpacing = 8
18+ legendMaxWidth = 700
19+ legendCompactWidth = 460
20+ legendTwoColumnWidth = 650
21+ legendInset = 16
22+ legendHeaderSpace = 80
23+ legendRefreshMs = 100
24+)
25+
26+// modeFrame owns the per-window mode border and its non-interactive legend.
27+// GTK access is restricted to the main thread, as with the rest of the UI.
28+type modeFrame struct {
29+ mode input.Mode
30+ tabID entity.TabID
31+ target layout.Widget
32+ frameClass string
33+ root *gtk.Box
34+ border *gtk.Box
35+ borderStyle cssClassTarget // border's CSS classes; a seam for tests
36+ borderRect func() (x, y, width, height int, ok bool)
37+ panel *gtk.Box
38+ content *gtk.FlowBox
39+ scroller *gtk.ScrolledWindow
40+ heading *gtk.Label
41+ rows map[string][]*gtk.Widget
42+ keycaps map[string][]*gtk.Widget
43+ pending string
44+ visible bool
45+ style string
46+ modeClass string
47+ config entity.WorkspaceStylingConfig
48+ showTimer uint
49+ flashTimer uint
50+ generation uint64
51+ placement func() (x, y, width, height int, ok bool)
52+ refreshSource uint
53+ lastColumns uint
54+ lastPanelWidth int
55+ lastMaxHeight int
56+ overlay *gtk.Overlay
57+ window *gtk.ApplicationWindow
58+ pressInFlight bool
59+ pressIdle uint
60+ positionHandlerID uint
61+ pulseCycle bool
62+ lingering bool
63+ pointerX, pointerY float64
64+ pointerInside bool
65+ motionController *gtk.EventControllerMotion
66+ keyController *gtk.EventControllerKey
67+ clickController *gtk.GestureClick
68+ motionCb func(gtk.EventControllerMotion, float64, float64)
69+ enterCb func(gtk.EventControllerMotion, float64, float64)
70+ leaveCb func(gtk.EventControllerMotion)
71+ keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
72+ clickCb func(gtk.GestureClick, int, float64, float64)
73+}
74+
75+func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
76+ if overlay == nil || window == nil {
77+ return nil
78+ }
79+ f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
80+ f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
81+ f.root.SetHalign(gtk.AlignStartValue)
82+ f.root.SetValign(gtk.AlignStartValue)
83+ f.root.SetCanFocus(false)
84+ f.root.SetCanTarget(false)
85+ f.root.SetVisible(false)
86+ f.root.AddCssClass("mode-legend-anchor")
87+ f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
88+ f.panel.SetCanFocus(false)
89+ f.panel.SetCanTarget(false)
90+ f.panel.AddCssClass("omnibox-container")
91+ f.panel.AddCssClass("mode-legend-panel")
92+ f.root.Append(&f.panel.Widget)
93+ title := ""
94+ f.heading = gtk.NewLabel(&title)
95+ f.heading.SetCanTarget(false)
96+ f.heading.SetCanFocus(false)
97+ f.heading.SetHalign(gtk.AlignStartValue)
98+ f.heading.AddCssClass("mode-legend-title")
99+ f.panel.Append(&f.heading.Widget)
100+ f.initLegendContent()
101+ // The border is its own overlay: inset shadows on pane containers are
102+ // painted below web content and would be invisible.
103+ f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
104+ f.border.SetCanFocus(false)
105+ f.border.SetCanTarget(false)
106+ f.border.SetVisible(false)
107+ f.border.AddCssClass("mode-frame-border")
108+ f.borderStyle = f.border
109+ overlay.AddOverlay(&f.border.Widget)
110+ overlay.SetClipOverlay(&f.border.Widget, false)
111+ overlay.SetMeasureOverlay(&f.border.Widget, false)
112+ overlay.AddOverlay(&f.root.Widget)
113+ overlay.SetClipOverlay(&f.root.Widget, false)
114+ overlay.SetMeasureOverlay(&f.root.Widget, false)
115+ // A single positioned overlay tracks the active pane without reparenting it.
116+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
117+ placement := f.placement
118+ if f.border != nil && widgetPtr == f.border.GoPointer() {
119+ placement = f.borderRect
120+ } else if widgetPtr != f.root.GoPointer() {
121+ return false
122+ }
123+ if placement == nil {
124+ return false
125+ }
126+ x, y, w, h, ok := placement()
127+ if !ok {
128+ return false
129+ }
130+ return writeOverlayAllocation(allocation, x, y, w, h)
131+ }
132+ f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
133+ f.installDismissControllers()
134+ return f
135+}
136+
137+func (f *modeFrame) installDismissControllers() {
138+ overlay := f.overlay
139+ window := f.window
140+ f.motionController = gtk.NewEventControllerMotion()
141+ f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
142+ f.pointerX, f.pointerY, f.pointerInside = x, y, true
143+ }
144+ f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
145+ f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
146+ f.pointerX, f.pointerY, f.pointerInside = x, y, true
147+ }
148+ f.motionController.ConnectEnter(&f.enterCb)
149+ f.motionController.ConnectMotion(&f.motionCb)
150+ f.motionController.ConnectLeave(&f.leaveCb)
151+ overlay.AddController(&f.motionController.EventController)
152+ f.keyController = gtk.NewEventControllerKey()
153+ f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
154+ f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
155+ f.dismissLinger()
156+ return false
157+ }
158+ f.keyController.ConnectKeyPressed(&f.keyCb)
159+ window.AddController(&f.keyController.EventController)
160+ f.clickController = gtk.NewGestureClick()
161+ f.clickController.SetButton(0)
162+ f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
163+ f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
164+ f.dismissLinger()
165+ f.pressInFlight = true
166+ if f.pressIdle != 0 {
167+ glib.SourceRemove(f.pressIdle)
168+ }
169+ cb := glib.SourceFunc(func(_ uintptr) bool {
170+ f.pressInFlight = false
171+ f.pressIdle = 0
172+ return false
173+ })
174+ f.pressIdle = glib.IdleAdd(&cb, 0)
175+ }
176+ f.clickController.ConnectPressed(&f.clickCb)
177+ window.AddController(&f.clickController.EventController)
178+}
179+
180+func (f *modeFrame) initLegendContent() {
181+ f.content = gtk.NewFlowBox()
182+ f.content.SetCanFocus(false)
183+ f.content.SetCanTarget(false)
184+ f.content.SetOrientation(gtk.OrientationHorizontalValue)
185+ f.content.SetSelectionMode(gtk.SelectionNoneValue)
186+ f.content.SetMinChildrenPerLine(1)
187+ f.content.SetMaxChildrenPerLine(3)
188+ f.content.SetColumnSpacing(legendColumnSpacing)
189+ f.content.SetRowSpacing(4)
190+ f.content.AddCssClass("mode-legend-content")
191+ f.scroller = gtk.NewScrolledWindow()
192+ f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
193+ f.scroller.SetPropagateNaturalHeight(true)
194+ f.scroller.SetHasFrame(false)
195+ f.scroller.SetCanFocus(false)
196+ f.scroller.SetCanTarget(false)
197+ f.scroller.SetChild(&f.content.Widget)
198+ f.panel.Append(&f.scroller.Widget)
199+}
200+
201+func (f *modeFrame) destroy() {
202+ if f == nil {
203+ return
204+ }
205+ f.cancelTimers()
206+ f.dismissLinger()
207+ f.stopGeometryRefresh()
208+ if f.pressIdle != 0 {
209+ glib.SourceRemove(f.pressIdle)
210+ f.pressIdle = 0
211+ }
212+ if f.overlay != nil {
213+ f.overlay.RemoveController(&f.motionController.EventController)
214+ }
215+ if f.window != nil {
216+ f.window.RemoveController(&f.keyController.EventController)
217+ f.window.RemoveController(&f.clickController.EventController)
218+ }
219+ f.setTarget(nil, "")
220+ if f.overlay != nil && f.positionHandlerID != 0 {
221+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
222+ f.positionHandlerID = 0
223+ }
224+ f.placement = nil
225+ f.borderRect = nil
226+ if f.overlay != nil && f.root != nil {
227+ f.overlay.RemoveOverlay(&f.root.Widget)
228+ }
229+ if f.overlay != nil && f.border != nil {
230+ f.overlay.RemoveOverlay(&f.border.Widget)
231+ }
232+ f.overlay = nil
233+ f.window = nil
234+ f.root = nil
235+ f.border = nil
236+ f.borderStyle = nil
237+}
238+
239+type cssClassTarget interface {
240+ AddCssClass(string)
241+ RemoveCssClass(string)
242+}
243+
244+func (f *modeFrame) setTarget(target layout.Widget, class string) {
245+ if f == nil {
246+ return
247+ }
248+ if target == f.target && class == f.frameClass {
249+ return
250+ }
251+ if f.borderStyle != nil && f.frameClass != "" {
252+ f.borderStyle.RemoveCssClass(f.frameClass)
253+ for _, pulseClass := range vimPulseClasses {
254+ f.borderStyle.RemoveCssClass(pulseClass)
255+ }
256+ }
257+ f.target, f.frameClass = target, class
258+ if f.borderStyle != nil && target != nil && class != "" {
259+ f.borderStyle.AddCssClass(class)
260+ }
261+ if f.border == nil {
262+ return
263+ }
264+ if target != nil && class != "" {
265+ f.startGeometryRefresh()
266+ return
267+ }
268+ f.stopGeometryRefresh()
269+ f.borderRect = nil
270+ f.border.SetVisible(false)
271+}
272+
273+var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
274+
275+func (f *modeFrame) pulse(fast bool) {
276+ if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
277+ return
278+ }
279+ for _, class := range vimPulseClasses {
280+ f.borderStyle.RemoveCssClass(class)
281+ }
282+ class := "vim-mode-pulse"
283+ if fast {
284+ class = "vim-mode-pulse-fast"
285+ }
286+ f.borderStyle.AddCssClass(class)
287+ cycle := "vim-mode-pulse-cycle-a"
288+ if f.pulseCycle {
289+ cycle = "vim-mode-pulse-cycle-b"
290+ }
291+ f.pulseCycle = !f.pulseCycle
292+ f.borderStyle.AddCssClass(cycle)
293+}
294+
295+func modeFrameClass(mode input.Mode) string {
296+ switch mode {
297+ case input.ModePane:
298+ return "pane-mode-active"
299+ case input.ModeResize:
300+ return "resize-mode-active"
301+ case input.ModeVim:
302+ return "vim-mode-active"
303+ case input.ModeTab:
304+ return "tab-mode-active"
305+ case input.ModeSession:
306+ return "session-mode-active"
307+ default:
308+ return ""
309+ }
310+}
311+
312+func (f *modeFrame) setMode(
313+ mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
314+ style string, actions map[string]entity.ActionBinding,
315+) {
316+ if f == nil {
317+ return
318+ }
319+ f.cancelTimers()
320+ // Normal-mode refreshes must preserve an already lingering legend.
321+ keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
322+ linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
323+ cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
324+ if !keepLinger {
325+ f.dismissLinger()
326+ }
327+ f.generation++
328+ f.mode, f.config = mode, cfg
329+ f.pending = ""
330+ f.pulseCycle = false
331+ if linger {
332+ f.lingering = true
333+ f.visible = false
334+ f.clearLegendFeedback()
335+ f.panel.AddCssClass("mode-legend-lingering")
336+ } else if !keepLinger {
337+ f.hide()
338+ }
339+ f.setTarget(target, modeFrameClass(mode))
340+ f.setLegendStyle(style, mode, linger || keepLinger)
341+ if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
342+ if !linger && !keepLinger {
343+ f.content.RemoveAll()
344+ }
345+ f.rows = nil
346+ f.keycaps = nil
347+ return
348+ }
349+ f.build(actions)
350+ if cfg.ModeLegendAnimations {
351+ f.panel.RemoveCssClass("mode-legend-no-motion")
352+ } else {
353+ f.panel.AddCssClass("mode-legend-no-motion")
354+ }
355+ if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
356+ generation := f.generation
357+ cb := glib.SourceFunc(func(_ uintptr) bool {
358+ f.showTimer = 0
359+ if generation == f.generation {
360+ f.show()
361+ }
362+ return false
363+ })
364+ f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
365+ return
366+ }
367+ f.show()
368+}
369+
370+func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
371+ if f.style != style {
372+ if f.style != "" {
373+ f.panel.RemoveCssClass("omnibox-style-" + f.style)
374+ }
375+ f.style = style
376+ if style != "" {
377+ f.panel.AddCssClass("omnibox-style-" + style)
378+ }
379+ }
380+ if f.modeClass != "" && !preserveModeClass {
381+ f.panel.RemoveCssClass(f.modeClass)
382+ }
383+ if !preserveModeClass {
384+ f.modeClass = "mode-legend-" + mode.String()
385+ f.panel.AddCssClass(f.modeClass)
386+ }
387+}
388+
389+func (f *modeFrame) clearLegendFeedback() {
390+ for _, rows := range f.rows {
391+ for _, row := range rows {
392+ row.RemoveCssClass("mode-legend-dim")
393+ }
394+ }
395+ for _, caps := range f.keycaps {
396+ for _, cap := range caps {
397+ cap.RemoveCssClass("mode-legend-flash")
398+ }
399+ }
400+}
401+
402+func (f *modeFrame) cancelTimers() {
403+ if f == nil {
404+ return
405+ }
406+ if f.showTimer != 0 {
407+ glib.SourceRemove(f.showTimer)
408+ f.showTimer = 0
409+ }
410+ if f.flashTimer != 0 {
411+ glib.SourceRemove(f.flashTimer)
412+ f.flashTimer = 0
413+ }
414+}
415+
416+func pointInRect(x, y float64, rx, ry, width, height int) bool {
417+ return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
418+}
419+
420+func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
421+ if !enabled || !inside || placement == nil {
422+ return false
423+ }
424+ rx, ry, w, h, ok := placement()
425+ return ok && pointInRect(x, y, rx, ry, w, h)
426+}
427+
428+func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
429+ return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
430+}
431+
432+func (f *modeFrame) dismissLinger() {
433+ if f == nil || !f.lingering {
434+ return
435+ }
436+ f.lingering = false
437+ f.panel.RemoveCssClass("mode-legend-lingering")
438+ f.hide()
439+}
440+
441+func (f *modeFrame) hide() {
442+ if f == nil || f.root == nil {
443+ return
444+ }
445+ f.visible = false
446+ f.placement = nil
447+ f.resetLegendHeight()
448+ f.root.SetVisible(false)
449+}
450+
451+func (f *modeFrame) stopGeometryRefresh() {
452+ if f == nil || f.refreshSource == 0 {
453+ return
454+ }
455+ glib.SourceRemove(f.refreshSource)
456+ f.refreshSource = 0
457+}
458+
459+func (f *modeFrame) show() {
460+ if f == nil || f.mode == input.ModeNormal || f.target == nil {
461+ return
462+ }
463+ f.visible = true
464+ f.startGeometryRefresh()
465+}
466+
467+// Measure outside GTK allocation while the legend is active, including window
468+// resizes and splitter drags after the initial layout has settled.
469+func (f *modeFrame) startGeometryRefresh() {
470+ f.stopGeometryRefresh()
471+ f.refreshGeometry()
472+ cb := glib.SourceFunc(func(_ uintptr) bool {
473+ if f.target == nil || f.root == nil || f.lingering {
474+ f.refreshSource = 0
475+ return false
476+ }
477+ f.refreshGeometry()
478+ return true
479+ })
480+ f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
481+}
482+
483+func (f *modeFrame) resetLegendHeight() {
484+ if f.lastMaxHeight != 0 {
485+ f.scroller.SetSizeRequest(-1, -1)
486+ f.scroller.SetPropagateNaturalHeight(true)
487+ f.lastMaxHeight = 0
488+ }
489+}
490+
491+// clearPlacement hides both the border and the legend: the target is gone.
492+func (f *modeFrame) clearPlacement() {
493+ if f.borderRect != nil {
494+ f.borderRect = nil
495+ f.border.SetVisible(false)
496+ }
497+ f.clearLegend()
498+}
499+
500+// clearLegend hides only the legend; the border keeps tracking its target.
501+func (f *modeFrame) clearLegend() {
502+ if f.placement == nil {
503+ return
504+ }
505+ f.placement = nil
506+ f.root.SetVisible(false)
507+ f.root.QueueAllocate()
508+}
509+
510+func (f *modeFrame) measureLegendHeight(panelWidth int) int {
511+ maxHeight := f.overlay.GetAllocatedHeight() - legendInset
512+ if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
513+ f.resetLegendHeight()
514+ }
515+ // Measure the panel: GTK reports 0 for the root while it is still hidden.
516+ _, naturalHeight := 0, 0
517+ f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
518+ if naturalHeight > maxHeight && maxHeight > 0 {
519+ if f.lastMaxHeight != maxHeight {
520+ f.scroller.SetPropagateNaturalHeight(false)
521+ f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
522+ f.lastMaxHeight = maxHeight
523+ }
524+ return maxHeight
525+ }
526+ return naturalHeight
527+}
528+
529+// refreshGeometry measures the legend outside GTK's positioning callback.
530+// A narrow target falls back to the window width, keeping the legend reachable.
531+func (f *modeFrame) refreshGeometry() {
532+ if f.lingering {
533+ return
534+ }
535+ previous := f.placement
536+ if f.target == nil || f.overlay == nil {
537+ f.clearPlacement()
538+ return
539+ }
540+ overlayWidget := &f.overlay.Widget
541+ point := &graphene.Point{X: 0, Y: 0}
542+ out := &graphene.Point{}
543+ target := f.target.GtkWidget()
544+ if target == nil || !target.ComputePoint(overlayWidget, point, out) {
545+ f.clearPlacement()
546+ return
547+ }
548+ width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
549+ if width <= 0 || height <= 0 {
550+ f.clearPlacement()
551+ return
552+ }
553+ x, bottom := int(out.X), int(out.Y)+height
554+ f.placeBorder(x, int(out.Y), width, height)
555+ if !f.visible {
556+ f.clearLegend()
557+ return
558+ }
559+ // Keep every keycap visible even in a narrow split. No webview resize.
560+ panelWidth := min(width-legendInset, legendMaxWidth)
561+ columns := uint(3)
562+ if panelWidth < legendCompactWidth {
563+ panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
564+ x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
565+ columns = 1
566+ } else {
567+ if panelWidth < legendTwoColumnWidth {
568+ columns = 2
569+ }
570+ x += (width - panelWidth) / 2
571+ }
572+ if panelWidth <= 0 {
573+ f.clearLegend()
574+ return
575+ }
576+ f.sizeLegendColumns(columns, panelWidth)
577+ naturalHeight := f.measureLegendHeight(panelWidth)
578+ if naturalHeight <= 0 {
579+ f.clearLegend()
580+ return
581+ }
582+ y := bottom - naturalHeight - 4
583+ if y < 0 {
584+ y = 0
585+ }
586+ f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
587+ f.root.SetVisible(true)
588+ if previous != nil {
589+ oldX, oldY, oldWidth, oldHeight, _ := previous()
590+ if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
591+ return
592+ }
593+ }
594+ f.root.QueueAllocate()
595+}
596+
597+func (f *modeFrame) placeBorder(x, y, width, height int) {
598+ if f.borderRect != nil {
599+ if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
600+ return
601+ }
602+ }
603+ f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
604+ f.border.SetVisible(true)
605+ f.border.QueueAllocate()
606+}
607+
608+func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
609+ if columns != f.lastColumns {
610+ f.content.SetMaxChildrenPerLine(columns)
611+ f.lastColumns = columns
612+ }
613+ if width != f.lastPanelWidth {
614+ f.panel.SetSizeRequest(width, -1)
615+ f.lastPanelWidth = width
616+ }
617+}
618+
619+func modeLegendGroup(mode input.Mode, name string) string {
620+ switch {
621+ case name == "confirm" || name == "cancel":
622+ return "EXIT"
623+ case strings.HasPrefix(name, "split-"):
624+ return "SPLIT"
625+ case strings.HasPrefix(name, "focus-"):
626+ return "FOCUS"
627+ case strings.HasPrefix(name, "resize-"):
628+ return "RESIZE"
629+ case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
630+ return "SCROLL"
631+ case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
632+ return "JUMP"
633+ case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"):
634+ return "SWITCH"
635+ default:
636+ return "MANAGE"
637+ }
638+}
A internal/ui/mode_frame_legend.go +191 -0
@@ -0,0 +1,191 @@
1+package ui
2+
3+import (
4+ "slices"
5+ "strings"
6+ "unicode"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/input"
10+ "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gtk"
12+)
13+
14+const legendFlashDurationMs = 120
15+
16+func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session entity.SessionConfig) map[string]entity.ActionBinding {
17+ switch mode {
18+ case input.ModePane:
19+ return workspace.PaneMode.Actions
20+ case input.ModeResize:
21+ return workspace.ResizeMode.Actions
22+ case input.ModeVim:
23+ return workspace.VimMode.Actions
24+ case input.ModeTab:
25+ return workspace.TabMode.Actions
26+ case input.ModeSession:
27+ return session.SessionMode.Actions
28+ default:
29+ return nil
30+ }
31+}
32+
33+func (f *modeFrame) build(actions map[string]entity.ActionBinding) {
34+ if f.flashTimer != 0 {
35+ glib.SourceRemove(f.flashTimer)
36+ f.flashTimer = 0
37+ }
38+ f.content.RemoveAll()
39+ f.rows = make(map[string][]*gtk.Widget)
40+ f.keycaps = make(map[string][]*gtk.Widget)
41+ f.heading.SetText(f.mode.DisplayName() + " · ESC to exit")
42+ groups := make(map[string][]string)
43+ for name, binding := range actions {
44+ if len(binding.Keys) == 0 {
45+ continue
46+ }
47+ group := modeLegendGroup(f.mode, name)
48+ groups[group] = append(groups[group], name)
49+ }
50+ order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "EXIT"}
51+ index := 0
52+ for _, groupName := range order {
53+ names := groups[groupName]
54+ if len(names) == 0 {
55+ continue
56+ }
57+ slices.Sort(names)
58+ column := gtk.NewBox(gtk.OrientationVerticalValue, 0)
59+ column.SetCanFocus(false)
60+ column.SetCanTarget(false)
61+ column.AddCssClass("mode-legend-group")
62+ title := gtk.NewLabel(&groupName)
63+ title.SetCanFocus(false)
64+ title.SetCanTarget(false)
65+ title.SetHalign(gtk.AlignStartValue)
66+ title.AddCssClass("mode-legend-group-title")
67+ column.Append(&title.Widget)
68+ for _, name := range names {
69+ column.Append(&f.actionRow(name, actions[name]).Widget)
70+ }
71+ f.content.Append(&column.Widget)
72+ if child := f.content.GetChildAtIndex(index); child != nil {
73+ child.SetCanFocus(false)
74+ child.SetCanTarget(false)
75+ }
76+ index++
77+ }
78+}
79+
80+func (f *modeFrame) actionRow(name string, binding entity.ActionBinding) *gtk.Box {
81+ row := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
82+ row.SetCanFocus(false)
83+ row.SetCanTarget(false)
84+ row.AddCssClass("mode-legend-row")
85+ f.rows[name] = append(f.rows[name], &row.Widget)
86+ for _, key := range binding.Keys {
87+ displayKey := displayLegendKey(key)
88+ keycap := gtk.NewLabel(&displayKey)
89+ keycap.SetCanFocus(false)
90+ keycap.SetCanTarget(false)
91+ keycap.AddCssClass("mode-legend-keycap")
92+ row.Append(&keycap.Widget)
93+ f.keycaps[name] = append(f.keycaps[name], &keycap.Widget)
94+ }
95+ label := binding.Desc
96+ if label == "" {
97+ label = strings.ReplaceAll(name, "-", " ")
98+ }
99+ text := gtk.NewLabel(&label)
100+ text.SetCanFocus(false)
101+ text.SetCanTarget(false)
102+ text.SetHalign(gtk.AlignStartValue)
103+ text.SetEllipsize(3) // PangoEllipsizeEnd
104+ text.AddCssClass("mode-legend-description")
105+ row.Append(&text.Widget)
106+ return row
107+}
108+
109+func displayLegendKey(key string) string {
110+ switch strings.ToLower(key) {
111+ case "arrowright":
112+ return "→"
113+ case "arrowleft":
114+ return "←"
115+ case "arrowup":
116+ return "↑"
117+ case "arrowdown":
118+ return "↓"
119+ case "escape":
120+ return "esc"
121+ case "enter":
122+ return "↵"
123+ default:
124+ return key
125+ }
126+}
127+
128+func (f *modeFrame) setPending(pending string, actions map[string]entity.ActionBinding) {
129+ if f == nil || f.lingering || f.mode != input.ModeVim {
130+ return
131+ }
132+ f.pending = pending
133+ for name, rows := range f.rows {
134+ matches := legendSequenceMatches(pending, actions[name].Keys)
135+ for _, row := range rows {
136+ if matches {
137+ row.RemoveCssClass("mode-legend-dim")
138+ } else {
139+ row.AddCssClass("mode-legend-dim")
140+ }
141+ }
142+ }
143+}
144+
145+func legendSequenceMatches(pending string, keys []string) bool {
146+ sequence := strings.TrimLeftFunc(pending, unicode.IsDigit)
147+ if sequence == "" {
148+ return true
149+ }
150+ for _, key := range keys {
151+ if strings.HasPrefix(key, sequence) {
152+ return true
153+ }
154+ }
155+ return false
156+}
157+
158+func (f *modeFrame) flash(action input.Action) {
159+ if f == nil || f.lingering || !f.visible || !f.config.ModeLegendAnimations {
160+ return
161+ }
162+ name := string(action)
163+ keycaps := f.keycaps[name]
164+ if len(keycaps) == 0 {
165+ name = strings.ReplaceAll(name, "_", "-")
166+ keycaps = f.keycaps[name]
167+ }
168+ if len(keycaps) == 0 {
169+ return
170+ }
171+ if f.flashTimer != 0 {
172+ glib.SourceRemove(f.flashTimer)
173+ f.flashTimer = 0
174+ }
175+ for _, caps := range f.keycaps {
176+ for _, cap := range caps {
177+ cap.RemoveCssClass("mode-legend-flash")
178+ }
179+ }
180+ for _, cap := range keycaps {
181+ cap.AddCssClass("mode-legend-flash")
182+ }
183+ cb := glib.SourceFunc(func(_ uintptr) bool {
184+ for _, cap := range keycaps {
185+ cap.RemoveCssClass("mode-legend-flash")
186+ }
187+ f.flashTimer = 0
188+ return false
189+ })
190+ f.flashTimer = glib.TimeoutAdd(legendFlashDurationMs, &cb, 0)
191+}
@@ -0,0 +1,191 @@
1+package ui
2+
3+import (
4+ "slices"
5+ "strings"
6+ "unicode"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/input"
10+ "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gtk"
12+)
13+
14+const legendFlashDurationMs = 120
15+
16+func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session entity.SessionConfig) map[string]entity.ActionBinding {
17+ switch mode {
18+ case input.ModePane:
19+ return workspace.PaneMode.Actions
20+ case input.ModeResize:
21+ return workspace.ResizeMode.Actions
22+ case input.ModeVim:
23+ return workspace.VimMode.Actions
24+ case input.ModeTab:
25+ return workspace.TabMode.Actions
26+ case input.ModeSession:
27+ return session.SessionMode.Actions
28+ default:
29+ return nil
30+ }
31+}
32+
33+func (f *modeFrame) build(actions map[string]entity.ActionBinding) {
34+ if f.flashTimer != 0 {
35+ glib.SourceRemove(f.flashTimer)
36+ f.flashTimer = 0
37+ }
38+ f.content.RemoveAll()
39+ f.rows = make(map[string][]*gtk.Widget)
40+ f.keycaps = make(map[string][]*gtk.Widget)
41+ f.heading.SetText(f.mode.DisplayName() + " · ESC to exit")
42+ groups := make(map[string][]string)
43+ for name, binding := range actions {
44+ if len(binding.Keys) == 0 {
45+ continue
46+ }
47+ group := modeLegendGroup(f.mode, name)
48+ groups[group] = append(groups[group], name)
49+ }
50+ order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "EXIT"}
51+ index := 0
52+ for _, groupName := range order {
53+ names := groups[groupName]
54+ if len(names) == 0 {
55+ continue
56+ }
57+ slices.Sort(names)
58+ column := gtk.NewBox(gtk.OrientationVerticalValue, 0)
59+ column.SetCanFocus(false)
60+ column.SetCanTarget(false)
61+ column.AddCssClass("mode-legend-group")
62+ title := gtk.NewLabel(&groupName)
63+ title.SetCanFocus(false)
64+ title.SetCanTarget(false)
65+ title.SetHalign(gtk.AlignStartValue)
66+ title.AddCssClass("mode-legend-group-title")
67+ column.Append(&title.Widget)
68+ for _, name := range names {
69+ column.Append(&f.actionRow(name, actions[name]).Widget)
70+ }
71+ f.content.Append(&column.Widget)
72+ if child := f.content.GetChildAtIndex(index); child != nil {
73+ child.SetCanFocus(false)
74+ child.SetCanTarget(false)
75+ }
76+ index++
77+ }
78+}
79+
80+func (f *modeFrame) actionRow(name string, binding entity.ActionBinding) *gtk.Box {
81+ row := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
82+ row.SetCanFocus(false)
83+ row.SetCanTarget(false)
84+ row.AddCssClass("mode-legend-row")
85+ f.rows[name] = append(f.rows[name], &row.Widget)
86+ for _, key := range binding.Keys {
87+ displayKey := displayLegendKey(key)
88+ keycap := gtk.NewLabel(&displayKey)
89+ keycap.SetCanFocus(false)
90+ keycap.SetCanTarget(false)
91+ keycap.AddCssClass("mode-legend-keycap")
92+ row.Append(&keycap.Widget)
93+ f.keycaps[name] = append(f.keycaps[name], &keycap.Widget)
94+ }
95+ label := binding.Desc
96+ if label == "" {
97+ label = strings.ReplaceAll(name, "-", " ")
98+ }
99+ text := gtk.NewLabel(&label)
100+ text.SetCanFocus(false)
101+ text.SetCanTarget(false)
102+ text.SetHalign(gtk.AlignStartValue)
103+ text.SetEllipsize(3) // PangoEllipsizeEnd
104+ text.AddCssClass("mode-legend-description")
105+ row.Append(&text.Widget)
106+ return row
107+}
108+
109+func displayLegendKey(key string) string {
110+ switch strings.ToLower(key) {
111+ case "arrowright":
112+ return "→"
113+ case "arrowleft":
114+ return "←"
115+ case "arrowup":
116+ return "↑"
117+ case "arrowdown":
118+ return "↓"
119+ case "escape":
120+ return "esc"
121+ case "enter":
122+ return "↵"
123+ default:
124+ return key
125+ }
126+}
127+
128+func (f *modeFrame) setPending(pending string, actions map[string]entity.ActionBinding) {
129+ if f == nil || f.lingering || f.mode != input.ModeVim {
130+ return
131+ }
132+ f.pending = pending
133+ for name, rows := range f.rows {
134+ matches := legendSequenceMatches(pending, actions[name].Keys)
135+ for _, row := range rows {
136+ if matches {
137+ row.RemoveCssClass("mode-legend-dim")
138+ } else {
139+ row.AddCssClass("mode-legend-dim")
140+ }
141+ }
142+ }
143+}
144+
145+func legendSequenceMatches(pending string, keys []string) bool {
146+ sequence := strings.TrimLeftFunc(pending, unicode.IsDigit)
147+ if sequence == "" {
148+ return true
149+ }
150+ for _, key := range keys {
151+ if strings.HasPrefix(key, sequence) {
152+ return true
153+ }
154+ }
155+ return false
156+}
157+
158+func (f *modeFrame) flash(action input.Action) {
159+ if f == nil || f.lingering || !f.visible || !f.config.ModeLegendAnimations {
160+ return
161+ }
162+ name := string(action)
163+ keycaps := f.keycaps[name]
164+ if len(keycaps) == 0 {
165+ name = strings.ReplaceAll(name, "_", "-")
166+ keycaps = f.keycaps[name]
167+ }
168+ if len(keycaps) == 0 {
169+ return
170+ }
171+ if f.flashTimer != 0 {
172+ glib.SourceRemove(f.flashTimer)
173+ f.flashTimer = 0
174+ }
175+ for _, caps := range f.keycaps {
176+ for _, cap := range caps {
177+ cap.RemoveCssClass("mode-legend-flash")
178+ }
179+ }
180+ for _, cap := range keycaps {
181+ cap.AddCssClass("mode-legend-flash")
182+ }
183+ cb := glib.SourceFunc(func(_ uintptr) bool {
184+ for _, cap := range keycaps {
185+ cap.RemoveCssClass("mode-legend-flash")
186+ }
187+ f.flashTimer = 0
188+ return false
189+ })
190+ f.flashTimer = glib.TimeoutAdd(legendFlashDurationMs, &cb, 0)
191+}
A internal/ui/mode_frame_test.go +155 -0
@@ -0,0 +1,155 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/ui/input"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/bnema/dumber/internal/ui/layout/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestModeFrameTargets(t *testing.T) {
14+ f := newVimModeTestFixture(t)
15+ tests := []struct {
16+ mode input.Mode
17+ want layout.Widget
18+ }{
19+ {input.ModeVim, f.pv1A.Widget()},
20+ {input.ModePane, f.pv1A.Widget()},
21+ {input.ModeResize, f.pv1A.Widget()},
22+ {input.ModeTab, f.app.workspaceViews["tab-1"].Widget()},
23+ {input.ModeSession, f.app.workspaceViews["tab-1"].Widget()},
24+ {input.ModeNormal, nil},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.mode.String(), func(t *testing.T) {
28+ require.Equal(t, tt.want, f.app.modeFrameTarget(f.bw1, tt.mode))
29+ })
30+ }
31+ f.bw1.tabs.ActiveTabID = "tab-missing"
32+ require.Nil(t, f.app.modeFrameTarget(f.bw1, input.ModePane))
33+}
34+
35+// The mode border is drawn by the frame's own overlay, above web content.
36+// Pane widgets are only geometry targets and must never receive mode classes:
37+// an inset shadow on them is painted below the web view and is invisible.
38+func TestModeFrameSetTargetStylesFrameBorderNotPane(t *testing.T) {
39+ first := mocks.NewMockWidget(t) // no expectations: must stay unstyled
40+ second := mocks.NewMockWidget(t) // no expectations: must stay unstyled
41+ border := mocks.NewMockWidget(t)
42+ f := &modeFrame{borderStyle: border}
43+ border.EXPECT().AddCssClass("pane-mode-active").Once()
44+ f.setTarget(first, "pane-mode-active")
45+ border.EXPECT().RemoveCssClass("pane-mode-active").Once()
46+ for _, class := range vimPulseClasses {
47+ border.EXPECT().RemoveCssClass(class).Times(2)
48+ }
49+ border.EXPECT().AddCssClass("resize-mode-active").Once()
50+ f.setTarget(second, "resize-mode-active")
51+ border.EXPECT().RemoveCssClass("resize-mode-active").Once()
52+ f.setTarget(nil, "")
53+}
54+
55+func TestModeFrameVimPulseAlternatesOnBorder(t *testing.T) {
56+ pane := mocks.NewMockWidget(t) // no expectations: must stay unstyled
57+ target := mocks.NewMockWidget(t)
58+ f := &modeFrame{mode: input.ModeVim, target: pane, frameClass: "vim-mode-active", borderStyle: target}
59+ for _, pulse := range []struct {
60+ fast bool
61+ class string
62+ cycle string
63+ }{
64+ {false, "vim-mode-pulse", "vim-mode-pulse-cycle-a"},
65+ {true, "vim-mode-pulse-fast", "vim-mode-pulse-cycle-b"},
66+ } {
67+ for _, class := range []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"} {
68+ target.EXPECT().RemoveCssClass(class).Once()
69+ }
70+ target.EXPECT().AddCssClass(pulse.class).Once()
71+ target.EXPECT().AddCssClass(pulse.cycle).Once()
72+ f.pulse(pulse.fast)
73+ }
74+ target.EXPECT().RemoveCssClass("vim-mode-active").Once()
75+ for _, class := range []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"} {
76+ target.EXPECT().RemoveCssClass(class).Once()
77+ }
78+ f.setTarget(nil, "")
79+}
80+
81+func TestModeLegendActionsUseActiveConfiguration(t *testing.T) {
82+ workspace := entity.WorkspaceConfig{
83+ PaneMode: entity.PaneModeConfig{Actions: map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}}},
84+ ResizeMode: entity.ResizeModeConfig{Actions: map[string]entity.ActionBinding{"resize-increase": {Keys: []string{"+"}}}},
85+ VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"focus-input": {Keys: []string{"gi"}}}},
86+ TabMode: entity.TabModeConfig{Actions: map[string]entity.ActionBinding{"next-tab": {Keys: []string{"l"}}}},
87+ }
88+ session := entity.SessionConfig{SessionMode: entity.SessionModeConfig{Actions: map[string]entity.ActionBinding{
89+ "session-manager": {Keys: []string{"s"}},
90+ }}}
91+ for _, tc := range []struct {
92+ mode input.Mode
93+ class string
94+ actions map[string]entity.ActionBinding
95+ }{
96+ {input.ModePane, "pane-mode-active", workspace.PaneMode.Actions},
97+ {input.ModeResize, "resize-mode-active", workspace.ResizeMode.Actions},
98+ {input.ModeVim, "vim-mode-active", workspace.VimMode.Actions},
99+ {input.ModeTab, "tab-mode-active", workspace.TabMode.Actions},
100+ {input.ModeSession, "session-mode-active", session.SessionMode.Actions},
101+ } {
102+ t.Run(tc.mode.String(), func(t *testing.T) {
103+ require.Equal(t, tc.actions, legendActions(tc.mode, workspace, session))
104+ require.Equal(t, tc.class, modeFrameClass(tc.mode))
105+ })
106+ }
107+ require.Nil(t, legendActions(input.ModeNormal, workspace, session))
108+ require.Empty(t, modeFrameClass(input.ModeNormal))
109+ require.Equal(t, "→", displayLegendKey("arrowright"))
110+ require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right"))
111+ require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next"))
112+}
113+
114+func TestModeFramePendingSequenceFiltersBindings(t *testing.T) {
115+ require.True(t, legendSequenceMatches("3]", []string{"]]"}))
116+ require.False(t, legendSequenceMatches("3]", []string{"gi"}))
117+ require.True(t, legendSequenceMatches("3", []string{"gi"}))
118+ require.True(t, legendSequenceMatches("", []string{"gi"}))
119+}
120+
121+func TestModeFrameTargetDoesNotDependOnGlobalFocusedWindow(t *testing.T) {
122+ f := newVimModeTestFixture(t)
123+ f.app.lastFocusedWindowID = f.bw2.id
124+ require.Equal(t, f.pv1A.Widget(), f.app.modeFrameTarget(f.bw1, input.ModeVim))
125+ require.Equal(t, f.pv2A.Widget(), f.app.modeFrameTarget(f.bw2, input.ModeVim))
126+}
127+
128+func TestShouldLinger(t *testing.T) {
129+ rect := func() (int, int, int, int, bool) { return 10, 20, 30, 40, true }
130+ for _, tt := range []struct {
131+ name string
132+ enabled, inside bool
133+ x, y float64
134+ placement func() (int, int, int, int, bool)
135+ want bool
136+ }{
137+ {"inside", true, true, 10, 20, rect, true},
138+ {"edge outside", true, true, 40, 20, rect, false},
139+ {"outside", true, true, 5, 25, rect, false},
140+ {"disabled", false, true, 15, 25, rect, false},
141+ {"left window", true, false, 15, 25, rect, false},
142+ {"nil placement", true, true, 15, 25, nil, false},
143+ } {
144+ t.Run(tt.name, func(t *testing.T) {
145+ require.Equal(t, tt.want, shouldLinger(tt.enabled, tt.inside, tt.x, tt.y, tt.placement))
146+ })
147+ }
148+}
149+
150+func TestShouldStartLinger(t *testing.T) {
151+ rect := func() (int, int, int, int, bool) { return 10, 20, 30, 40, true }
152+ require.True(t, shouldStartLinger(true, false, true, true, 15, 25, rect))
153+ require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))
154+ require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
155+}
@@ -0,0 +1,155 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/ui/input"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/bnema/dumber/internal/ui/layout/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestModeFrameTargets(t *testing.T) {
14+ f := newVimModeTestFixture(t)
15+ tests := []struct {
16+ mode input.Mode
17+ want layout.Widget
18+ }{
19+ {input.ModeVim, f.pv1A.Widget()},
20+ {input.ModePane, f.pv1A.Widget()},
21+ {input.ModeResize, f.pv1A.Widget()},
22+ {input.ModeTab, f.app.workspaceViews["tab-1"].Widget()},
23+ {input.ModeSession, f.app.workspaceViews["tab-1"].Widget()},
24+ {input.ModeNormal, nil},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.mode.String(), func(t *testing.T) {
28+ require.Equal(t, tt.want, f.app.modeFrameTarget(f.bw1, tt.mode))
29+ })
30+ }
31+ f.bw1.tabs.ActiveTabID = "tab-missing"
32+ require.Nil(t, f.app.modeFrameTarget(f.bw1, input.ModePane))
33+}
34+
35+// The mode border is drawn by the frame's own overlay, above web content.
36+// Pane widgets are only geometry targets and must never receive mode classes:
37+// an inset shadow on them is painted below the web view and is invisible.
38+func TestModeFrameSetTargetStylesFrameBorderNotPane(t *testing.T) {
39+ first := mocks.NewMockWidget(t) // no expectations: must stay unstyled
40+ second := mocks.NewMockWidget(t) // no expectations: must stay unstyled
41+ border := mocks.NewMockWidget(t)
42+ f := &modeFrame{borderStyle: border}
43+ border.EXPECT().AddCssClass("pane-mode-active").Once()
44+ f.setTarget(first, "pane-mode-active")
45+ border.EXPECT().RemoveCssClass("pane-mode-active").Once()
46+ for _, class := range vimPulseClasses {
47+ border.EXPECT().RemoveCssClass(class).Times(2)
48+ }
49+ border.EXPECT().AddCssClass("resize-mode-active").Once()
50+ f.setTarget(second, "resize-mode-active")
51+ border.EXPECT().RemoveCssClass("resize-mode-active").Once()
52+ f.setTarget(nil, "")
53+}
54+
55+func TestModeFrameVimPulseAlternatesOnBorder(t *testing.T) {
56+ pane := mocks.NewMockWidget(t) // no expectations: must stay unstyled
57+ target := mocks.NewMockWidget(t)
58+ f := &modeFrame{mode: input.ModeVim, target: pane, frameClass: "vim-mode-active", borderStyle: target}
59+ for _, pulse := range []struct {
60+ fast bool
61+ class string
62+ cycle string
63+ }{
64+ {false, "vim-mode-pulse", "vim-mode-pulse-cycle-a"},
65+ {true, "vim-mode-pulse-fast", "vim-mode-pulse-cycle-b"},
66+ } {
67+ for _, class := range []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"} {
68+ target.EXPECT().RemoveCssClass(class).Once()
69+ }
70+ target.EXPECT().AddCssClass(pulse.class).Once()
71+ target.EXPECT().AddCssClass(pulse.cycle).Once()
72+ f.pulse(pulse.fast)
73+ }
74+ target.EXPECT().RemoveCssClass("vim-mode-active").Once()
75+ for _, class := range []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"} {
76+ target.EXPECT().RemoveCssClass(class).Once()
77+ }
78+ f.setTarget(nil, "")
79+}
80+
81+func TestModeLegendActionsUseActiveConfiguration(t *testing.T) {
82+ workspace := entity.WorkspaceConfig{
83+ PaneMode: entity.PaneModeConfig{Actions: map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}}},
84+ ResizeMode: entity.ResizeModeConfig{Actions: map[string]entity.ActionBinding{"resize-increase": {Keys: []string{"+"}}}},
85+ VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"focus-input": {Keys: []string{"gi"}}}},
86+ TabMode: entity.TabModeConfig{Actions: map[string]entity.ActionBinding{"next-tab": {Keys: []string{"l"}}}},
87+ }
88+ session := entity.SessionConfig{SessionMode: entity.SessionModeConfig{Actions: map[string]entity.ActionBinding{
89+ "session-manager": {Keys: []string{"s"}},
90+ }}}
91+ for _, tc := range []struct {
92+ mode input.Mode
93+ class string
94+ actions map[string]entity.ActionBinding
95+ }{
96+ {input.ModePane, "pane-mode-active", workspace.PaneMode.Actions},
97+ {input.ModeResize, "resize-mode-active", workspace.ResizeMode.Actions},
98+ {input.ModeVim, "vim-mode-active", workspace.VimMode.Actions},
99+ {input.ModeTab, "tab-mode-active", workspace.TabMode.Actions},
100+ {input.ModeSession, "session-mode-active", session.SessionMode.Actions},
101+ } {
102+ t.Run(tc.mode.String(), func(t *testing.T) {
103+ require.Equal(t, tc.actions, legendActions(tc.mode, workspace, session))
104+ require.Equal(t, tc.class, modeFrameClass(tc.mode))
105+ })
106+ }
107+ require.Nil(t, legendActions(input.ModeNormal, workspace, session))
108+ require.Empty(t, modeFrameClass(input.ModeNormal))
109+ require.Equal(t, "→", displayLegendKey("arrowright"))
110+ require.Equal(t, "SPLIT", modeLegendGroup(input.ModePane, "split-right"))
111+ require.Equal(t, "JUMP", modeLegendGroup(input.ModeVim, "heading-next"))
112+}
113+
114+func TestModeFramePendingSequenceFiltersBindings(t *testing.T) {
115+ require.True(t, legendSequenceMatches("3]", []string{"]]"}))
116+ require.False(t, legendSequenceMatches("3]", []string{"gi"}))
117+ require.True(t, legendSequenceMatches("3", []string{"gi"}))
118+ require.True(t, legendSequenceMatches("", []string{"gi"}))
119+}
120+
121+func TestModeFrameTargetDoesNotDependOnGlobalFocusedWindow(t *testing.T) {
122+ f := newVimModeTestFixture(t)
123+ f.app.lastFocusedWindowID = f.bw2.id
124+ require.Equal(t, f.pv1A.Widget(), f.app.modeFrameTarget(f.bw1, input.ModeVim))
125+ require.Equal(t, f.pv2A.Widget(), f.app.modeFrameTarget(f.bw2, input.ModeVim))
126+}
127+
128+func TestShouldLinger(t *testing.T) {
129+ rect := func() (int, int, int, int, bool) { return 10, 20, 30, 40, true }
130+ for _, tt := range []struct {
131+ name string
132+ enabled, inside bool
133+ x, y float64
134+ placement func() (int, int, int, int, bool)
135+ want bool
136+ }{
137+ {"inside", true, true, 10, 20, rect, true},
138+ {"edge outside", true, true, 40, 20, rect, false},
139+ {"outside", true, true, 5, 25, rect, false},
140+ {"disabled", false, true, 15, 25, rect, false},
141+ {"left window", true, false, 15, 25, rect, false},
142+ {"nil placement", true, true, 15, 25, nil, false},
143+ } {
144+ t.Run(tt.name, func(t *testing.T) {
145+ require.Equal(t, tt.want, shouldLinger(tt.enabled, tt.inside, tt.x, tt.y, tt.placement))
146+ })
147+ }
148+}
149+
150+func TestShouldStartLinger(t *testing.T) {
151+ rect := func() (int, int, int, int, bool) { return 10, 20, 30, 40, true }
152+ require.True(t, shouldStartLinger(true, false, true, true, 15, 25, rect))
153+ require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))
154+ require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
155+}
M internal/ui/theme/css.go +29 -38
@@ -95,8 +95,9 @@ func GenerateCSSFullWithTiming(p Palette, _ float64, fonts FontConfig, modeColor
9595 sb.WriteString(generateTabBarCSS(p))
9696 sb.WriteString("\n")
9797
98- // Omnibox styling
98+ // Omnibox and modal legend styling
9999 sb.WriteString(generateOmniboxCSS(p))
100+ sb.WriteString(generateModeLegendCSS())
100101 sb.WriteString("\n")
101102
102103 // Find bar styling
@@ -805,7 +806,7 @@ entry.find-bar-entry:focus-visible {
805806 // generatePaneCSS creates pane border styles.
806807 // Uses em units for scalable UI.
807808 // generateVimModeCSS creates the pane-local Vim Mode accent and pulse styling.
808-// It reuses --pane-mode-color (from workspace.styling.pane_mode_color).
809+// It uses --vim-mode-color (from workspace.styling.vim_mode_color).
809810 func generateVimModeCSS(transitionDurationMs int) string {
810811 if transitionDurationMs <= 0 {
811812 transitionDurationMs = defaultTransitionDurationMs
@@ -814,39 +815,37 @@ func generateVimModeCSS(transitionDurationMs int) string {
814815 fastPulseMs := transitionDurationMs * 6
815816 return fmt.Sprintf(`/* ===== Vim Mode Styling ===== */
816817
817-/* Vim mode active — subtle local border accent on the pane overlay.
818- Uses the existing pane mode color token via CSS variable.
819- This is independent of the workspace-level pane-mode border overlay. */
818+/* Vim mode frame on the active pane; pulses use this same widget. */
820819 .vim-mode-active {
821- box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35);
820+ box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35);
822821 border-radius: 0;
823822 }
824823
825-/* Pane overlay pulse keyframes — start/end at the active accent state so the
826- pane keeps its Vim mode border while briefly flaring brighter. The separate
824+/* Frame pulse keyframes — start/end at the active accent state so the
825+ frame keeps its Vim mode border while briefly flaring brighter. The separate
827826 -anim-a and -anim-b pairs are required to restart repeated pulse animations. */
828827 @keyframes vim-mode-overlay-pulse-anim-a {
829- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
830- 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); }
831- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
828+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
829+ 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--vim-mode-color), 0.78); }
830+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
832831 }
833832
834833 @keyframes vim-mode-overlay-pulse-anim-b {
835- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
836- 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); }
837- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
834+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
835+ 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--vim-mode-color), 0.78); }
836+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
838837 }
839838
840839 @keyframes vim-mode-overlay-pulse-fast-anim-a {
841- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
842- 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); }
843- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
840+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
841+ 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--vim-mode-color), 0.95); }
842+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
844843 }
845844
846845 @keyframes vim-mode-overlay-pulse-fast-anim-b {
847- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
848- 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); }
849- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }
846+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
847+ 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--vim-mode-color), 0.95); }
848+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
850849 }
851850
852851 /* Pane overlay scroll pulse — derived from transition_duration. */
@@ -921,28 +920,22 @@ func generatePaneCSS(p Palette) string {
921920 border-color: transparent;
922921 }
923922
924-/* Pane mode active - thick inset border (for overlay) */
925-.pane-mode-active {
923+/* Mode frame: a click-through overlay drawn above web content. */
924+.mode-frame-border {
926925 background-color: transparent;
927- box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
928926 border-radius: 0;
929927 }
930928
931-/* Tab mode active - thick inset border (for overlay) */
932-.tab-mode-active {
933- background-color: transparent;
929+.mode-frame-border.pane-mode-active {
930+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
931+}
932+
933+.mode-frame-border.tab-mode-active {
934934 box-shadow: inset 0 0 0 0.25em var(--tab-mode-color);
935- border-radius: 0;
936935 }
937936
938-/* Resize mode active - thick inset border */
939-.resize-mode-active {
940- background-color: transparent;
941- /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
937+.mode-frame-border.resize-mode-active {
942938 box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
943- outline: 0.25em solid var(--resize-mode-color);
944- outline-offset: -0.25em;
945- border-radius: 0;
946939 }
947940 `
948941 }
@@ -1302,10 +1295,8 @@ row:selected .session-manager-row {
13021295 }
13031296
13041297 /* Session mode border */
1305-.session-mode-active {
1306- background-color: transparent;
1298+.mode-frame-border.session-mode-active {
13071299 box-shadow: inset 0 0 0 0.25em var(--session-mode-color);
1308- border-radius: 0;
13091300 }
13101301 `
13111302 }
@@ -1422,7 +1413,7 @@ func generateToasterCSS(p Palette) string {
14221413 }
14231414
14241415 .toast-vim-mode {
1425- background-color: var(--pane-mode-color);
1416+ background-color: var(--vim-mode-color);
14261417 color: #ffffff;
14271418 }
14281419
@@ -95,8 +95,9 @@ func GenerateCSSFullWithTiming(p Palette, _ float64, fonts FontConfig, modeColor
95 sb.WriteString(generateTabBarCSS(p))95 sb.WriteString(generateTabBarCSS(p))
96 sb.WriteString("\n")96 sb.WriteString("\n")
97 97
98- // Omnibox styling98+ // Omnibox and modal legend styling
99 sb.WriteString(generateOmniboxCSS(p))99 sb.WriteString(generateOmniboxCSS(p))
100+ sb.WriteString(generateModeLegendCSS())
100 sb.WriteString("\n")101 sb.WriteString("\n")
101 102
102 // Find bar styling103 // Find bar styling
@@ -805,7 +806,7 @@ entry.find-bar-entry:focus-visible {
805 // generatePaneCSS creates pane border styles.806 // generatePaneCSS creates pane border styles.
806 // Uses em units for scalable UI.807 // Uses em units for scalable UI.
807 // generateVimModeCSS creates the pane-local Vim Mode accent and pulse styling.808 // generateVimModeCSS creates the pane-local Vim Mode accent and pulse styling.
808-// It reuses --pane-mode-color (from workspace.styling.pane_mode_color).809+// It uses --vim-mode-color (from workspace.styling.vim_mode_color).
809 func generateVimModeCSS(transitionDurationMs int) string {810 func generateVimModeCSS(transitionDurationMs int) string {
810 if transitionDurationMs <= 0 {811 if transitionDurationMs <= 0 {
811 transitionDurationMs = defaultTransitionDurationMs812 transitionDurationMs = defaultTransitionDurationMs
@@ -814,39 +815,37 @@ func generateVimModeCSS(transitionDurationMs int) string {
814 fastPulseMs := transitionDurationMs * 6815 fastPulseMs := transitionDurationMs * 6
815 return fmt.Sprintf(`/* ===== Vim Mode Styling ===== */816 return fmt.Sprintf(`/* ===== Vim Mode Styling ===== */
816 817
817-/* Vim mode active — subtle local border accent on the pane overlay.818+/* Vim mode frame on the active pane; pulses use this same widget. */
818- Uses the existing pane mode color token via CSS variable.
819- This is independent of the workspace-level pane-mode border overlay. */
820 .vim-mode-active {819 .vim-mode-active {
821- box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35);820+ box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35);
822 border-radius: 0;821 border-radius: 0;
823 }822 }
824 823
825-/* Pane overlay pulse keyframes — start/end at the active accent state so the824+/* Frame pulse keyframes — start/end at the active accent state so the
826- pane keeps its Vim mode border while briefly flaring brighter. The separate825+ frame keeps its Vim mode border while briefly flaring brighter. The separate
827 -anim-a and -anim-b pairs are required to restart repeated pulse animations. */826 -anim-a and -anim-b pairs are required to restart repeated pulse animations. */
828 @keyframes vim-mode-overlay-pulse-anim-a {827 @keyframes vim-mode-overlay-pulse-anim-a {
829- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }828+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
830- 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); }829+ 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--vim-mode-color), 0.78); }
831- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }830+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
832 }831 }
833 832
834 @keyframes vim-mode-overlay-pulse-anim-b {833 @keyframes vim-mode-overlay-pulse-anim-b {
835- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }834+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
836- 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--pane-mode-color), 0.78); }835+ 35%% { box-shadow: inset 0 0 0 0.2em alpha(var(--vim-mode-color), 0.78); }
837- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }836+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
838 }837 }
839 838
840 @keyframes vim-mode-overlay-pulse-fast-anim-a {839 @keyframes vim-mode-overlay-pulse-fast-anim-a {
841- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }840+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
842- 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); }841+ 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--vim-mode-color), 0.95); }
843- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }842+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
844 }843 }
845 844
846 @keyframes vim-mode-overlay-pulse-fast-anim-b {845 @keyframes vim-mode-overlay-pulse-fast-anim-b {
847- 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }846+ 0%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
848- 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--pane-mode-color), 0.95); }847+ 25%% { box-shadow: inset 0 0 0 0.26em alpha(var(--vim-mode-color), 0.95); }
849- 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--pane-mode-color), 0.35); }848+ 100%% { box-shadow: inset 0 0 0 0.125em alpha(var(--vim-mode-color), 0.35); }
850 }849 }
851 850
852 /* Pane overlay scroll pulse — derived from transition_duration. */851 /* Pane overlay scroll pulse — derived from transition_duration. */
@@ -921,28 +920,22 @@ func generatePaneCSS(p Palette) string {
921 border-color: transparent;920 border-color: transparent;
922 }921 }
923 922
924-/* Pane mode active - thick inset border (for overlay) */923+/* Mode frame: a click-through overlay drawn above web content. */
925-.pane-mode-active {924+.mode-frame-border {
926 background-color: transparent;925 background-color: transparent;
927- box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
928 border-radius: 0;926 border-radius: 0;
929 }927 }
930 928
931-/* Tab mode active - thick inset border (for overlay) */929+.mode-frame-border.pane-mode-active {
932-.tab-mode-active {930+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
933- background-color: transparent;931+}
932+
933+.mode-frame-border.tab-mode-active {
934 box-shadow: inset 0 0 0 0.25em var(--tab-mode-color);934 box-shadow: inset 0 0 0 0.25em var(--tab-mode-color);
935- border-radius: 0;
936 }935 }
937 936
938-/* Resize mode active - thick inset border */937+.mode-frame-border.resize-mode-active {
939-.resize-mode-active {
940- background-color: transparent;
941- /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
942 box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);938 box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
943- outline: 0.25em solid var(--resize-mode-color);
944- outline-offset: -0.25em;
945- border-radius: 0;
946 }939 }
947 `940 `
948 }941 }
@@ -1302,10 +1295,8 @@ row:selected .session-manager-row {
1302 }1295 }
1303 1296
1304 /* Session mode border */1297 /* Session mode border */
1305-.session-mode-active {1298+.mode-frame-border.session-mode-active {
1306- background-color: transparent;
1307 box-shadow: inset 0 0 0 0.25em var(--session-mode-color);1299 box-shadow: inset 0 0 0 0.25em var(--session-mode-color);
1308- border-radius: 0;
1309 }1300 }
1310 `1301 `
1311 }1302 }
@@ -1422,7 +1413,7 @@ func generateToasterCSS(p Palette) string {
1422 }1413 }
1423 1414
1424 .toast-vim-mode {1415 .toast-vim-mode {
1425- background-color: var(--pane-mode-color);1416+ background-color: var(--vim-mode-color);
1426 color: #ffffff;1417 color: #ffffff;
1427 }1418 }
1428 1419
M internal/ui/theme/css_test.go +13 -0
@@ -414,3 +414,16 @@ func TestGenerateCSS_SessionContainerHasNoMinWidth(t *testing.T) {
414414 assert.NotContains(t, block, "min-width:")
415415 assert.Contains(t, block, "background-color: var(--surface-variant);")
416416 }
417+
418+func TestVimModeUsesOwnColor(t *testing.T) {
419+ css := generateVimModeCSS(120)
420+ if strings.Contains(css, "--pane-mode-color") || !strings.Contains(css, "--vim-mode-color") {
421+ t.Fatalf("Vim mode CSS must use Vim color")
422+ }
423+ if !strings.Contains(generateModeLegendCSS(), "var(--vim-mode-color)") {
424+ t.Fatal("legend missing Vim color")
425+ }
426+ if !strings.Contains(DefaultModeColors().ToCSSVars(), "--vim-mode-color: #F7768E") {
427+ t.Fatal("palette missing Vim color")
428+ }
429+}
@@ -414,3 +414,16 @@ func TestGenerateCSS_SessionContainerHasNoMinWidth(t *testing.T) {
414 assert.NotContains(t, block, "min-width:")414 assert.NotContains(t, block, "min-width:")
415 assert.Contains(t, block, "background-color: var(--surface-variant);")415 assert.Contains(t, block, "background-color: var(--surface-variant);")
416 }416 }
417+
418+func TestVimModeUsesOwnColor(t *testing.T) {
419+ css := generateVimModeCSS(120)
420+ if strings.Contains(css, "--pane-mode-color") || !strings.Contains(css, "--vim-mode-color") {
421+ t.Fatalf("Vim mode CSS must use Vim color")
422+ }
423+ if !strings.Contains(generateModeLegendCSS(), "var(--vim-mode-color)") {
424+ t.Fatal("legend missing Vim color")
425+ }
426+ if !strings.Contains(DefaultModeColors().ToCSSVars(), "--vim-mode-color: #F7768E") {
427+ t.Fatal("palette missing Vim color")
428+ }
429+}
M internal/ui/theme/manager_test.go +2 -0
@@ -145,12 +145,14 @@ func TestModeColorsFromEntity(t *testing.T) {
145145 TabMode: "#222222",
146146 SessionMode: "#333333",
147147 ResizeMode: "#444444",
148+ VimMode: "#abcdef",
148149 })
149150
150151 assert.Equal(t, "#111111", modeColors.PaneMode)
151152 assert.Equal(t, "#222222", modeColors.TabMode)
152153 assert.Equal(t, "#333333", modeColors.SessionMode)
153154 assert.Equal(t, "#444444", modeColors.ResizeMode)
155+ assert.Equal(t, "#abcdef", modeColors.VimMode)
154156 }
155157
156158 func TestFontConfigFromEntity(t *testing.T) {
@@ -145,12 +145,14 @@ func TestModeColorsFromEntity(t *testing.T) {
145 TabMode: "#222222",145 TabMode: "#222222",
146 SessionMode: "#333333",146 SessionMode: "#333333",
147 ResizeMode: "#444444",147 ResizeMode: "#444444",
148+ VimMode: "#abcdef",
148 })149 })
149 150
150 assert.Equal(t, "#111111", modeColors.PaneMode)151 assert.Equal(t, "#111111", modeColors.PaneMode)
151 assert.Equal(t, "#222222", modeColors.TabMode)152 assert.Equal(t, "#222222", modeColors.TabMode)
152 assert.Equal(t, "#333333", modeColors.SessionMode)153 assert.Equal(t, "#333333", modeColors.SessionMode)
153 assert.Equal(t, "#444444", modeColors.ResizeMode)154 assert.Equal(t, "#444444", modeColors.ResizeMode)
155+ assert.Equal(t, "#abcdef", modeColors.VimMode)
154 }156 }
155 157
156 func TestFontConfigFromEntity(t *testing.T) {158 func TestFontConfigFromEntity(t *testing.T) {
A internal/ui/theme/mode_legend_css.go +76 -0
@@ -0,0 +1,76 @@
1+package theme
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// modeLegendColors maps legend mode classes to the palette variable of their mode.
9+var modeLegendColors = []struct{ mode, color string }{
10+ {"pane", "--pane-mode-color"},
11+ {"vim", "--vim-mode-color"},
12+ {"resize", "--resize-mode-color"},
13+ {"tab", "--tab-mode-color"},
14+ {"session", "--session-mode-color"},
15+}
16+
17+func generateModeLegendCSS() string {
18+ var sb strings.Builder
19+ sb.WriteString(`/* Mode frame legend: omnibox surface, tinted with the active mode color. */
20+.mode-legend-anchor { background-color: transparent; }
21+.omnibox-container.mode-legend-panel {
22+ border-style: solid;
23+ border-width: 0.125em;
24+ padding: 0 0 0.375em 0;
25+ min-width: 0;
26+ animation: mode-legend-appear 120ms ease-out;
27+}
28+.omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; }
29+.omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; }
30+.mode-legend-title {
31+ font-family: var(--font-mono);
32+ font-size: 0.8125em;
33+ font-weight: bold;
34+ color: #ffffff;
35+ padding: 0.375em 0.75em;
36+ margin-bottom: 0.375em;
37+}
38+.mode-legend-content { padding: 0 0.25em; }
39+.mode-legend-group { margin: 0 0.375em; min-width: 0; }
40+.mode-legend-group-title {
41+ font-family: var(--font-mono);
42+ font-size: 0.6875em;
43+ font-weight: bold;
44+ margin-bottom: 0.25em;
45+}
46+.mode-legend-row { padding: 0.125em 0; min-width: 0; }
47+.mode-legend-keycap {
48+ font-family: var(--font-mono);
49+ font-size: 0.75em;
50+ font-weight: bold;
51+ color: var(--text);
52+ background-color: var(--surface-variant);
53+ border-style: solid;
54+ border-width: 0.0625em;
55+ border-radius: 0.25em;
56+ padding: 0.125em 0.375em;
57+ margin-right: 0.375em;
58+ transition: background-color 120ms ease-out, color 120ms ease-out;
59+}
60+.mode-legend-description { font-size: 0.75em; color: var(--muted); }
61+.mode-legend-row.mode-legend-dim { opacity: 0.3; }
62+@keyframes mode-legend-appear {
63+ from { opacity: 0; }
64+ to { opacity: 1; }
65+}
66+`)
67+ for _, m := range modeLegendColors {
68+ fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); }
69+.mode-legend-%[1]s .mode-legend-title { background-color: var(%[2]s); }
70+.mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); }
71+.mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); }
72+.mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: #ffffff; }
73+`, m.mode, m.color)
74+ }
75+ return sb.String()
76+}
@@ -0,0 +1,76 @@
1+package theme
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// modeLegendColors maps legend mode classes to the palette variable of their mode.
9+var modeLegendColors = []struct{ mode, color string }{
10+ {"pane", "--pane-mode-color"},
11+ {"vim", "--vim-mode-color"},
12+ {"resize", "--resize-mode-color"},
13+ {"tab", "--tab-mode-color"},
14+ {"session", "--session-mode-color"},
15+}
16+
17+func generateModeLegendCSS() string {
18+ var sb strings.Builder
19+ sb.WriteString(`/* Mode frame legend: omnibox surface, tinted with the active mode color. */
20+.mode-legend-anchor { background-color: transparent; }
21+.omnibox-container.mode-legend-panel {
22+ border-style: solid;
23+ border-width: 0.125em;
24+ padding: 0 0 0.375em 0;
25+ min-width: 0;
26+ animation: mode-legend-appear 120ms ease-out;
27+}
28+.omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; }
29+.omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; }
30+.mode-legend-title {
31+ font-family: var(--font-mono);
32+ font-size: 0.8125em;
33+ font-weight: bold;
34+ color: #ffffff;
35+ padding: 0.375em 0.75em;
36+ margin-bottom: 0.375em;
37+}
38+.mode-legend-content { padding: 0 0.25em; }
39+.mode-legend-group { margin: 0 0.375em; min-width: 0; }
40+.mode-legend-group-title {
41+ font-family: var(--font-mono);
42+ font-size: 0.6875em;
43+ font-weight: bold;
44+ margin-bottom: 0.25em;
45+}
46+.mode-legend-row { padding: 0.125em 0; min-width: 0; }
47+.mode-legend-keycap {
48+ font-family: var(--font-mono);
49+ font-size: 0.75em;
50+ font-weight: bold;
51+ color: var(--text);
52+ background-color: var(--surface-variant);
53+ border-style: solid;
54+ border-width: 0.0625em;
55+ border-radius: 0.25em;
56+ padding: 0.125em 0.375em;
57+ margin-right: 0.375em;
58+ transition: background-color 120ms ease-out, color 120ms ease-out;
59+}
60+.mode-legend-description { font-size: 0.75em; color: var(--muted); }
61+.mode-legend-row.mode-legend-dim { opacity: 0.3; }
62+@keyframes mode-legend-appear {
63+ from { opacity: 0; }
64+ to { opacity: 1; }
65+}
66+`)
67+ for _, m := range modeLegendColors {
68+ fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); }
69+.mode-legend-%[1]s .mode-legend-title { background-color: var(%[2]s); }
70+.mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); }
71+.mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); }
72+.mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: #ffffff; }
73+`, m.mode, m.color)
74+ }
75+ return sb.String()
76+}
M internal/ui/theme/palette.go +4 -0
@@ -27,6 +27,7 @@ type Palette struct {
2727
2828 // ModeColors holds colors for modal mode indicators (borders and toasters).
2929 type ModeColors struct {
30+ VimMode string // Color for Vim mode (default: #F7768E)
3031 PaneMode string // Color for pane mode (default: #4A90E2 blue)
3132 TabMode string // Color for tab mode (default: #FFA500 orange)
3233 SessionMode string // Color for session mode (default: #9B59B6 purple)
@@ -37,6 +38,7 @@ type ModeColors struct {
3738 func DefaultModeColors() ModeColors {
3839 return ModeColors{
3940 PaneMode: "#4A90E2",
41+ VimMode: "#F7768E",
4042 TabMode: "#FFA500",
4143 SessionMode: "#9B59B6",
4244 ResizeMode: "#00D4AA",
@@ -47,6 +49,7 @@ func DefaultModeColors() ModeColors {
4749 func (m ModeColors) ToCSSVars() string {
4850 var sb strings.Builder
4951 sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")
52+ sb.WriteString(" --vim-mode-color: " + m.VimMode + ";\n")
5053 sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")
5154 sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")
5255 sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")
@@ -139,6 +142,7 @@ func PaletteFromEntity(p entity.ColorPalette, isDark bool) Palette {
139142 func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors {
140143 return ModeColors{
141144 PaneMode: colors.PaneMode,
145+ VimMode: colors.VimMode,
142146 TabMode: colors.TabMode,
143147 SessionMode: colors.SessionMode,
144148 ResizeMode: colors.ResizeMode,
@@ -27,6 +27,7 @@ type Palette struct {
27 27
28 // ModeColors holds colors for modal mode indicators (borders and toasters).28 // ModeColors holds colors for modal mode indicators (borders and toasters).
29 type ModeColors struct {29 type ModeColors struct {
30+ VimMode string // Color for Vim mode (default: #F7768E)
30 PaneMode string // Color for pane mode (default: #4A90E2 blue)31 PaneMode string // Color for pane mode (default: #4A90E2 blue)
31 TabMode string // Color for tab mode (default: #FFA500 orange)32 TabMode string // Color for tab mode (default: #FFA500 orange)
32 SessionMode string // Color for session mode (default: #9B59B6 purple)33 SessionMode string // Color for session mode (default: #9B59B6 purple)
@@ -37,6 +38,7 @@ type ModeColors struct {
37 func DefaultModeColors() ModeColors {38 func DefaultModeColors() ModeColors {
38 return ModeColors{39 return ModeColors{
39 PaneMode: "#4A90E2",40 PaneMode: "#4A90E2",
41+ VimMode: "#F7768E",
40 TabMode: "#FFA500",42 TabMode: "#FFA500",
41 SessionMode: "#9B59B6",43 SessionMode: "#9B59B6",
42 ResizeMode: "#00D4AA",44 ResizeMode: "#00D4AA",
@@ -47,6 +49,7 @@ func DefaultModeColors() ModeColors {
47 func (m ModeColors) ToCSSVars() string {49 func (m ModeColors) ToCSSVars() string {
48 var sb strings.Builder50 var sb strings.Builder
49 sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")51 sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")
52+ sb.WriteString(" --vim-mode-color: " + m.VimMode + ";\n")
50 sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")53 sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")
51 sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")54 sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")
52 sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")55 sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")
@@ -139,6 +142,7 @@ func PaletteFromEntity(p entity.ColorPalette, isDark bool) Palette {
139 func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors {142 func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors {
140 return ModeColors{143 return ModeColors{
141 PaneMode: colors.PaneMode,144 PaneMode: colors.PaneMode,
145+ VimMode: colors.VimMode,
142 TabMode: colors.TabMode,146 TabMode: colors.TabMode,
143 SessionMode: colors.SessionMode,147 SessionMode: colors.SessionMode,
144 ResizeMode: colors.ResizeMode,148 ResizeMode: colors.ResizeMode,

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