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 Unverified
B5690EEEBB952194The key is unknown
33 files · +1464 -455
M docs/config/index.md +16 -6
| @@ -482,7 +482,7 @@ Notes: | |||
| 482 | 482 | - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused. | |
| 483 | 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 | 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 | 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 | 488 | ### Resize Mode | |
| @@ -615,13 +615,18 @@ desc = "Open floating pane on GitHub" | |||
| 615 | 615 | |-----|------|---------|-------------| | |
| 616 | 616 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | |
| 617 | 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 | | |
| 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) | | |
| 620 | 620 | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | | |
| 621 | 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 | 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.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 | | |
| 625 | 630 | ||
| 626 | 631 | **Example:** | |
| 627 | 632 | ||
| @@ -630,11 +635,16 @@ desc = "Open floating pane on GitHub" | |||
| 630 | 635 | border_width = 1 | |
| 631 | 636 | border_color = "@theme_selected_bg_color" | |
| 632 | 637 | 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 | |
| 634 | 639 | tab_mode_color = "#FFA500" # Orange for tab mode | |
| 635 | 640 | session_mode_color = "#9B59B6" # Purple for session mode | |
| 641 | + | vim_mode_color = "#F7768E" # Vim mode | |
| 636 | 642 | resize_mode_color = "#00D4AA" # Teal for resize mode | |
| 637 | 643 | 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 | 648 | transition_duration = 120 | |
| 639 | 649 | ``` | |
| 640 | 650 | ||
| @@ -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 Mode | 488 | ### 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 = 1 | 635 | border_width = 1 | ||
| 631 | border_color = "@theme_selected_bg_color" | 636 | border_color = "@theme_selected_bg_color" | ||
| 632 | mode_border_width = 4 | 637 | 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 |
| 634 | tab_mode_color = "#FFA500" # Orange for tab mode | 639 | tab_mode_color = "#FFA500" # Orange for tab mode | ||
| 635 | session_mode_color = "#9B59B6" # Purple for session mode | 640 | session_mode_color = "#9B59B6" # Purple for session mode | ||
| 641 | + | vim_mode_color = "#F7768E" # Vim mode | |||
| 636 | resize_mode_color = "#00D4AA" # Teal for resize mode | 642 | resize_mode_color = "#00D4AA" # Teal for resize mode | ||
| 637 | mode_indicator_toaster_enabled = true | 643 | 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 = 120 | 648 | transition_duration = 120 | ||
| 639 | ``` | 649 | ``` | ||
| 640 | 650 | ||||
M docs/reference/configuration.md +7 -2
| @@ -133,12 +133,17 @@ | |||
| 133 | 133 | | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | | | |
| 134 | 134 | | `workspace.styling.border_width` | int | `1` | | | |
| 135 | 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 | 137 | | `workspace.styling.pane_mode_color` | string | `#4A90E2` | | | |
| 138 | 138 | | `workspace.styling.tab_mode_color` | string | `#FFA500` | | | |
| 139 | 139 | | `workspace.styling.session_mode_color` | string | `#9B59B6` | | | |
| 140 | + | | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend | | |
| 140 | 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 | 147 | | `workspace.styling.transition_duration` | int | `120` | | | |
| 143 | 148 | | `session.auto_restore` | bool | `false` | | | |
| 144 | 149 | | `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 | |||
| 55 | 55 | ||
| 56 | 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 | 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 | ||
| @@ -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( | |||
| 111 | 111 | if styling != nil { | |
| 112 | 112 | modeColors := entity.ThemeModeColors{ | |
| 113 | 113 | PaneMode: styling.PaneModeColor, | |
| 114 | + | VimMode: styling.VimModeColor, | |
| 114 | 115 | TabMode: styling.TabModeColor, | |
| 115 | 116 | SessionMode: styling.SessionModeColor, | |
| 116 | 117 | 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 | |||
| 1317 | 1317 | TabModeColor: "#222222", | |
| 1318 | 1318 | SessionModeColor: "#333333", | |
| 1319 | 1319 | ResizeModeColor: "#444444", | |
| 1320 | + | VimModeColor: "#abcdef", | |
| 1320 | 1321 | } | |
| 1321 | 1322 | preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"} | |
| 1322 | 1323 | ||
| @@ -1340,7 +1341,7 @@ func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testi | |||
| 1340 | 1341 | if input.UIScale != 1.25 { | |
| 1341 | 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 | 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 | 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 | + | } | |
| @@ -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 { | |||
| 164 | 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 line | |
| 165 | 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 line | |
| 166 | 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 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 | 168 | 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 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 | |
| 170 | 175 | ||
| 171 | 176 | 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 | } | |
| @@ -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 line | 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 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 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 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 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 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 line | 168 | 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 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 | |||
| 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 line | 176 | 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 { | |||
| 40 | 40 | // All values must be CSS-safe hex colors (e.g., "#4A90E2"). | |
| 41 | 41 | type ThemeModeColors struct { | |
| 42 | 42 | PaneMode string | |
| 43 | + | VimMode string | |
| 43 | 44 | TabMode string | |
| 44 | 45 | SessionMode string | |
| 45 | 46 | ResizeMode string | |
| @@ -104,6 +105,7 @@ func DefaultThemeFonts() ThemeFonts { | |||
| 104 | 105 | func DefaultThemeModeColors() ThemeModeColors { | |
| 105 | 106 | return ThemeModeColors{ | |
| 106 | 107 | PaneMode: "#4A90E2", | |
| 108 | + | VimMode: "#F7768E", | |
| 107 | 109 | TabMode: "#FFA500", | |
| 108 | 110 | SessionMode: "#9B59B6", | |
| 109 | 111 | ResizeMode: "#00D4AA", | |
| @@ -266,6 +268,7 @@ func ValidateModeColorsHex(colors *ThemeModeColors, prefix string) []ThemeWarnin | |||
| 266 | 268 | } | |
| 267 | 269 | var warnings []ThemeWarning | |
| 268 | 270 | checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode) | |
| 271 | + | checkRequiredHex(&warnings, prefix, "vim_mode", colors.VimMode) | |
| 269 | 272 | checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode) | |
| 270 | 273 | checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode) | |
| 271 | 274 | checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode) | |
| @@ -286,6 +289,9 @@ func MergeModeColors(src *ThemeModeColors, defaults ThemeModeColors) ThemeModeCo | |||
| 286 | 289 | if result.PaneMode == "" { | |
| 287 | 290 | result.PaneMode = defaults.PaneMode | |
| 288 | 291 | } | |
| 292 | + | if result.VimMode == "" { | |
| 293 | + | result.VimMode = defaults.VimMode | |
| 294 | + | } | |
| 289 | 295 | if result.TabMode == "" { | |
| 290 | 296 | result.TabMode = defaults.TabMode | |
| 291 | 297 | } | |
| @@ -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 string | 42 | PaneMode string | ||
| 43 | + | VimMode string | |||
| 43 | TabMode string | 44 | TabMode string | ||
| 44 | SessionMode string | 45 | SessionMode string | ||
| 45 | ResizeMode string | 46 | 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 []ThemeWarning | 269 | 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.PaneMode | 290 | 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.TabMode | 296 | 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) { | |||
| 130 | 130 | TabMode: "#FFA500", | |
| 131 | 131 | SessionMode: "#9B59B6", | |
| 132 | 132 | ResizeMode: "#00D4AA", | |
| 133 | + | VimMode: "#F7768E", | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | 136 | fields := map[string]string{ | |
| @@ -137,6 +138,7 @@ func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) { | |||
| 137 | 138 | "TabMode": defaults.TabMode, | |
| 138 | 139 | "SessionMode": defaults.SessionMode, | |
| 139 | 140 | "ResizeMode": defaults.ResizeMode, | |
| 141 | + | "VimMode": defaults.VimMode, | |
| 140 | 142 | } | |
| 141 | 143 | ||
| 142 | 144 | for name, value := range fields { | |
| @@ -171,6 +173,7 @@ func TestResolvedTheme_StructFieldsExist(t *testing.T) { | |||
| 171 | 173 | TabMode: "#FFA500", | |
| 172 | 174 | SessionMode: "#9B59B6", | |
| 173 | 175 | ResizeMode: "#00D4AA", | |
| 176 | + | VimMode: "#F7768E", | |
| 174 | 177 | }, | |
| 175 | 178 | Warnings: []ThemeWarning{ | |
| 176 | 179 | {Field: "external", Message: "malformed external theme, using last-good"}, | |
| @@ -493,6 +496,9 @@ func TestMergeModeColors_FillsEmpties(t *testing.T) { | |||
| 493 | 496 | if result.PaneMode != "#111111" { | |
| 494 | 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 | 502 | if result.TabMode != def.TabMode { | |
| 497 | 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 | 511 | TabMode: "not-a-color", | |
| 506 | 512 | SessionMode: "#333333", | |
| 507 | 513 | ResizeMode: "", | |
| 514 | + | VimMode: "#F7768E", | |
| 508 | 515 | } | |
| 509 | 516 | ||
| 510 | 517 | 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 ( | |||
| 76 | 76 | defaultPaneModeColor = "#4A90E2" // Blue for pane mode | |
| 77 | 77 | defaultTabModeColor = "#FFA500" // Orange for tab mode | |
| 78 | 78 | defaultSessionModeColor = "#9B59B6" // Purple for session mode | |
| 79 | + | defaultVimModeColor = "#F7768E" | |
| 79 | 80 | defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode | |
| 80 | 81 | ||
| 81 | 82 | // Mode indicator toaster | |
| @@ -394,7 +395,12 @@ func DefaultConfig() *Config { | |||
| 394 | 395 | TabModeColor: defaultTabModeColor, | |
| 395 | 396 | SessionModeColor: defaultSessionModeColor, | |
| 396 | 397 | ResizeModeColor: defaultResizeModeColor, | |
| 398 | + | VimModeColor: defaultVimModeColor, | |
| 399 | + | ModeLegendLinger: true, | |
| 397 | 400 | ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled, | |
| 401 | + | ModeLegend: "delay", | |
| 402 | + | ModeLegendDelayMs: 500, | |
| 403 | + | ModeLegendAnimations: true, | |
| 398 | 404 | TransitionDuration: defaultTransitionDuration, | |
| 399 | 405 | }, | |
| 400 | 406 | }, | |
| @@ -76,6 +76,7 @@ const ( | |||||
| 76 | defaultPaneModeColor = "#4A90E2" // Blue for pane mode | 76 | defaultPaneModeColor = "#4A90E2" // Blue for pane mode | ||
| 77 | defaultTabModeColor = "#FFA500" // Orange for tab mode | 77 | defaultTabModeColor = "#FFA500" // Orange for tab mode | ||
| 78 | defaultSessionModeColor = "#9B59B6" // Purple for session mode | 78 | defaultSessionModeColor = "#9B59B6" // Purple for session mode | ||
| 79 | + | defaultVimModeColor = "#F7768E" | |||
| 79 | defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode | 80 | defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode | ||
| 80 | 81 | ||||
| 81 | // Mode indicator toaster | 82 | // 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) { | |||
| 15 | 15 | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | |
| 16 | 16 | assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior) | |
| 17 | 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 | 24 | // Engine defaults (replaces old Performance/Privacy sections) | |
| 20 | 25 | 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) { | |||
| 739 | 739 | m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor) | |
| 740 | 740 | m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor) | |
| 741 | 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 | 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 | 748 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 744 | 749 | } | |
| 745 | 750 | ||
| @@ -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 ( | |||
| 8 | 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 | 23 | func TestSetEngineDefaults(t *testing.T) { | |
| 12 | 24 | mgr := &Manager{viper: viper.New()} | |
| 13 | 25 | 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 | |||
| 669 | 669 | Key: "workspace.styling.mode_border_width", | |
| 670 | 670 | Type: "int", | |
| 671 | 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 | 673 | Range: ">=0", | |
| 674 | 674 | Section: SectionWorkspace, | |
| 675 | 675 | }, | |
| @@ -694,6 +694,10 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 694 | 694 | Description: "Color for session mode indicator", | |
| 695 | 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 | 702 | Key: "workspace.styling.resize_mode_color", | |
| 699 | 703 | Type: "string", | |
| @@ -705,7 +709,35 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 705 | 709 | Key: "workspace.styling.mode_indicator_toaster_enabled", | |
| 706 | 710 | Type: "bool", | |
| 707 | 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 | 741 | Section: SectionWorkspace, | |
| 710 | 742 | }, | |
| 711 | 743 | { | |
| @@ -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 ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 13 | "github.com/spf13/viper" | |
| 13 | 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 | 38 | func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) { | |
| 17 | 39 | docKeys := configurationReferenceKeys(t) | |
| 18 | 40 | ||
| @@ -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 { | |||
| 223 | 223 | if config.Workspace.Styling.TransitionDuration < 0 { | |
| 224 | 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 | 234 | return validationErrors | |
| 227 | 235 | } | |
| 228 | 236 | ||
| @@ -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 validationErrors | 234 | return validationErrors | ||
| 227 | } | 235 | } | ||
| 228 | 236 | ||||
M internal/infrastructure/config/validation_test.go +26 -0
| @@ -10,6 +10,32 @@ import ( | |||
| 10 | 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 | 39 | func TestValidateConfig_OmniboxStyle(t *testing.T) { | |
| 14 | 40 | tests := []struct { | |
| 15 | 41 | 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 string | 41 | style string | ||
M internal/ui/app.go +31 -82
| @@ -128,8 +128,6 @@ type App struct { | |||
| 128 | 128 | vimNavigationUC *usecase.VimNavigationUseCase | |
| 129 | 129 | pageEditableFocusByPane map[entity.PaneID]bool | |
| 130 | 130 | ||
| 131 | - | resizeModeBorderTarget layout.Widget | |
| 132 | - | ||
| 133 | 131 | // Omnibox configuration (omnibox is created per workspace view) | |
| 134 | 132 | omniboxCfg component.OmniboxConfig | |
| 135 | 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 | 1138 | bw.keyboardHandler = input.NewKeyboardHandler(ctx, &workspaceCfg, &sessionCfg) | |
| 1141 | 1139 | bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error { | |
| 1142 | 1140 | a.activateBrowserWindow(bw) | |
| 1141 | + | a.modeFrameAction(bw, action) | |
| 1143 | 1142 | if action == input.ActionClosePane { | |
| 1144 | 1143 | if a.closeAndReleaseActiveFloatingPane(actionCtx) { | |
| 1145 | 1144 | return nil | |
| @@ -1149,6 +1148,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1149 | 1148 | }) | |
| 1150 | 1149 | bw.keyboardHandler.SetOnVimScrollLifecycle(func(scrollCtx context.Context, action input.Action, phase input.VimScrollPhase) error { | |
| 1151 | 1150 | a.activateBrowserWindow(bw) | |
| 1151 | + | if phase == input.VimScrollContinuous { | |
| 1152 | + | a.modeFrameAction(bw, action) | |
| 1153 | + | } | |
| 1152 | 1154 | return a.kbDispatcher.DispatchVimScrollLifecycle(scrollCtx, action, phase) | |
| 1153 | 1155 | }) | |
| 1154 | 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 | 1164 | a.bindVimModeSequenceToaster(ctx, bw) | |
| 1163 | 1165 | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | |
| 1166 | + | a.modeFrameAction(bw, input.Action(strings.ReplaceAll(action, "-", "_"))) | |
| 1164 | 1167 | a.navigateVimSequenceAction(ctx, bw, action, count) | |
| 1165 | 1168 | }) | |
| 1166 | 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 | 4046 | log := logging.FromContext(ctx) | |
| 4044 | 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 | 4049 | if to == input.ModeVim && from != input.ModeVim { | |
| 4055 | 4050 | a.enableAccessibilityForVimMode(ctx, bw) | |
| 4056 | 4051 | } | |
| @@ -4062,12 +4057,7 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||
| 4062 | 4057 | // Entering Vim Mode accents the active pane; leaving removes the accent. | |
| 4063 | 4058 | a.handleVimModeOwnership(ctx, bw, to, from) | |
| 4064 | 4059 | ||
| 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) | |
| 4071 | 4061 | ||
| 4072 | 4062 | // Show/hide this window's mode indicator toaster based on mode and config. | |
| 4073 | 4063 | a.updateModeIndicatorToaster(ctx, bw, to) | |
| @@ -4191,16 +4181,8 @@ func (a *App) triggerVimModePulse(_ context.Context, fast bool) { | |||
| 4191 | 4181 | if bw == nil || bw.vimModePaneID == "" { | |
| 4192 | 4182 | return | |
| 4193 | 4183 | } | |
| 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) | |
| 4204 | 4186 | } | |
| 4205 | 4187 | } | |
| 4206 | 4188 | ||
| @@ -4243,8 +4225,6 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, | |||
| 4243 | 4225 | 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 | 4228 | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 4249 | 4229 | component.WithDuration(0), | |
| 4250 | 4230 | component.WithPosition(component.ToastPositionBottomLeft), | |
| @@ -4271,7 +4251,14 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) | |||
| 4271 | 4251 | // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster; | |
| 4272 | 4252 | // silent reset under ModalState avoids stale pending callbacks. | |
| 4273 | 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 | 4262 | return | |
| 4276 | 4263 | } | |
| 4277 | 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 | 4305 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | |
| 4362 | 4306 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |
| 4363 | 4307 | if !a.createWorkspaceViewWithoutAttach(ctx, tab) { | |
| @@ -4415,6 +4359,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 4415 | 4359 | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated") | |
| 4416 | 4360 | if bw := a.browserWindowForTab(tab.ID); bw != nil { | |
| 4417 | 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 | 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 | 4380 | // Set omnibox config for this workspace view. | |
| 4437 | 4381 | // When the owning browser window is known, bind navigation to the | |
| 4438 | 4382 | // 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 | 4394 | wsView.SetAutoOpenOnNewPane(a.runtimeConfigSnapshot().UI.Omnibox.AutoOpenOnNewPane) | |
| 4451 | 4395 | ||
| 4452 | 4396 | 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()) | |
| 4460 | 4405 | } | |
| 4461 | 4406 | }) | |
| 4462 | 4407 | ||
| @@ -4587,6 +4532,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 4587 | 4532 | // Active tab state is managed by TabList.SetActive; no per-window field needed. | |
| 4588 | 4533 | if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil { | |
| 4589 | 4534 | target.mainWindow.SetContent(gtkWidget) | |
| 4535 | + | if target.keyboardHandler != nil { | |
| 4536 | + | a.retargetModeFrame(target, target.keyboardHandler.Mode()) | |
| 4537 | + | } | |
| 4590 | 4538 | } else if a.mainWindow != nil { | |
| 4591 | 4539 | a.mainWindow.SetContent(gtkWidget) | |
| 4592 | 4540 | } | |
| @@ -5556,6 +5504,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||
| 5556 | 5504 | if bw.keyboardHandler != nil { | |
| 5557 | 5505 | mode = bw.keyboardHandler.Mode() | |
| 5558 | 5506 | } | |
| 5507 | + | a.updateModeFrame(bw, mode) | |
| 5559 | 5508 | a.updateModeIndicatorToaster(ctx, bw, mode) | |
| 5560 | 5509 | } | |
| 5561 | 5510 | } | |
| @@ -128,8 +128,6 @@ type App struct { | |||||
| 128 | vimNavigationUC *usecase.VimNavigationUseCase | 128 | vimNavigationUC *usecase.VimNavigationUseCase | ||
| 129 | pageEditableFocusByPane map[entity.PaneID]bool | 129 | 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.OmniboxConfig | 132 | 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 nil | 1144 | 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 | return | 4182 | 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 | return | 4225 | 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 | return | 4262 | 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 the | 4381 | // When the owning browser window is known, bind navigation to the | ||
| 4438 | // owning window so that the omnibox always navigates in the correct | 4382 | // 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 = paneID | 4401 | + | |
| 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 ( | |||
| 17 | 17 | "github.com/bnema/dumber/internal/domain/entity" | |
| 18 | 18 | "github.com/bnema/dumber/internal/ui/component" | |
| 19 | 19 | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 20 | - | "github.com/bnema/dumber/internal/ui/focus" | |
| 21 | 20 | "github.com/bnema/dumber/internal/ui/input" | |
| 22 | 21 | "github.com/bnema/dumber/internal/ui/layout" | |
| 23 | 22 | "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| @@ -143,20 +142,12 @@ func createTestPaneView( | |||
| 143 | 142 | } | |
| 144 | 143 | ||
| 145 | 144 | // 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) { | |
| 152 | 146 | t.Helper() | |
| 153 | - | overlay.EXPECT().AddCssClass("vim-mode-active").Once() | |
| 154 | 147 | app.handleVimModeOwnership(context.Background(), bw, input.ModeVim, input.ModeNormal) | |
| 155 | 148 | } | |
| 156 | 149 | ||
| 157 | - | func expectVimModeAccentHidden(overlay *mocks.MockOverlayWidget) { | |
| 158 | - | overlay.EXPECT().RemoveCssClass("vim-mode-active").Once() | |
| 159 | - | } | |
| 150 | + | func expectVimModeAccentHidden(_ *mocks.MockOverlayWidget) {} | |
| 160 | 151 | ||
| 161 | 152 | type accessibilityEnablingWebView struct { | |
| 162 | 153 | *portmocks.MockWebView | |
| @@ -417,7 +408,7 @@ func newSingleWindowVimModeFixture(t *testing.T) *vimModeTestFixture { | |||
| 417 | 408 | func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) { | |
| 418 | 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 | 413 | assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID, | |
| 423 | 414 | "entering vim mode sets ownership on the active pane") | |
| @@ -434,7 +425,7 @@ func TestVimMode_Enter_SetsOwnershipOnActivePane(t *testing.T) { | |||
| 434 | 425 | func TestVimMode_Enter_MarksCorrectWindow(t *testing.T) { | |
| 435 | 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 | 430 | assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID) | |
| 440 | 431 | assert.True(t, f.pv2A.IsVimMode()) | |
| @@ -630,7 +621,7 @@ func TestVimMode_Leave_ClearsOwnershipFromOwningPane(t *testing.T) { | |||
| 630 | 621 | f := newVimModeTestFixture(t) | |
| 631 | 622 | ||
| 632 | 623 | // Enter first | |
| 633 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 624 | + | enterVimMode(t, f.app, f.bw1) | |
| 634 | 625 | require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID) | |
| 635 | 626 | ||
| 636 | 627 | // Leave — SetVimMode(false) hides indicator and removes the CSS class | |
| @@ -651,7 +642,7 @@ func TestVimMode_Transfer_MovesOwnershipToNewPane(t *testing.T) { | |||
| 651 | 642 | f := newVimModeTestFixture(t) | |
| 652 | 643 | ||
| 653 | 644 | // 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 | 646 | require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID) | |
| 656 | 647 | ||
| 657 | 648 | // 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 | 652 | // pane before SetVimMode(true) on the new pane. Set up both. | |
| 662 | 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 | 655 | // Transfer ownership to pane-b | |
| 668 | 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 | 706 | // 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 | 715 | func setUpNormalPulse(overlay *mocks.MockOverlayWidget) { | |
| 722 | 716 | overlay.EXPECT().RemoveCssClass("vim-mode-pulse").Once() | |
| 723 | 717 | overlay.EXPECT().RemoveCssClass("vim-mode-pulse-fast").Once() | |
| @@ -739,25 +733,22 @@ func setUpFastPulse(overlay *mocks.MockOverlayWidget) { | |||
| 739 | 733 | func TestVimMode_Pulse_NormalTriggersOnOwningPane(t *testing.T) { | |
| 740 | 734 | f := newVimModeTestFixture(t) | |
| 741 | 735 | ||
| 742 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 736 | + | enterVimMode(t, f.app, f.bw1) | |
| 743 | 737 | require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID) | |
| 744 | 738 | ||
| 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) | |
| 748 | 740 | setUpNormalPulse(f.overlay1A) | |
| 749 | - | ||
| 750 | 741 | f.app.triggerVimModePulse(context.Background(), false) | |
| 751 | 742 | } | |
| 752 | 743 | ||
| 753 | 744 | func TestVimMode_Pulse_FastTriggersOnOwningPane(t *testing.T) { | |
| 754 | 745 | f := newVimModeTestFixture(t) | |
| 755 | 746 | ||
| 756 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 747 | + | enterVimMode(t, f.app, f.bw1) | |
| 757 | 748 | require.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID) | |
| 758 | 749 | ||
| 750 | + | attachTestModeFrame(f.bw1, f.overlay1A) | |
| 759 | 751 | setUpFastPulse(f.overlay1A) | |
| 760 | - | ||
| 761 | 752 | f.app.triggerVimModePulse(context.Background(), true) | |
| 762 | 753 | } | |
| 763 | 754 | ||
| @@ -775,8 +766,8 @@ func TestVimMode_Pulse_NoOwnerIsNoop(t *testing.T) { | |||
| 775 | 766 | func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) { | |
| 776 | 767 | f := newVimModeTestFixture(t) | |
| 777 | 768 | ||
| 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) | |
| 780 | 771 | ||
| 781 | 772 | assert.Equal(t, entity.PaneID("pane-a"), f.bw1.vimModePaneID) | |
| 782 | 773 | assert.Equal(t, entity.PaneID("pane-a"), f.bw2.vimModePaneID) | |
| @@ -796,8 +787,8 @@ func TestVimMode_MultiWindow_TwoWindowsEnterLeave(t *testing.T) { | |||
| 796 | 787 | func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) { | |
| 797 | 788 | f := newVimModeTestFixture(t) | |
| 798 | 789 | ||
| 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) | |
| 801 | 792 | ||
| 802 | 793 | f.bw1.keyboardHandler = newKeyboardHandlerInVimMode(t) | |
| 803 | 794 | ||
| @@ -805,7 +796,6 @@ func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) { | |||
| 805 | 796 | expectVimModeAccentHidden(f.overlay1A) | |
| 806 | 797 | ||
| 807 | 798 | // Transfer bw1 to pane-b. | |
| 808 | - | f.overlay1B.EXPECT().AddCssClass("vim-mode-active").Once() | |
| 809 | 799 | f.app.transferVimModeOwnershipToPane(context.Background(), f.bw1, entity.PaneID("pane-b")) | |
| 810 | 800 | ||
| 811 | 801 | assert.Equal(t, entity.PaneID("pane-b"), f.bw1.vimModePaneID) | |
| @@ -820,10 +810,11 @@ func TestVimMode_MultiWindow_TransferIsolated(t *testing.T) { | |||
| 820 | 810 | func TestVimMode_MultiWindow_PulseIsolated(t *testing.T) { | |
| 821 | 811 | f := newVimModeTestFixture(t) | |
| 822 | 812 | ||
| 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) | |
| 825 | 815 | ||
| 826 | 816 | // Pulse on bw1 (lastFocusedWindowID = "win-1") | |
| 817 | + | attachTestModeFrame(f.bw1, f.overlay1A) | |
| 827 | 818 | setUpNormalPulse(f.overlay1A) | |
| 828 | 819 | f.app.triggerVimModePulse(context.Background(), false) | |
| 829 | 820 | ||
| @@ -866,7 +857,7 @@ func TestVimMode_HandleModeChange_NilBwDoesNotCrash(t *testing.T) { | |||
| 866 | 857 | ||
| 867 | 858 | func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) { | |
| 868 | 859 | f := newSingleWindowVimModeFixture(t) | |
| 869 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 860 | + | enterVimMode(t, f.app, f.bw1) | |
| 870 | 861 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | |
| 871 | 862 | ||
| 872 | 863 | expectVimModeAccentHidden(f.overlay1A) | |
| @@ -879,7 +870,7 @@ func TestVimMode_EditableFocusOnActivePaneExitsVimMode(t *testing.T) { | |||
| 879 | 870 | ||
| 880 | 871 | func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) { | |
| 881 | 872 | f := newSingleWindowVimModeFixture(t) | |
| 882 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 873 | + | enterVimMode(t, f.app, f.bw1) | |
| 883 | 874 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | |
| 884 | 875 | ||
| 885 | 876 | f.app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-b"), true) | |
| @@ -891,7 +882,7 @@ func TestVimMode_EditableFocusOnInactivePaneDoesNotExitVimMode(t *testing.T) { | |||
| 891 | 882 | ||
| 892 | 883 | func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) { | |
| 893 | 884 | f := newSingleWindowVimModeFixture(t) | |
| 894 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 885 | + | enterVimMode(t, f.app, f.bw1) | |
| 895 | 886 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | |
| 896 | 887 | f.app.pageEditableFocusByPane = map[entity.PaneID]bool{ | |
| 897 | 888 | "pane-b": true, | |
| @@ -908,7 +899,7 @@ func TestVimMode_PaneSwitchToEditablePaneExitsVimMode(t *testing.T) { | |||
| 908 | 899 | ||
| 909 | 900 | func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t *testing.T) { | |
| 910 | 901 | factory := mocks.NewMockWidgetFactory(t) | |
| 911 | - | pv1, overlay1 := createTestPaneView(t, factory, "pane-a") | |
| 902 | + | pv1, _ := createTestPaneView(t, factory, "pane-a") | |
| 912 | 903 | pv2, _ := createTestPaneView(t, factory, "pane-x") | |
| 913 | 904 | ||
| 914 | 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 | 923 | lastFocusedWindowID: "win-1", | |
| 933 | 924 | } | |
| 934 | 925 | ||
| 935 | - | enterVimMode(t, app, overlay1, bw1) | |
| 926 | + | enterVimMode(t, app, bw1) | |
| 936 | 927 | kh := bindVimModeKeyboardHandler(t, app, bw1) | |
| 937 | 928 | ||
| 938 | 929 | app.handlePageEditableFocusChanged(context.Background(), entity.PaneID("pane-x"), true) | |
| @@ -944,7 +935,7 @@ func TestVimMode_BackgroundWindowEditableFocusDoesNotExitFocusedWindowVimMode(t | |||
| 944 | 935 | ||
| 945 | 936 | func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) { | |
| 946 | 937 | f := newSingleWindowVimModeFixture(t) | |
| 947 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 938 | + | enterVimMode(t, f.app, f.bw1) | |
| 948 | 939 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | |
| 949 | 940 | ||
| 950 | 941 | expectVimModeAccentHidden(f.overlay1A) | |
| @@ -956,7 +947,7 @@ func TestVimMode_OmniboxFocusExitsVimMode(t *testing.T) { | |||
| 956 | 947 | ||
| 957 | 948 | func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) { | |
| 958 | 949 | f := newSingleWindowVimModeFixture(t) | |
| 959 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 950 | + | enterVimMode(t, f.app, f.bw1) | |
| 960 | 951 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | |
| 961 | 952 | ||
| 962 | 953 | expectVimModeAccentHidden(f.overlay1A) | |
| @@ -968,7 +959,7 @@ func TestVimMode_FindBarFocusExitsVimMode(t *testing.T) { | |||
| 968 | 959 | ||
| 969 | 960 | func TestVimMode_TabSwitchExitsAndClearsOldAccent(t *testing.T) { | |
| 970 | 961 | f := newSingleWindowVimModeFixture(t) | |
| 971 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 962 | + | enterVimMode(t, f.app, f.bw1) | |
| 972 | 963 | kh := bindVimModeKeyboardHandler(t, f.app, f.bw1) | |
| 973 | 964 | ||
| 974 | 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 | 1014 | 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)) | |
| 1027 | 1016 | } | |
| 1028 | 1017 | ||
| 1029 | 1018 | // ============================================================================ | |
| @@ -1202,39 +1191,16 @@ func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) { | |||
| 1202 | 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 | 1195 | // Pulse Debounce | |
| 1231 | 1196 | // ============================================================================ | |
| 1232 | 1197 | ||
| 1233 | 1198 | func TestVimMode_Pulse_DebounceSkipsRapidConsecutiveCalls(t *testing.T) { | |
| 1234 | 1199 | f := newSingleWindowVimModeFixture(t) | |
| 1235 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 1200 | + | enterVimMode(t, f.app, f.bw1) | |
| 1236 | 1201 | ||
| 1237 | 1202 | // Set up expectations for exactly ONE normal pulse. | |
| 1203 | + | attachTestModeFrame(f.bw1, f.overlay1A) | |
| 1238 | 1204 | setUpNormalPulse(f.overlay1A) | |
| 1239 | 1205 | ||
| 1240 | 1206 | // First call fires the pulse (debounce timer is zero). | |
| @@ -1257,9 +1223,10 @@ func TestVimMode_Pulse_DebounceAllowsSpacedCalls(t *testing.T) { | |||
| 1257 | 1223 | assert.True(t, f.app.vimModePulseLastTime.IsZero(), | |
| 1258 | 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 | 1228 | // Pulse #1 fires because timer is zero (time.Since(zero) is huge). | |
| 1229 | + | attachTestModeFrame(f.bw1, f.overlay1A) | |
| 1263 | 1230 | setUpNormalPulse(f.overlay1A) | |
| 1264 | 1231 | f.app.triggerVimModePulse(context.Background(), false) | |
| 1265 | 1232 | ||
| @@ -1275,9 +1242,10 @@ func TestVimMode_Pulse_DebounceResetOnClearOwnership(t *testing.T) { | |||
| 1275 | 1242 | // pulse (which would need pulse-cycle alternation handling). | |
| 1276 | 1243 | ||
| 1277 | 1244 | f := newSingleWindowVimModeFixture(t) | |
| 1278 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 1245 | + | enterVimMode(t, f.app, f.bw1) | |
| 1279 | 1246 | ||
| 1280 | 1247 | // Pulse #1 fires and sets the timer. | |
| 1248 | + | attachTestModeFrame(f.bw1, f.overlay1A) | |
| 1281 | 1249 | setUpNormalPulse(f.overlay1A) | |
| 1282 | 1250 | f.app.triggerVimModePulse(context.Background(), false) | |
| 1283 | 1251 | assert.False(t, f.app.vimModePulseLastTime.IsZero(), | |
| @@ -1300,10 +1268,11 @@ func TestVimMode_Pulse_DebounceFirstPulseAlwaysFires(t *testing.T) { | |||
| 1300 | 1268 | // of time.Time producing a time.Since result much larger than | |
| 1301 | 1269 | // the debounce interval. | |
| 1302 | 1270 | f := newSingleWindowVimModeFixture(t) | |
| 1303 | - | enterVimMode(t, f.app, f.overlay1A, f.bw1) | |
| 1271 | + | enterVimMode(t, f.app, f.bw1) | |
| 1304 | 1272 | ||
| 1305 | 1273 | // Rapid double pulse — only first should fire regardless of order. | |
| 1306 | 1274 | // Set up just one set of expectations. | |
| 1275 | + | attachTestModeFrame(f.bw1, f.overlay1A) | |
| 1307 | 1276 | setUpNormalPulse(f.overlay1A) | |
| 1308 | 1277 | f.app.triggerVimModePulse(context.Background(), false) | |
| 1309 | 1278 | 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.MockWebView | 153 | *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 first | 623 | // 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 class | 627 | // 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 expectation | 644 | // 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 switch | 648 | // 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-b | 655 | // 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 fast | 706 | // 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 during | 739 | + | 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 Debounce | 1195 | // 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 than | 1268 | // 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 ( | |||
| 14 | 14 | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 15 | 15 | "github.com/bnema/dumber/internal/ui/component" | |
| 16 | 16 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 17 | - | "github.com/bnema/dumber/internal/ui/focus" | |
| 18 | 17 | "github.com/bnema/dumber/internal/ui/input" | |
| 19 | 18 | "github.com/bnema/dumber/internal/ui/layout" | |
| 20 | 19 | "github.com/bnema/dumber/internal/ui/window" | |
| @@ -37,7 +36,7 @@ type browserWindow struct { | |||
| 37 | 36 | appToaster *component.Toaster | |
| 38 | 37 | modeToaster *component.Toaster | |
| 39 | 38 | touchpadNavIndicator *component.TouchpadNavigationIndicator | |
| 40 | - | borderMgr *focus.BorderManager | |
| 39 | + | modeFrame *modeFrame | |
| 41 | 40 | sessionManager *component.SessionManager | |
| 42 | 41 | tabPicker *component.TabPicker | |
| 43 | 42 | tabPickerWidget layout.Widget | |
| @@ -101,7 +100,10 @@ func (bw *browserWindow) clearShellState() { | |||
| 101 | 100 | bw.appToaster = nil | |
| 102 | 101 | bw.modeToaster = nil | |
| 103 | 102 | bw.touchpadNavIndicator = nil | |
| 104 | - | bw.borderMgr = nil | |
| 103 | + | if bw.modeFrame != nil { | |
| 104 | + | bw.modeFrame.destroy() | |
| 105 | + | bw.modeFrame = nil | |
| 106 | + | } | |
| 105 | 107 | bw.sessionManager = nil | |
| 106 | 108 | bw.tabPicker = nil | |
| 107 | 109 | bw.tabPickerWidget = nil | |
| @@ -179,15 +181,7 @@ func (bw *browserWindow) initBorderOverlay(a *App) { | |||
| 179 | 181 | return | |
| 180 | 182 | } | |
| 181 | 183 | ||
| 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()) | |
| 191 | 185 | } | |
| 192 | 186 | ||
| 193 | 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 | 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 | 362 | if a.contentCoord != nil && tab.Workspace != nil { | |
| 360 | 363 | for _, pane := range tab.Workspace.AllPanes() { | |
| 361 | 364 | if pane == nil { | |
| @@ -492,7 +495,6 @@ func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *br | |||
| 492 | 495 | } | |
| 493 | 496 | ||
| 494 | 497 | func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) { | |
| 495 | - | a.clearResizeModeBorder() | |
| 496 | 498 | if fallback != nil { | |
| 497 | 499 | a.activateBrowserWindow(fallback) | |
| 498 | 500 | return | |
| @@ -523,7 +525,6 @@ func (a *App) cleanupTransientBrowserWindowForDestroy(bw *browserWindow) { | |||
| 523 | 525 | } | |
| 524 | 526 | } | |
| 525 | 527 | if bw.mainWindow == a.mainWindow { | |
| 526 | - | a.clearResizeModeBorder() | |
| 527 | 528 | if fallback != nil { | |
| 528 | 529 | a.activateBrowserWindow(fallback) | |
| 529 | 530 | 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.Toaster | 36 | appToaster *component.Toaster | ||
| 38 | modeToaster *component.Toaster | 37 | modeToaster *component.Toaster | ||
| 39 | touchpadNavIndicator *component.TouchpadNavigationIndicator | 38 | touchpadNavIndicator *component.TouchpadNavigationIndicator | ||
| 40 | - | borderMgr *focus.BorderManager | 39 | + | modeFrame *modeFrame |
| 41 | sessionManager *component.SessionManager | 40 | sessionManager *component.SessionManager | ||
| 42 | tabPicker *component.TabPicker | 41 | tabPicker *component.TabPicker | ||
| 43 | tabPickerWidget layout.Widget | 42 | tabPickerWidget layout.Widget | ||
| @@ -101,7 +100,10 @@ func (bw *browserWindow) clearShellState() { | |||||
| 101 | bw.appToaster = nil | 100 | bw.appToaster = nil | ||
| 102 | bw.modeToaster = nil | 101 | bw.modeToaster = nil | ||
| 103 | bw.touchpadNavIndicator = nil | 102 | bw.touchpadNavIndicator = nil | ||
| 104 | - | bw.borderMgr = nil | 103 | + | if bw.modeFrame != nil { |
| 104 | + | bw.modeFrame.destroy() | |||
| 105 | + | bw.modeFrame = nil | |||
| 106 | + | } | |||
| 105 | bw.sessionManager = nil | 107 | bw.sessionManager = nil | ||
| 106 | bw.tabPicker = nil | 108 | bw.tabPicker = nil | ||
| 107 | bw.tabPickerWidget = nil | 109 | bw.tabPickerWidget = nil | ||
| @@ -179,15 +181,7 @@ func (bw *browserWindow) initBorderOverlay(a *App) { | |||||
| 179 | return | 181 | 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 | return | 500 | 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 | return | 530 | return | ||
M internal/ui/browser_window_test.go +7 -10
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 16 | 16 | "github.com/bnema/dumber/internal/ui/component" | |
| 17 | 17 | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 18 | - | "github.com/bnema/dumber/internal/ui/focus" | |
| 19 | 18 | "github.com/bnema/dumber/internal/ui/input" | |
| 20 | 19 | "github.com/bnema/dumber/internal/ui/layout" | |
| 21 | 20 | "github.com/bnema/dumber/internal/ui/window" | |
| @@ -36,7 +35,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |||
| 36 | 35 | ||
| 37 | 36 | setShellField(t, removed, "appToaster", &component.Toaster{}) | |
| 38 | 37 | setShellField(t, removed, "modeToaster", &component.Toaster{}) | |
| 39 | - | setShellField(t, removed, "borderMgr", &focus.BorderManager{}) | |
| 38 | + | setShellField(t, removed, "modeFrame", &modeFrame{}) | |
| 40 | 39 | setShellField(t, removed, "sessionManager", &component.SessionManager{}) | |
| 41 | 40 | setShellField(t, removed, "tabPicker", &component.TabPicker{}) | |
| 42 | 41 | setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil)) | |
| @@ -54,7 +53,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |||
| 54 | 53 | for _, name := range []string{ | |
| 55 | 54 | "appToaster", | |
| 56 | 55 | "modeToaster", | |
| 57 | - | "borderMgr", | |
| 56 | + | "modeFrame", | |
| 58 | 57 | "sessionManager", | |
| 59 | 58 | "tabPicker", | |
| 60 | 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 | 431 | mainWindow := &window.MainWindow{} | |
| 433 | 432 | otherWindow := &window.MainWindow{} | |
| 434 | 433 | removed := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 435 | 434 | remaining := &browserWindow{id: "window-2", mainWindow: otherWindow} | |
| 436 | - | resizeTarget := &testLayoutWidget{} | |
| 437 | 435 | 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}, | |
| 441 | 438 | } | |
| 442 | 439 | ||
| 443 | 440 | app.removeBrowserWindow(removed.id) | |
| 444 | 441 | ||
| 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) | |
| 447 | 444 | } | |
| 448 | 445 | } | |
| 449 | 446 | ||
| @@ -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 ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/ui/layout" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 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" | |
| 39 | 16 | ||
| 40 | 17 | // PaneView is a container for a single WebView with active state indication. | |
| 41 | 18 | // It uses an overlay to display a border around the active pane. | |
| @@ -52,9 +29,8 @@ type PaneView struct { | |||
| 52 | 29 | paneID entity.PaneID | |
| 53 | 30 | isActive bool | |
| 54 | 31 | ||
| 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 | |
| 58 | 34 | ||
| 59 | 35 | onFocusIn func(paneID entity.PaneID) | |
| 60 | 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 | 526 | func (pv *PaneView) SetVimMode(active bool) { | |
| 551 | 527 | pv.mu.Lock() | |
| 552 | 528 | defer pv.mu.Unlock() | |
| @@ -556,12 +532,6 @@ func (pv *PaneView) SetVimMode(active bool) { | |||
| 556 | 532 | } | |
| 557 | 533 | ||
| 558 | 534 | 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 | 537 | // IsVimMode returns whether Vim mode is currently active on this pane. | |
| @@ -572,40 +542,6 @@ func (pv *PaneView) IsVimMode() bool { | |||
| 572 | 542 | 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 | 545 | // Cleanup removes the WebView widget from the overlay and clears references. | |
| 610 | 546 | // This must be called before destroying the WebView to ensure proper GTK cleanup. | |
| 611 | 547 | // 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 overlay | 15 | + | 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.PaneID | 29 | paneID entity.PaneID | ||
| 53 | isActive bool | 30 | 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 bool | 33 | + | 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 = active | 534 | 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.vimMode | 542 | 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) { | |||
| 690 | 690 | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 691 | 691 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 692 | 692 | ||
| 693 | - | mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once() | |
| 694 | 693 | pv.SetVimMode(true) | |
| 695 | 694 | ||
| 696 | 695 | require.True(t, pv.IsVimMode()) | |
| @@ -704,9 +703,7 @@ func TestSetVimMode_FalseRemovesPaneAccent(t *testing.T) { | |||
| 704 | 703 | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 705 | 704 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 706 | 705 | ||
| 707 | - | mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once() | |
| 708 | 706 | pv.SetVimMode(true) | |
| 709 | - | mockOverlay.EXPECT().RemoveCssClass("vim-mode-active").Once() | |
| 710 | 707 | pv.SetVimMode(false) | |
| 711 | 708 | ||
| 712 | 709 | require.False(t, pv.IsVimMode()) | |
| @@ -733,66 +730,12 @@ func TestSetVimMode_TrueThenTrueIsIdempotent(t *testing.T) { | |||
| 733 | 730 | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 734 | 731 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 735 | 732 | ||
| 736 | - | mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once() | |
| 737 | 733 | pv.SetVimMode(true) | |
| 738 | 734 | pv.SetVimMode(true) | |
| 739 | 735 | ||
| 740 | 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 | 739 | func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) { | |
| 797 | 740 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 798 | 741 | mockOverlay := mocks.NewMockOverlayWidget(t) | |
| @@ -801,7 +744,6 @@ func TestVimMode_CleanupHasNoIndicatorOverlay(t *testing.T) { | |||
| 801 | 744 | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 802 | 745 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 803 | 746 | ||
| 804 | - | mockOverlay.EXPECT().AddCssClass("vim-mode-active").Once() | |
| 805 | 747 | pv.SetVimMode(true) | |
| 806 | 748 | mockOverlay.EXPECT().RemoveOverlay(mock.Anything).Once() | |
| 807 | 749 | 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 { | |||
| 53 | 53 | onActivePaneChanged func(paneID entity.PaneID) | |
| 54 | 54 | onWebViewAttached func(paneID entity.PaneID) | |
| 55 | 55 | onSplitRatioDragged func(nodeID string, ratio float64) | |
| 56 | + | onRebuilt func() | |
| 56 | 57 | ||
| 57 | 58 | // Hover suppression for keyboard navigation (Issue #89) | |
| 58 | 59 | // 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 | 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 | 199 | if ws == nil { | |
| 199 | 200 | return ErrNilWorkspace | |
| 200 | 201 | } | |
| 201 | 202 | ||
| 202 | 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 | 212 | // Release previous views before dropping their ownership. This also detaches | |
| 206 | 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 | 459 | 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 | 469 | // Rebuild rebuilds the widget tree from the current workspace. | |
| 456 | 470 | // Use this after structural changes like splits or closes. | |
| 457 | 471 | 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 changes | 59 | // 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 ErrNilWorkspace | 200 | 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 detaches | 212 | // 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 = fn | 459 | 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 | |||
| 95 | 95 | sb.WriteString(generateTabBarCSS(p)) | |
| 96 | 96 | sb.WriteString("\n") | |
| 97 | 97 | ||
| 98 | - | // Omnibox styling | |
| 98 | + | // Omnibox and modal legend styling | |
| 99 | 99 | sb.WriteString(generateOmniboxCSS(p)) | |
| 100 | + | sb.WriteString(generateModeLegendCSS()) | |
| 100 | 101 | sb.WriteString("\n") | |
| 101 | 102 | ||
| 102 | 103 | // Find bar styling | |
| @@ -805,7 +806,7 @@ entry.find-bar-entry:focus-visible { | |||
| 805 | 806 | // generatePaneCSS creates pane border styles. | |
| 806 | 807 | // Uses em units for scalable UI. | |
| 807 | 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 | 810 | func generateVimModeCSS(transitionDurationMs int) string { | |
| 810 | 811 | if transitionDurationMs <= 0 { | |
| 811 | 812 | transitionDurationMs = defaultTransitionDurationMs | |
| @@ -814,39 +815,37 @@ func generateVimModeCSS(transitionDurationMs int) string { | |||
| 814 | 815 | fastPulseMs := transitionDurationMs * 6 | |
| 815 | 816 | return fmt.Sprintf(`/* ===== Vim Mode Styling ===== */ | |
| 816 | 817 | ||
| 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. */ | |
| 820 | 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 | 821 | border-radius: 0; | |
| 823 | 822 | } | |
| 824 | 823 | ||
| 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 | |
| 827 | 826 | -anim-a and -anim-b pairs are required to restart repeated pulse animations. */ | |
| 828 | 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); } | |
| 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); } | |
| 832 | 831 | } | |
| 833 | 832 | ||
| 834 | 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); } | |
| 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); } | |
| 838 | 837 | } | |
| 839 | 838 | ||
| 840 | 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); } | |
| 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); } | |
| 844 | 843 | } | |
| 845 | 844 | ||
| 846 | 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); } | |
| 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); } | |
| 850 | 849 | } | |
| 851 | 850 | ||
| 852 | 851 | /* Pane overlay scroll pulse — derived from transition_duration. */ | |
| @@ -921,28 +920,22 @@ func generatePaneCSS(p Palette) string { | |||
| 921 | 920 | border-color: transparent; | |
| 922 | 921 | } | |
| 923 | 922 | ||
| 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 { | |
| 926 | 925 | background-color: transparent; | |
| 927 | - | box-shadow: inset 0 0 0 0.25em var(--pane-mode-color); | |
| 928 | 926 | border-radius: 0; | |
| 929 | 927 | } | |
| 930 | 928 | ||
| 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 { | |
| 934 | 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 */ | |
| 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 { | |
| 942 | 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 | 1297 | /* Session mode border */ | |
| 1305 | - | .session-mode-active { | |
| 1306 | - | background-color: transparent; | |
| 1298 | + | .mode-frame-border.session-mode-active { | |
| 1307 | 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 | 1415 | .toast-vim-mode { | |
| 1425 | - | background-color: var(--pane-mode-color); | |
| 1416 | + | background-color: var(--vim-mode-color); | |
| 1426 | 1417 | color: #ffffff; | |
| 1427 | 1418 | } | |
| 1428 | 1419 | ||
| @@ -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 styling | 98 | + | // 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 styling | 103 | // 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 = defaultTransitionDurationMs | 812 | transitionDurationMs = defaultTransitionDurationMs | ||
| @@ -814,39 +815,37 @@ func generateVimModeCSS(transitionDurationMs int) string { | |||||
| 814 | fastPulseMs := transitionDurationMs * 6 | 815 | 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 the | 824 | + | /* Frame pulse keyframes — start/end at the active accent state so the |
| 826 | - | pane keeps its Vim mode border while briefly flaring brighter. The separate | 825 | + | 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) { | |||
| 414 | 414 | assert.NotContains(t, block, "min-width:") | |
| 415 | 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 | + | } | |
| @@ -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) { | |||
| 145 | 145 | TabMode: "#222222", | |
| 146 | 146 | SessionMode: "#333333", | |
| 147 | 147 | ResizeMode: "#444444", | |
| 148 | + | VimMode: "#abcdef", | |
| 148 | 149 | }) | |
| 149 | 150 | ||
| 150 | 151 | assert.Equal(t, "#111111", modeColors.PaneMode) | |
| 151 | 152 | assert.Equal(t, "#222222", modeColors.TabMode) | |
| 152 | 153 | assert.Equal(t, "#333333", modeColors.SessionMode) | |
| 153 | 154 | assert.Equal(t, "#444444", modeColors.ResizeMode) | |
| 155 | + | assert.Equal(t, "#abcdef", modeColors.VimMode) | |
| 154 | 156 | } | |
| 155 | 157 | ||
| 156 | 158 | 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 { | |||
| 27 | 27 | ||
| 28 | 28 | // ModeColors holds colors for modal mode indicators (borders and toasters). | |
| 29 | 29 | type ModeColors struct { | |
| 30 | + | VimMode string // Color for Vim mode (default: #F7768E) | |
| 30 | 31 | PaneMode string // Color for pane mode (default: #4A90E2 blue) | |
| 31 | 32 | TabMode string // Color for tab mode (default: #FFA500 orange) | |
| 32 | 33 | SessionMode string // Color for session mode (default: #9B59B6 purple) | |
| @@ -37,6 +38,7 @@ type ModeColors struct { | |||
| 37 | 38 | func DefaultModeColors() ModeColors { | |
| 38 | 39 | return ModeColors{ | |
| 39 | 40 | PaneMode: "#4A90E2", | |
| 41 | + | VimMode: "#F7768E", | |
| 40 | 42 | TabMode: "#FFA500", | |
| 41 | 43 | SessionMode: "#9B59B6", | |
| 42 | 44 | ResizeMode: "#00D4AA", | |
| @@ -47,6 +49,7 @@ func DefaultModeColors() ModeColors { | |||
| 47 | 49 | func (m ModeColors) ToCSSVars() string { | |
| 48 | 50 | var sb strings.Builder | |
| 49 | 51 | sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n") | |
| 52 | + | sb.WriteString(" --vim-mode-color: " + m.VimMode + ";\n") | |
| 50 | 53 | sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n") | |
| 51 | 54 | sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n") | |
| 52 | 55 | sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n") | |
| @@ -139,6 +142,7 @@ func PaletteFromEntity(p entity.ColorPalette, isDark bool) Palette { | |||
| 139 | 142 | func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors { | |
| 140 | 143 | return ModeColors{ | |
| 141 | 144 | PaneMode: colors.PaneMode, | |
| 145 | + | VimMode: colors.VimMode, | |
| 142 | 146 | TabMode: colors.TabMode, | |
| 143 | 147 | SessionMode: colors.SessionMode, | |
| 144 | 148 | 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.Builder | 50 | 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, | ||