detached 1983990

Merge pull request 'feat(ui): expandable mode toaster and always-active default actions' (#5) from feat/mode-legend-toaster into main

Reviewed-on: https://git.bnema.dev/bnema/dumber/pulls/5
commit
1983990d257913fe5281e98039a48c9ff234fe3f
author
bnema · · 2026-10-14 09:18 UTC
merge of
0ad4e24 + 3bab4f6 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
69 files · +4798 -1406
M .mockery.yaml +1 -0
@@ -75,6 +75,7 @@ packages:
75 KeybindingsProvider: {}75 KeybindingsProvider: {}
76 KeybindingsSaver: {}76 KeybindingsSaver: {}
77 OmniboxPreferencesSaver: {}77 OmniboxPreferencesSaver: {}
78+ ModeLegendPreferencesSaver: {}
78 DownloadResponse: {}79 DownloadResponse: {}
79 PermissionDialogPresenter: {}80 PermissionDialogPresenter: {}
80 PermissionRepository: {}81 PermissionRepository: {}
M CHANGELOG.md +3 -0
@@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.
15 15
16 ### Changed16 ### Changed
17 17
18+- **Mode toaster and legend**: The bottom-left mode indicator and the key legend are now one expandable "mode toaster". The legend expands above the pill as a wide band (up to 1100 px, one to four columns by window width, groups kept in order with balanced column heights) instead of a centered panel. Click the toaster to collapse or expand the legend; the choice applies to every window and mode and is saved as `workspace.styling.mode_legend_collapsed`. With `mode_indicator_toaster_enabled = false` the toaster stays as the legend's header unless `mode_legend = "off"`.
18 - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style.19 - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style.
19 - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker.20 - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker.
20 - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.21 - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.
@@ -26,6 +27,8 @@ All notable changes to this project will be documented in this file.
26 27
27 ### Fixed28 ### Fixed
28 29
30+- **Default actions in partial action maps**: Default actions missing from a configured action map (for example new Vim Mode bindings such as `f`, `v`, or `yap`) are active without running `dumber config migrate`. Your keys win on conflict, keys are compared by the binding they resolve to (`right` = `arrowright`), `keys = []` disables an action, and legacy underscore action names are read as their hyphenated form.
31+- **Config reload**: Edits to the config file and preferences saved from the app are applied on reload instead of being replaced by stale values from the first load.
29 - **Omnibox favorite selection**: Selected favorite rows retain their yellow left indicator in every visual style.32 - **Omnibox favorite selection**: Selected favorite rows retain their yellow left indicator in every visual style.
30 - **Popup and floating-pane stability**: Hardened deferred popup cleanup, failed popup creation, opener-capable fallback behavior, and pane/window rollback paths.33 - **Popup and floating-pane stability**: Hardened deferred popup cleanup, failed popup creation, opener-capable fallback behavior, and pane/window rollback paths.
31 - **WebView resource cleanup**: Fixed stale callback, timer, tick, split, hover-controller, and rebuilt-WebView ownership paths that could leak resources or act on detached views.34 - **WebView resource cleanup**: Fixed stale callback, timer, tick, split, hover-controller, and rebuilt-WebView ownership paths that could leak resources or act on detached views.
M docs/config/index.md +22 -16
@@ -355,6 +355,10 @@ Existing configs using older keys are migrated to the current engine shape. New
355 new_pane_url = "dumb://history"355 new_pane_url = "dumb://history"
356 ```356 ```
357 357
358+### Action Maps
359+
360+Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it. Keys are compared by the binding they resolve to, not by spelling: `right` and `arrowright`, `ctrl` and `control`, and `M` and `shift+m` are the same key. Action names use hyphens (`stack-pane`); older underscore spellings (`stack_pane`) are still read and renamed to the hyphen form when the config loads (if both spellings are present, the hyphenated one is used).
361+
358 ### Pane Mode362 ### Pane Mode
359 363
360 | Key | Type | Default | Description |364 | Key | Type | Default | Description |
@@ -560,26 +564,26 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
560 564
561 | Action | Default Keys | Description |565 | Action | Default Keys | Description |
562 |--------|--------------|-------------|566 |--------|--------------|-------------|
563-| `toggle_floating_pane` | `alt+f` | Toggle the workspace floating pane (persistent session) |567+| `toggle-floating-pane` | `alt+f` | Toggle the workspace floating pane (persistent session) |
564-| `close_pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) |568+| `close-pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) |
565-| `next_tab` | `ctrl+tab` | Switch to next tab |569+| `next-tab` | `ctrl+tab` | Switch to next tab |
566-| `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |570+| `previous-tab` | `ctrl+shift+tab` | Switch to previous tab |
567-| `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |571+| `consume-or-expel-left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |
568-| `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked |572+| `consume-or-expel-right` | `alt+]` | Consume into right sibling stack, or expel right if stacked |
569-| `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked |573+| `consume-or-expel-up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked |
570-| `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked |574+| `consume-or-expel-down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked |
571 575
572 **Example:**576 **Example:**
573 ```toml577 ```toml
574-[workspace.shortcuts.actions.close_pane]578+[workspace.shortcuts.actions.close-pane]
575 keys = ["ctrl+w"]579 keys = ["ctrl+w"]
576 desc = "Close active pane"580 desc = "Close active pane"
577 581
578-[workspace.shortcuts.actions.next_tab]582+[workspace.shortcuts.actions.next-tab]
579 keys = ["ctrl+tab", "alt+l"]583 keys = ["ctrl+tab", "alt+l"]
580 desc = "Switch to next tab"584 desc = "Switch to next tab"
581 585
582-[workspace.shortcuts.actions.toggle_floating_pane]586+[workspace.shortcuts.actions.toggle-floating-pane]
583 keys = ["alt+f"]587 keys = ["alt+f"]
584 desc = "Toggle floating pane"588 desc = "Toggle floating pane"
585 ```589 ```
@@ -597,7 +601,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
597 601
598 The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.602 The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.
599 603
600-- Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle_floating_pane`).604+- Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle-floating-pane`).
601 - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.605 - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.
602 - `Alt+F` toggles visibility only and preserves floating state.606 - `Alt+F` toggles visibility only and preserves floating state.
603 - `Ctrl+W` on an active floating pane closes and releases that floating session for a fresh next open.607 - `Ctrl+W` on an active floating pane closes and releases that floating session for a fresh next open.
@@ -612,7 +616,7 @@ The floating workspace pane is a persistent overlay session: hiding it does not
612 **Example:**616 **Example:**
613 617
614 ```toml618 ```toml
615-[workspace.shortcuts.actions.toggle_floating_pane]619+[workspace.shortcuts.actions.toggle-floating-pane]
616 keys = ["alt+f"]620 keys = ["alt+f"]
617 desc = "Toggle floating pane"621 desc = "Toggle floating pane"
618 622
@@ -658,11 +662,12 @@ desc = "Open floating pane on GitHub"
658 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |662 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
659 | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color |663 | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color |
660 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |664 | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
661-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |665+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active. When `false` and `mode_legend` is not `off`, the header is still shown because the legend expands from it |
662-| `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend sits on the active mode border in that mode's color |666+| `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend expands from the bottom-left mode toaster in that mode's color. `off` keeps only the toaster header, without the expand/collapse chevron |
663-| `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the legend when set to `delay` (>= 0) |667+| `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the expanded legend when set to `delay` (>= 0) |
664 | `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. |668 | `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. |
665 | `workspace.styling.mode_legend_animations` | bool | `true` | Animate the legend and its keycap feedback |669 | `workspace.styling.mode_legend_animations` | bool | `true` | Animate the legend and its keycap feedback |
670+| `workspace.styling.mode_legend_collapsed` | bool | `false` | Collapse the legend to the mode toaster header. Clicking the toaster toggles it, applies immediately in every window, and is saved here for all modes |
666 | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms |671 | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms |
667 672
668 **Example:**673 **Example:**
@@ -682,6 +687,7 @@ mode_legend = "delay"
682 mode_legend_delay_ms = 500687 mode_legend_delay_ms = 500
683 mode_legend_linger = true688 mode_legend_linger = true
684 mode_legend_animations = true689 mode_legend_animations = true
690+mode_legend_collapsed = false
685 transition_duration = 120691 transition_duration = 120
686 ```692 ```
687 693
M docs/reference/configuration.md +6 -5
@@ -101,7 +101,7 @@
101 | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |101 | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |
102 | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |102 | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |
103 | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |103 | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 |
104-| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix |104+| `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix; every action map keeps default actions you do not define, your keys win on conflict, and `keys = []` disables an action |
105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
@@ -119,8 +119,8 @@
119 | `workspace.resize_mode.step_percent` | float | `5.0` | |119 | `workspace.resize_mode.step_percent` | float | `5.0` | |
120 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |120 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
121 | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one |121 | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one |
122-| `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |122+| `workspace.shortcuts.actions.toggle-floating-pane.keys` | []string | `alt+f` | key strings |
123-| `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |123+| `workspace.shortcuts.actions.toggle-floating-pane.desc` | string | `Toggle floating pane` | |
124 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |124 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
125 | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |125 | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |
126 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |126 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
@@ -143,11 +143,12 @@
143 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |143 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |
144 | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend |144 | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend |
145 | `workspace.styling.resize_mode_color` | string | `#00D4AA` | |145 | `workspace.styling.resize_mode_color` | string | `#00D4AA` | |
146-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |146+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active (still shown when the legend is not `off`) |
147-| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` |147+| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off`; `off` keeps only the toaster header |
148 | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` |148 | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` |
149 | `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. |149 | `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. |
150 | `workspace.styling.mode_legend_animations` | bool | `true` | |150 | `workspace.styling.mode_legend_animations` | bool | `true` | |
151+| `workspace.styling.mode_legend_collapsed` | bool | `false` | Collapse the legend to the mode toaster header; toggled by clicking the toaster and saved for all modes |
151 | `workspace.styling.transition_duration` | int | `120` | |152 | `workspace.styling.transition_duration` | int | `120` | |
152 | `session.auto_restore` | bool | `false` | |153 | `session.auto_restore` | bool | `false` | |
153 | `session.snapshot_interval_ms` | int | `5000` | |154 | `session.snapshot_interval_ms` | int | `5000` | |
M docs/reference/floating-pane.md +1 -1
@@ -35,7 +35,7 @@ OAuth flows and scripts that request popup dimensions or restricted browser chro
35 ## Example35 ## Example
36 36
37 ```toml37 ```toml
38-[workspace.shortcuts.actions.toggle_floating_pane]38+[workspace.shortcuts.actions.toggle-floating-pane]
39 keys = ["alt+f"]39 keys = ["alt+f"]
40 desc = "Toggle floating pane"40 desc = "Toggle floating pane"
41 41
M docs/reference/keybindings.md +3 -1
@@ -20,6 +20,8 @@ Press `Escape` or `Enter` to exit any mode.
20 20
21 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.21 Keybinding tables use uppercase letters as visual labels for unshifted letter keys. In config, use lowercase (for example, `["w"]` for Pane Mode eject). Shifted keys are shown with an explicit `Shift+` prefix.
22 22
23+Default actions missing from your config are always active. Your own bindings override defaults (a default is skipped if one of its keys is already used in the same mode), and `keys = []` disables an action.
24+
23 ## Pane Mode (`Ctrl+P`)25 ## Pane Mode (`Ctrl+P`)
24 26
25 | Action | Keys |27 | Action | Keys |
@@ -58,7 +60,7 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
58 60
59 ## Vim Mode (`Ctrl+Y`)61 ## Vim Mode (`Ctrl+Y`)
60 62
61-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.63+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` toaster stays visible while the mode is active, and a shortcut legend expands from it after 500 ms. Click the toaster to collapse or expand the legend (the choice is saved in `workspace.styling.mode_legend_collapsed` and shared by all modes). 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.
62 64
63 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.65 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.
64 66
M internal/application/port/handler_deps.go +1 -0
@@ -46,6 +46,7 @@ type AutoCopyConfig interface {
46 type HandlerDeps struct {46 type HandlerDeps struct {
47 SaveConfig func(context.Context, dto.WebUIConfig) error47 SaveConfig func(context.Context, dto.WebUIConfig) error
48 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error48 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
49+ SaveModeLegendCollapsed func(context.Context, bool) error
49 KeybindingsGetter KeybindingsGetter50 KeybindingsGetter KeybindingsGetter
50 KeybindingSetter KeybindingSetter51 KeybindingSetter KeybindingSetter
51 KeybindingResetter KeybindingResetter52 KeybindingResetter KeybindingResetter
A internal/application/port/mocks/mode_legend_preferences.go +104 -0
@@ -0,0 +1,104 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockModeLegendPreferencesSaver creates a new instance of MockModeLegendPreferencesSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockModeLegendPreferencesSaver(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockModeLegendPreferencesSaver {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &MockModeLegendPreferencesSaver{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// MockModeLegendPreferencesSaver is an autogenerated mock type for the ModeLegendPreferencesSaver type
37+type MockModeLegendPreferencesSaver struct {
38+ mock.Mock
39+}
40+
41+type MockModeLegendPreferencesSaver_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *MockModeLegendPreferencesSaver) EXPECT() *MockModeLegendPreferencesSaver_Expecter {
46+ return &MockModeLegendPreferencesSaver_Expecter{mock: &_m.Mock}
47+}
48+
49+// SaveModeLegendCollapsed provides a mock function for the type MockModeLegendPreferencesSaver
50+func (_mock *MockModeLegendPreferencesSaver) SaveModeLegendCollapsed(ctx context.Context, collapsed bool) error {
51+ ret := _mock.Called(ctx, collapsed)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for SaveModeLegendCollapsed")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) error); ok {
59+ r0 = returnFunc(ctx, collapsed)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveModeLegendCollapsed'
67+type MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call struct {
68+ *mock.Call
69+}
70+
71+// SaveModeLegendCollapsed is a helper method to define mock.On call
72+// - ctx context.Context
73+// - collapsed bool
74+func (_e *MockModeLegendPreferencesSaver_Expecter) SaveModeLegendCollapsed(ctx any, collapsed any) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
75+ return &MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call{Call: _e.mock.On("SaveModeLegendCollapsed", ctx, collapsed)}
76+}
77+
78+func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) Run(run func(ctx context.Context, collapsed bool)) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ var arg1 bool
85+ if args[1] != nil {
86+ arg1 = args[1].(bool)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ )
92+ })
93+ return _c
94+}
95+
96+func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) Return(err error) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
97+ _c.Call.Return(err)
98+ return _c
99+}
100+
101+func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) RunAndReturn(run func(ctx context.Context, collapsed bool) error) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
102+ _c.Call.Return(run)
103+ return _c
104+}
A internal/application/port/mode_legend_preferences.go +8 -0
@@ -0,0 +1,8 @@
1+package port
2+
3+import "context"
4+
5+// ModeLegendPreferencesSaver persists mode legend preference changes.
6+type ModeLegendPreferencesSaver interface {
7+ SaveModeLegendCollapsed(ctx context.Context, collapsed bool) error
8+}
A internal/application/usecase/save_mode_legend_collapsed.go +25 -0
@@ -0,0 +1,25 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// SaveModeLegendCollapsedUseCase persists whether the mode legend is collapsed
11+// to its toaster header. The preference is shared by every mode and window.
12+type SaveModeLegendCollapsedUseCase struct {
13+ saver port.ModeLegendPreferencesSaver
14+}
15+
16+func NewSaveModeLegendCollapsedUseCase(saver port.ModeLegendPreferencesSaver) *SaveModeLegendCollapsedUseCase {
17+ return &SaveModeLegendCollapsedUseCase{saver: saver}
18+}
19+
20+func (uc *SaveModeLegendCollapsedUseCase) Execute(ctx context.Context, collapsed bool) error {
21+ if uc == nil || uc.saver == nil {
22+ return fmt.Errorf("mode legend preferences saver is nil")
23+ }
24+ return uc.saver.SaveModeLegendCollapsed(ctx, collapsed)
25+}
A internal/application/usecase/save_mode_legend_collapsed_test.go +34 -0
@@ -0,0 +1,34 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSaveModeLegendCollapsedUseCase_PersistsValue(t *testing.T) {
13+ ctx := context.Background()
14+ for _, collapsed := range []bool{true, false} {
15+ saver := portmocks.NewMockModeLegendPreferencesSaver(t)
16+ saver.EXPECT().SaveModeLegendCollapsed(ctx, collapsed).Return(nil).Once()
17+
18+ require.NoError(t, NewSaveModeLegendCollapsedUseCase(saver).Execute(ctx, collapsed))
19+ }
20+}
21+
22+func TestSaveModeLegendCollapsedUseCase_ReturnsSaverError(t *testing.T) {
23+ ctx := context.Background()
24+ saver := portmocks.NewMockModeLegendPreferencesSaver(t)
25+ saver.EXPECT().SaveModeLegendCollapsed(ctx, true).Return(errors.New("disk full")).Once()
26+
27+ require.ErrorContains(t, NewSaveModeLegendCollapsedUseCase(saver).Execute(ctx, true), "disk full")
28+}
29+
30+func TestSaveModeLegendCollapsedUseCase_NilSaverReturnsError(t *testing.T) {
31+ require.Error(t, NewSaveModeLegendCollapsedUseCase(nil).Execute(context.Background(), true))
32+ var uc *SaveModeLegendCollapsedUseCase
33+ require.Error(t, uc.Execute(context.Background(), true))
34+}
M internal/bootstrap/handler_deps.go +2 -0
@@ -21,10 +21,12 @@ func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) {
21 gateway := config.NewKeybindingsGateway(mgr)21 gateway := config.NewKeybindingsGateway(mgr)
22 saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))22 saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
23 omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr))23 omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr))
24+ modeLegendSaveUC := usecase.NewSaveModeLegendCollapsedUseCase(config.NewModeLegendPreferencesGateway(mgr))
24 25
25 return &port.HandlerDeps{26 return &port.HandlerDeps{
26 SaveConfig: saveUC.Execute,27 SaveConfig: saveUC.Execute,
27 SaveOmniboxInitialBehavior: omniboxSaveUC.Execute,28 SaveOmniboxInitialBehavior: omniboxSaveUC.Execute,
29+ SaveModeLegendCollapsed: modeLegendSaveUC.Execute,
28 KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway),30 KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway),
29 KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway),31 KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway),
30 KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway),32 KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway),
M internal/domain/entity/builtin_shortcuts.go +3 -2
@@ -2,8 +2,9 @@ package entity
2 2
3 // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every3 // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every
4 // keymap preset. Config defaults include them so users can rebind or disable4 // keymap preset. Config defaults include them so users can rebind or disable
5-// them; the input layer also falls back to them for actions missing from an5+// them, and config loading completes any that a config file omits (unless the
6-// older config file. An action present with empty keys stays disabled.6+// user already bound one of their keys). An action present with empty keys
7+// stays disabled.
7 func BuiltinGlobalShortcuts() map[string]ActionBinding {8 func BuiltinGlobalShortcuts() map[string]ActionBinding {
8 return map[string]ActionBinding{9 return map[string]ActionBinding{
9 "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"},10 "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"},
M internal/domain/entity/config_types.go +1 -0
@@ -194,6 +194,7 @@ type WorkspaceStylingConfig struct {
194 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 line194 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
195 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 line195 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
196 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 line196 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
197+ ModeLegendCollapsed bool `mapstructure:"mode_legend_collapsed" yaml:"mode_legend_collapsed" toml:"mode_legend_collapsed" json:"mode_legend_collapsed"` //nolint:lll // struct tags must stay on one line
197 198
198 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line199 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
199 }200 }
A internal/domain/keychord/keychord.go +195 -0
@@ -0,0 +1,195 @@
1+// Package keychord parses and canonicalizes single key chords such as
2+// "ctrl+shift+t" or "arrowright".
3+//
4+// It is the one place that knows which spellings name the same key (aliases,
5+// modifier names, letter case), so every consumer agrees on when two config
6+// strings are the same binding. It has no toolkit dependency: the input layer
7+// maps the canonical key names to its own key values.
8+package keychord
9+
10+import (
11+ "slices"
12+ "strings"
13+)
14+
15+// Mods holds the modifier flags of a chord.
16+type Mods uint8
17+
18+// Modifier flags.
19+const (
20+ Ctrl Mods = 1 << iota
21+ Alt
22+ Shift
23+)
24+
25+// Chord is a parsed key chord. Key is the canonical key name: one of the
26+// names returned by KeyNames, or a single printable ASCII character.
27+type Chord struct {
28+ Mods Mods
29+ Key string
30+}
31+
32+// String renders the chord in canonical form: modifiers in the fixed order
33+// ctrl, alt, shift, followed by the key.
34+func (c Chord) String() string {
35+ var b strings.Builder
36+ if c.Mods&Ctrl != 0 {
37+ b.WriteString("ctrl+")
38+ }
39+ if c.Mods&Alt != 0 {
40+ b.WriteString("alt+")
41+ }
42+ if c.Mods&Shift != 0 {
43+ b.WriteString("shift+")
44+ }
45+ b.WriteString(c.Key)
46+ return b.String()
47+}
48+
49+// keyAliases maps every accepted lower-case spelling of a named key to its
50+// canonical name. Canonical names map to themselves.
51+var keyAliases = map[string]string{
52+ "escape": "escape",
53+ "esc": "escape",
54+ "return": "return",
55+ "enter": "return",
56+ "tab": "tab",
57+ "space": "space",
58+ "backspace": "backspace",
59+ "delete": "delete",
60+ "del": "delete",
61+ "home": "home",
62+ "end": "end",
63+ "pageup": "pageup",
64+ "page_up": "pageup",
65+ "pagedown": "pagedown",
66+ "page_down": "pagedown",
67+ "left": "left",
68+ "arrowleft": "left",
69+ "right": "right",
70+ "arrowright": "right",
71+ "up": "up",
72+ "arrowup": "up",
73+ "down": "down",
74+ "arrowdown": "down",
75+ "f1": "f1",
76+ "f2": "f2",
77+ "f3": "f3",
78+ "f4": "f4",
79+ "f5": "f5",
80+ "f6": "f6",
81+ "f7": "f7",
82+ "f8": "f8",
83+ "f9": "f9",
84+ "f10": "f10",
85+ "f11": "f11",
86+ "f12": "f12",
87+ "plus": "plus",
88+ "+": "plus",
89+ "minus": "minus",
90+ "-": "minus",
91+ "equal": "equal",
92+ "=": "equal",
93+ "bracketleft": "bracketleft",
94+ "[": "bracketleft",
95+ "bracketright": "bracketright",
96+ "]": "bracketright",
97+ "braceleft": "braceleft",
98+ "{": "braceleft",
99+ "braceright": "braceright",
100+ "}": "braceright",
101+}
102+
103+// KeyNames returns the canonical names of the named keys, sorted. Printable
104+// ASCII characters are also valid keys but are not listed.
105+func KeyNames() []string {
106+ seen := make(map[string]struct{})
107+ for _, canonical := range keyAliases {
108+ seen[canonical] = struct{}{}
109+ }
110+ names := make([]string, 0, len(seen))
111+ for name := range seen {
112+ names = append(names, name)
113+ }
114+ slices.Sort(names)
115+ return names
116+}
117+
118+// canonicalKey resolves a key spelling to its canonical name.
119+func canonicalKey(name string) (string, bool) {
120+ if canonical, ok := keyAliases[strings.ToLower(name)]; ok {
121+ return canonical, true
122+ }
123+ // Single printable ASCII keys (letters, digits, symbols such as %, ", &).
124+ if len(name) == 1 && name[0] > ' ' && name[0] <= '~' {
125+ return name, true
126+ }
127+ return "", false
128+}
129+
130+// Parse parses a config key string such as "ctrl+t", "Control+Shift+T" or
131+// "alt+left". Modifier names and named keys are case-insensitive; an upper-case
132+// single letter implies Shift. It returns false if the string is not a valid
133+// single chord.
134+func Parse(s string) (Chord, bool) {
135+ s = strings.TrimSpace(s)
136+ if s == "" {
137+ return Chord{}, false
138+ }
139+ if s == "+" {
140+ return Chord{Key: "plus"}, true
141+ }
142+
143+ var mods Mods
144+ var keyPart string
145+ for _, part := range strings.Split(s, "+") {
146+ part = strings.TrimSpace(part)
147+ if part == "" {
148+ continue
149+ }
150+ switch strings.ToLower(part) {
151+ case "ctrl", "control":
152+ mods |= Ctrl
153+ case "shift":
154+ mods |= Shift
155+ case "alt":
156+ mods |= Alt
157+ default:
158+ if keyPart != "" {
159+ return Chord{}, false
160+ }
161+ keyPart = part
162+ }
163+ }
164+
165+ // Allow "ctrl++" / "alt+shift++" where the key is "+".
166+ if keyPart == "" && strings.HasSuffix(s, "++") {
167+ keyPart = "+"
168+ }
169+ if keyPart == "" {
170+ return Chord{}, false
171+ }
172+
173+ // An upper-case single letter is Shift+<letter>.
174+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
175+ mods |= Shift
176+ keyPart = strings.ToLower(keyPart)
177+ }
178+
179+ key, ok := canonicalKey(keyPart)
180+ if !ok {
181+ return Chord{}, false
182+ }
183+ return Chord{Mods: mods, Key: key}, true
184+}
185+
186+// Canonicalize returns the canonical spelling of a key string, so that two
187+// strings that name the same binding compare equal. It returns false if the
188+// string is not a valid single chord.
189+func Canonicalize(s string) (string, bool) {
190+ chord, ok := Parse(s)
191+ if !ok {
192+ return "", false
193+ }
194+ return chord.String(), true
195+}
A internal/domain/keychord/keychord_test.go +96 -0
@@ -0,0 +1,96 @@
1+package keychord
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestCanonicalize_SameBindingSameString(t *testing.T) {
11+ groups := [][]string{
12+ {"right", "arrowright", "Right", "ArrowRight"},
13+ {"left", "arrowleft"},
14+ {"escape", "esc", "Escape"},
15+ {"enter", "return", "Return"},
16+ {"delete", "del"},
17+ {"pageup", "page_up"},
18+ {"ctrl+t", "control+t", "CTRL+t", " Ctrl + t"},
19+ {"ctrl+shift+t", "Ctrl+Shift+T", "shift+ctrl+t", "control+shift+T", "Control+T"},
20+ {"M", "shift+m", "shift+M"},
21+ {"ctrl+plus", "ctrl++", "ctrl+Plus"},
22+ {"plus", "+"},
23+ {"minus", "-"},
24+ {"alt+[", "alt+bracketleft"},
25+ {"alt+shift+tab", "shift+alt+tab"},
26+ {" ctrl + t ", "ctrl+t"},
27+ }
28+ for _, group := range groups {
29+ want, ok := Canonicalize(group[0])
30+ require.True(t, ok, group[0])
31+ for _, spelling := range group[1:] {
32+ got, ok := Canonicalize(spelling)
33+ require.True(t, ok, spelling)
34+ assert.Equal(t, want, got, "%q should equal %q", spelling, group[0])
35+ }
36+ }
37+}
38+
39+func TestCanonicalize_DifferentBindingsDiffer(t *testing.T) {
40+ pairs := [][2]string{
41+ {"m", "M"},
42+ {"ctrl+t", "alt+t"},
43+ {"ctrl+t", "t"},
44+ {"left", "right"},
45+ {"ctrl+shift+t", "ctrl+t"},
46+ {"1", "f1"},
47+ }
48+ for _, pair := range pairs {
49+ a, ok := Canonicalize(pair[0])
50+ require.True(t, ok, pair[0])
51+ b, ok := Canonicalize(pair[1])
52+ require.True(t, ok, pair[1])
53+ assert.NotEqual(t, a, b, "%q vs %q", pair[0], pair[1])
54+ }
55+}
56+
57+func TestCanonicalize_CanonicalForm(t *testing.T) {
58+ tests := map[string]string{
59+ "arrowright": "right",
60+ "Control+Shift+T": "ctrl+shift+t",
61+ "shift+alt+ctrl+Esc": "ctrl+alt+shift+escape",
62+ "M": "shift+m",
63+ "ctrl++": "ctrl+plus",
64+ "%": "%",
65+ "shift+%": "shift+%",
66+ "f5": "f5",
67+ "0": "0",
68+ "alt+bracketleft": "alt+bracketleft",
69+ "ctrl+arrowleft": "ctrl+left",
70+ " Alt + Page_Down ": "alt+pagedown",
71+ }
72+ for in, want := range tests {
73+ got, ok := Canonicalize(in)
74+ require.True(t, ok, in)
75+ assert.Equal(t, want, got, in)
76+ }
77+}
78+
79+func TestParse_Invalid(t *testing.T) {
80+ for _, in := range []string{"", " ", "ctrl+", "ctrl", "unknownkey", "ctrl+unknownkey", "a+b", "é"} {
81+ _, ok := Parse(in)
82+ assert.False(t, ok, "%q", in)
83+ }
84+}
85+
86+func TestKeyNames_AreCanonicalAndStable(t *testing.T) {
87+ names := KeyNames()
88+ require.Contains(t, names, "left")
89+ require.Contains(t, names, "plus")
90+ require.NotContains(t, names, "arrowleft")
91+ for _, name := range names {
92+ got, ok := canonicalKey(name)
93+ require.True(t, ok, name)
94+ assert.Equal(t, name, got)
95+ }
96+}
M internal/infrastructure/config/browsing_contexts_test.go +17 -14
@@ -273,20 +273,21 @@ func TestLegacyPopupsFullRoundTrip(t *testing.T) {
273 m.viper.Set("workspace.popups.oauth_auto_close", false)273 m.viper.Set("workspace.popups.oauth_auto_close", false)
274 274
275 // Transform should map popups → browsing_contexts.275 // Transform should map popups → browsing_contexts.
276- m.transformLegacyConfig()276+ settings, err := m.transformedSettings()
277+ require.NoError(t, err)
277 278
278 // popups key should be gone from viper after transform.279 // popups key should be gone from viper after transform.
279- assert.False(t, m.viper.IsSet("workspace.popups.behavior"),280+ assert.False(t, settings.IsSet("workspace.popups.behavior"),
280 "workspace.popups.behavior should not exist after transform")281 "workspace.popups.behavior should not exist after transform")
281 282
282 // browsing_contexts should now hold the values.283 // browsing_contexts should now hold the values.
283- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))284+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
284- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))285+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
285- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))286+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
286 287
287 // Unmarshal should succeed.288 // Unmarshal should succeed.
288 cfg := &Config{}289 cfg := &Config{}
289- err := m.viper.Unmarshal(cfg)290+ err = settings.Unmarshal(cfg)
290 require.NoError(t, err)291 require.NoError(t, err)
291 292
292 // BrowsingContexts should have the legacy values.293 // BrowsingContexts should have the legacy values.
@@ -319,14 +320,15 @@ oauth_auto_close = false
319 m.setDefaults()320 m.setDefaults()
320 require.NoError(t, m.viper.ReadInConfig())321 require.NoError(t, m.viper.ReadInConfig())
321 322
322- m.transformLegacyConfig()323+ settings, err := m.transformedSettings()
324+ require.NoError(t, err)
323 325
324- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))326+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
325- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))327+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
326- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))328+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
327 329
328 cfg := &Config{}330 cfg := &Config{}
329- require.NoError(t, m.viper.Unmarshal(cfg))331+ require.NoError(t, settings.Unmarshal(cfg))
330 normalizeConfig(cfg)332 normalizeConfig(cfg)
331 333
332 assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)334 assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)
@@ -354,10 +356,11 @@ placement = "left"
354 m.setDefaults()356 m.setDefaults()
355 require.NoError(t, m.viper.ReadInConfig())357 require.NoError(t, m.viper.ReadInConfig())
356 358
357- m.transformLegacyConfig()359+ settings, err := m.transformedSettings()
360+ require.NoError(t, err)
358 361
359- assert.Equal(t, "tabbed", m.viper.GetString("workspace.browsing_contexts.behavior"))362+ assert.Equal(t, "tabbed", settings.GetString("workspace.browsing_contexts.behavior"))
360- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))363+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
361 }364 }
362 365
363 func TestReload_ReappliesLegacyPopupsCompatibility(t *testing.T) {366 func TestReload_ReappliesLegacyPopupsCompatibility(t *testing.T) {
A internal/infrastructure/config/default_actions.go +146 -0
@@ -0,0 +1,146 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "sort"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// actionMapSpec describes one configurable action map. It is the single source
12+// of truth for the list of action maps shared by load-time completion and the
13+// config migrator.
14+type actionMapSpec struct {
15+ // key is the dotted config path of the action map.
16+ key string
17+ // actions returns a pointer to the action map inside a Config.
18+ actions func(*Config) *map[string]ActionBinding
19+ // canonKey canonicalizes a key sequence the way the validator does for
20+ // duplicate detection. nil means single key chords (see canonicalizeChordKey).
21+ canonKey func(string) (string, error)
22+}
23+
24+func actionMapSpecs() []actionMapSpec {
25+ return []actionMapSpec{
26+ {key: "workspace.shortcuts.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.Shortcuts.Actions }},
27+ {key: "workspace.prefix_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PrefixMode.Actions }},
28+ {key: "workspace.pane_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PaneMode.Actions }},
29+ {key: "workspace.tab_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.TabMode.Actions }},
30+ {
31+ key: "workspace.vim_mode.actions",
32+ actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.VimMode.Actions },
33+ canonKey: canonicalizeVimModeSequenceKey,
34+ },
35+ {key: "workspace.resize_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.ResizeMode.Actions }},
36+ {key: "session.session_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Session.SessionMode.Actions }},
37+ }
38+}
39+
40+// skippedDefaultAction is a default action not added because a user binding wins.
41+type skippedDefaultAction struct {
42+ name string
43+ sequence string
44+ by string
45+}
46+
47+// missingDefaultActions returns the sorted names of default actions that the
48+// user did not configure and whose key sequences are not already used by a user
49+// action. userKeys maps each user action name to its keys; an action with no
50+// keys (disabled) is still present, so it is never re-added. Action names are
51+// expected in canonical (hyphenated) form: LegacyConfigTransformer renames
52+// underscore spellings before this runs.
53+func (s actionMapSpec) missingDefaultActions(
54+ defaults map[string]ActionBinding,
55+ userKeys map[string][]string,
56+) (missing []string, skipped []skippedDefaultAction) {
57+ canon := s.canonKey
58+ if canon == nil {
59+ canon = canonicalizeChordKey
60+ }
61+ canonOrRaw := func(key string) string {
62+ if c, err := canon(key); err == nil {
63+ return c
64+ }
65+ return key
66+ }
67+
68+ used := make(map[string]string)
69+ userNames := make([]string, 0, len(userKeys))
70+ for name := range userKeys {
71+ userNames = append(userNames, name)
72+ }
73+ sort.Strings(userNames)
74+ for _, name := range userNames {
75+ for _, key := range userKeys[name] {
76+ used[canonOrRaw(key)] = name
77+ }
78+ }
79+
80+ defaultNames := make([]string, 0, len(defaults))
81+ for name := range defaults {
82+ defaultNames = append(defaultNames, name)
83+ }
84+ sort.Strings(defaultNames)
85+
86+ for _, name := range defaultNames {
87+ if _, ok := userKeys[name]; ok {
88+ continue
89+ }
90+ conflict := false
91+ for _, key := range defaults[name].Keys {
92+ sequence := canonOrRaw(key)
93+ if by, ok := used[sequence]; ok {
94+ skipped = append(skipped, skippedDefaultAction{name: name, sequence: sequence, by: by})
95+ conflict = true
96+ break
97+ }
98+ }
99+ if !conflict {
100+ missing = append(missing, name)
101+ }
102+ }
103+ return missing, skipped
104+}
105+
106+// completeDefaultActions fills every action map of cfg with the default
107+// actions the user did not configure. User actions always win: a default is
108+// skipped when one of its key sequences is already used by a user action in the
109+// same map. Maps are replaced, never mutated, so copies of cfg sharing them are
110+// unaffected. The config file is not touched.
111+func completeDefaultActions(cfg *Config) {
112+ if cfg == nil {
113+ return
114+ }
115+ defaults := DefaultConfig()
116+ var skippedAll []string
117+ for _, spec := range actionMapSpecs() {
118+ userActions := *spec.actions(cfg)
119+ userKeys := make(map[string][]string, len(userActions))
120+ for name, binding := range userActions {
121+ userKeys[name] = binding.Keys
122+ }
123+
124+ defaultActions := *spec.actions(defaults)
125+ missing, skipped := spec.missingDefaultActions(defaultActions, userKeys)
126+
127+ merged := make(map[string]ActionBinding, len(userActions)+len(missing))
128+ for name, binding := range userActions {
129+ merged[name] = binding
130+ }
131+ for _, name := range missing {
132+ binding := defaultActions[name]
133+ binding.Keys = slices.Clone(binding.Keys)
134+ merged[name] = binding
135+ }
136+ *spec.actions(cfg) = merged
137+
138+ for _, s := range skipped {
139+ skippedAll = append(skippedAll, fmt.Sprintf("%s.%s (key '%s' used by %s)", spec.key, s.name, s.sequence, s.by))
140+ }
141+ }
142+ if len(skippedAll) > 0 {
143+ log := logging.NewFromEnv()
144+ log.Debug().Strs("skipped", skippedAll).Msg("config: default actions not added because user bindings take precedence")
145+ }
146+}
A internal/infrastructure/config/default_actions_test.go +401 -0
@@ -0,0 +1,401 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func loadConfigFromTOML(t *testing.T, content string) *Config {
13+ t.Helper()
14+ home := t.TempDir()
15+ t.Setenv("HOME", home)
16+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
17+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
18+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
19+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
20+
21+ configFile, err := GetConfigFile()
22+ require.NoError(t, err)
23+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
24+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
25+
26+ mgr, err := NewManager()
27+ require.NoError(t, err)
28+ require.NoError(t, mgr.Load())
29+ return mgr.Get()
30+}
31+
32+// unusedKey returns a valid key sequence that no default action in the map uses.
33+func unusedKey(spec actionMapSpec) string {
34+ if spec.canonKey != nil {
35+ return "<C-x>"
36+ }
37+ return "ctrl+alt+f12"
38+}
39+
40+func TestActionMapSpecs_CoverEveryActionMap(t *testing.T) {
41+ cfg := DefaultConfig()
42+ wantKeys := []string{
43+ "workspace.shortcuts.actions",
44+ "workspace.prefix_mode.actions",
45+ "workspace.pane_mode.actions",
46+ "workspace.tab_mode.actions",
47+ "workspace.vim_mode.actions",
48+ "workspace.resize_mode.actions",
49+ "session.session_mode.actions",
50+ }
51+ var gotKeys []string
52+ for _, spec := range actionMapSpecs() {
53+ gotKeys = append(gotKeys, spec.key)
54+ assert.NotEmpty(t, *spec.actions(cfg), spec.key)
55+ }
56+ assert.ElementsMatch(t, wantKeys, gotKeys)
57+}
58+
59+func TestCompleteDefaultActions_AllMaps(t *testing.T) {
60+ for _, spec := range actionMapSpecs() {
61+ t.Run(spec.key, func(t *testing.T) {
62+ defaults := DefaultConfig()
63+ defaultActions := *spec.actions(defaults)
64+
65+ // Partial user map: a single custom action on an unused key.
66+ cfg := DefaultConfig()
67+ *spec.actions(cfg) = map[string]ActionBinding{
68+ "custom-user-action": {Keys: []string{unusedKey(spec)}, Desc: "custom"},
69+ }
70+ completeDefaultActions(cfg)
71+
72+ got := *spec.actions(cfg)
73+ assert.Contains(t, got, "custom-user-action")
74+ assert.Len(t, got, len(defaultActions)+1)
75+ for name, binding := range defaultActions {
76+ assert.Equal(t, binding, got[name], name)
77+ }
78+ require.NoError(t, validateConfig(cfg))
79+ })
80+ }
81+}
82+
83+func TestCompleteDefaultActions_UserOverrideWins(t *testing.T) {
84+ for _, spec := range actionMapSpecs() {
85+ t.Run(spec.key, func(t *testing.T) {
86+ defaults := DefaultConfig()
87+ var name string
88+ for n, b := range *spec.actions(defaults) {
89+ if len(b.Keys) > 0 {
90+ name = n
91+ break
92+ }
93+ }
94+ require.NotEmpty(t, name)
95+
96+ cfg := DefaultConfig()
97+ *spec.actions(cfg) = map[string]ActionBinding{
98+ name: {Keys: []string{unusedKey(spec)}, Desc: "mine"},
99+ }
100+ completeDefaultActions(cfg)
101+
102+ got := (*spec.actions(cfg))[name]
103+ assert.Equal(t, []string{unusedKey(spec)}, got.Keys)
104+ assert.Equal(t, "mine", got.Desc)
105+ })
106+ }
107+}
108+
109+func TestCompleteDefaultActions_SkipsDefaultsConflictingWithUserKeys(t *testing.T) {
110+ cfg := DefaultConfig()
111+ // The user key canonicalizes to the same vim sequence as the default "shift+j"
112+ // key of vim-scroll-down-fast; the user binds it to a custom action.
113+ cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
114+ "my-action": {Keys: []string{"shift+j"}, Desc: "mine"},
115+ }
116+ completeDefaultActions(cfg)
117+
118+ got := cfg.Workspace.VimMode.Actions
119+ assert.Contains(t, got, "my-action")
120+ assert.NotContains(t, got, "vim-scroll-down-fast")
121+ assert.Contains(t, got, "vim-scroll-up-fast")
122+ require.NoError(t, validateConfig(cfg))
123+}
124+
125+func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) {
126+ cfg := DefaultConfig()
127+ original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}}
128+ cfg.Workspace.TabMode.Actions = original
129+ completeDefaultActions(cfg)
130+ assert.Len(t, original, 1)
131+ assert.Greater(t, len(cfg.Workspace.TabMode.Actions), 1)
132+}
133+
134+func TestManagerLoad_PartialVimActionsGetMergedDefaults(t *testing.T) {
135+ cfg := loadConfigFromTOML(t, `
136+[workspace.vim_mode.actions.cancel]
137+keys = ["escape"]
138+desc = "Cancel"
139+`)
140+ defaults := DefaultConfig().Workspace.VimMode.Actions
141+ assert.Len(t, cfg.Workspace.VimMode.Actions, len(defaults))
142+ for name := range defaults {
143+ assert.Contains(t, cfg.Workspace.VimMode.Actions, name)
144+ }
145+}
146+
147+func TestManagerLoad_UserKeysOverrideDefaultAndConflictingDefaultSkipped(t *testing.T) {
148+ // Rebind scroll-left to "j", which is the default key of scroll-down.
149+ cfg := loadConfigFromTOML(t, `
150+[workspace.vim_mode.actions.vim-scroll-left]
151+keys = ["j"]
152+desc = "Mine"
153+`)
154+ actions := cfg.Workspace.VimMode.Actions
155+ assert.Equal(t, []string{"j"}, actions["vim-scroll-left"].Keys)
156+ assert.NotContains(t, actions, "vim-scroll-down")
157+ assert.Contains(t, actions, "vim-scroll-up")
158+}
159+
160+func TestManagerLoad_EmptyKeysDisablesActionAndIsNotRefilled(t *testing.T) {
161+ cfg := loadConfigFromTOML(t, `
162+[workspace.vim_mode.actions.vim-scroll-left]
163+keys = []
164+desc = "Disabled"
165+
166+[workspace.pane_mode.actions.stack-pane]
167+keys = []
168+`)
169+ left, ok := cfg.Workspace.VimMode.Actions["vim-scroll-left"]
170+ require.True(t, ok)
171+ assert.Empty(t, left.Keys)
172+ assert.Equal(t, "Disabled", left.Desc)
173+
174+ stack, ok := cfg.Workspace.PaneMode.Actions["stack-pane"]
175+ require.True(t, ok)
176+ assert.Empty(t, stack.Keys)
177+
178+ // Other defaults are still completed.
179+ assert.Contains(t, cfg.Workspace.VimMode.Actions, "vim-scroll-right")
180+ assert.Contains(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window")
181+}
182+
183+func TestValidateModalModeActions_EmptyKeysAreDisabledNotInvalid(t *testing.T) {
184+ errs := validateModalModeActions("tab_mode", 0, map[string]ActionBinding{
185+ "new-tab": {Keys: []string{}},
186+ "other": {Keys: nil},
187+ })
188+ assert.Empty(t, errs)
189+}
190+
191+func TestMigrator_DisabledActionIsNotReportedMissing(t *testing.T) {
192+ m := NewMigrator()
193+ missing := m.detectMissingDefaultActions(map[string]any{
194+ "workspace.pane_mode.actions": map[string]any{
195+ "stack-pane": map[string]any{"keys": []any{}, "desc": "off"},
196+ },
197+ })
198+ assert.NotContains(t, missing, "workspace.pane_mode.actions.stack-pane")
199+ assert.Contains(t, missing, "workspace.pane_mode.actions.eject-pane-to-window")
200+}
201+
202+func TestMigrator_MergeSkipsDefaultsConflictingWithUserKeys(t *testing.T) {
203+ m := NewMigrator()
204+ raw := map[string]any{
205+ "workspace": map[string]any{
206+ "vim_mode": map[string]any{
207+ "actions": map[string]any{
208+ "vim-scroll-left": map[string]any{"keys": []any{"j"}, "desc": "mine"},
209+ },
210+ },
211+ },
212+ }
213+ m.mergeMissingDefaultActions(raw)
214+ actions := m.getNestedValue(raw, "workspace.vim_mode.actions").(map[string]any)
215+ assert.NotContains(t, actions, "vim-scroll-down")
216+ assert.Contains(t, actions, "vim-scroll-up")
217+}
218+
219+func TestManagerSave_KeepsDisabledActionsDisabledAcrossReload(t *testing.T) {
220+ home := t.TempDir()
221+ t.Setenv("HOME", home)
222+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
223+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
224+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
225+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
226+
227+ configFile, err := GetConfigFile()
228+ require.NoError(t, err)
229+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
230+ require.NoError(t, os.WriteFile(configFile, []byte(`
231+[workspace.vim_mode.actions.cancel]
232+keys = ["escape"]
233+
234+[workspace.pane_mode.actions.stack-pane]
235+keys = []
236+`), 0o644))
237+
238+ mgr, err := NewManager()
239+ require.NoError(t, err)
240+ require.NoError(t, mgr.Load())
241+
242+ cfg := mgr.Get()
243+ require.NoError(t, mgr.Save(cfg))
244+
245+ reloaded, err := NewManager()
246+ require.NoError(t, err)
247+ require.NoError(t, reloaded.Load())
248+ got := reloaded.Get()
249+
250+ stack, ok := got.Workspace.PaneMode.Actions["stack-pane"]
251+ require.True(t, ok)
252+ assert.Empty(t, stack.Keys)
253+ assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions))
254+}
255+
256+func TestManagerLoad_UnderscoreActionNamesAreCanonicalizedBeforeCompletion(t *testing.T) {
257+ cfg := loadConfigFromTOML(t, `
258+[workspace.pane_mode.actions.stack_pane]
259+keys = ["ctrl+alt+f12"]
260+desc = "Mine"
261+
262+[workspace.resize_mode.actions.resize_increase_left]
263+keys = ["z"]
264+
265+[workspace.prefix_mode.actions.enter_resize_mode]
266+keys = ["ctrl+alt+f11"]
267+
268+[workspace.pane_mode.actions.focus_left]
269+keys = ["ctrl+alt+f10"]
270+
271+[workspace.shortcuts.actions.toggle_floating_pane]
272+keys = ["alt+g"]
273+`)
274+
275+ pane := cfg.Workspace.PaneMode.Actions
276+ assert.NotContains(t, pane, "stack_pane")
277+ assert.Equal(t, []string{"ctrl+alt+f12"}, pane["stack-pane"].Keys)
278+ assert.Equal(t, "Mine", pane["stack-pane"].Desc)
279+ assert.NotContains(t, pane, "focus_left")
280+ assert.Equal(t, []string{"ctrl+alt+f10"}, pane["focus-left"].Keys)
281+
282+ resize := cfg.Workspace.ResizeMode.Actions
283+ assert.NotContains(t, resize, "resize_increase_left")
284+ assert.Equal(t, []string{"z"}, resize["resize-increase-left"].Keys)
285+ assert.Contains(t, resize, "resize-increase-right", "other defaults are still completed")
286+
287+ prefix := cfg.Workspace.PrefixMode.Actions
288+ assert.NotContains(t, prefix, "enter_resize_mode")
289+ assert.Equal(t, []string{"ctrl+alt+f11"}, prefix["enter-resize-mode"].Keys)
290+
291+ shortcuts := cfg.Workspace.Shortcuts.Actions
292+ assert.NotContains(t, shortcuts, "toggle_floating_pane")
293+ assert.Equal(t, []string{"alt+g"}, shortcuts["toggle-floating-pane"].Keys)
294+
295+ for _, spec := range actionMapSpecs() {
296+ for name := range *spec.actions(cfg) {
297+ assert.NotContains(t, name, "_", "%s.%s", spec.key, name)
298+ }
299+ }
300+}
301+
302+func TestManagerLoad_HyphenActionNameWinsOverUnderscoreDuplicate(t *testing.T) {
303+ cfg := loadConfigFromTOML(t, `
304+[workspace.pane_mode.actions.stack_pane]
305+keys = ["ctrl+alt+f12"]
306+
307+[workspace.pane_mode.actions.stack-pane]
308+keys = ["ctrl+alt+f11"]
309+`)
310+ assert.Equal(t, []string{"ctrl+alt+f11"}, cfg.Workspace.PaneMode.Actions["stack-pane"].Keys)
311+ assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack_pane")
312+}
313+
314+func TestCompleteDefaultActions_AliasSpellingsOfAUserKeyBlockTheDefault(t *testing.T) {
315+ tests := []struct {
316+ name string
317+ userKey string
318+ wantSkipped string
319+ }{
320+ {name: "right vs arrowright", userKey: "right", wantSkipped: "split-right"},
321+ {name: "Right vs arrowright", userKey: "Right", wantSkipped: "split-right"},
322+ {name: "uppercase letter vs explicit shift", userKey: "shift+M", wantSkipped: "move-pane-to-next-tab"},
323+ {name: "explicit shift vs uppercase letter", userKey: "M", wantSkipped: "move-pane-to-next-tab"},
324+ }
325+ for _, tt := range tests {
326+ t.Run(tt.name, func(t *testing.T) {
327+ cfg := DefaultConfig()
328+ cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{
329+ "close-pane": {Keys: []string{tt.userKey}},
330+ }
331+ completeDefaultActions(cfg)
332+
333+ got := cfg.Workspace.PaneMode.Actions
334+ assert.Equal(t, []string{tt.userKey}, got["close-pane"].Keys)
335+ if tt.wantSkipped != "" {
336+ assert.NotContains(t, got, tt.wantSkipped)
337+ }
338+ require.NoError(t, validateConfig(cfg), "completed config must stay valid")
339+ })
340+ }
341+}
342+
343+func TestCompleteDefaultActions_ControlSpellingBlocksGlobalDefault(t *testing.T) {
344+ cfg := DefaultConfig()
345+ cfg.Workspace.Shortcuts.Actions = map[string]ActionBinding{
346+ "new-tab": {Keys: []string{"Control+r"}},
347+ }
348+ completeDefaultActions(cfg)
349+
350+ // Default reload is ["ctrl+r", "f5"]; "Control+r" is the same key as
351+ // "ctrl+r", so the user owns it and reload is not added back.
352+ assert.NotContains(t, cfg.Workspace.Shortcuts.Actions, "reload")
353+ assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "hard-reload")
354+ assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "open-omnibox")
355+ require.NoError(t, validateConfig(cfg))
356+}
357+
358+func TestManagerLoad_AliasConflictIsResolvedDeterministically(t *testing.T) {
359+ cfg := loadConfigFromTOML(t, `
360+[workspace.pane_mode.actions.close-pane]
361+keys = ["right"]
362+`)
363+ pane := cfg.Workspace.PaneMode.Actions
364+ assert.Equal(t, []string{"right"}, pane["close-pane"].Keys)
365+ assert.NotContains(t, pane, "split-right", "arrowright is the same key as right")
366+ assert.Contains(t, pane, "split-left")
367+}
368+
369+func TestValidateModalModeActions_DetectsAliasDuplicates(t *testing.T) {
370+ tests := []struct {
371+ name string
372+ keysA string
373+ keysB string
374+ wantCanon string
375+ }{
376+ {name: "right and arrowright", keysA: "right", keysB: "arrowright", wantCanon: "right"},
377+ {name: "control and ctrl", keysA: "control+t", keysB: "ctrl+t", wantCanon: "ctrl+t"},
378+ {name: "uppercase and explicit shift", keysA: "M", keysB: "shift+m", wantCanon: "shift+m"},
379+ {name: "modifier order and case", keysA: "Shift+Ctrl+T", keysB: "ctrl+shift+t", wantCanon: "ctrl+shift+t"},
380+ }
381+ for _, tt := range tests {
382+ t.Run(tt.name, func(t *testing.T) {
383+ errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{
384+ "action-a": {Keys: []string{tt.keysA}},
385+ "action-b": {Keys: []string{tt.keysB}},
386+ })
387+ require.Len(t, errs, 1)
388+ assert.Contains(t, errs[0], "duplicate key binding '"+tt.wantCanon+"' found in pane_mode")
389+ assert.Contains(t, errs[0], "action-a")
390+ assert.Contains(t, errs[0], "action-b")
391+ })
392+ }
393+}
394+
395+func TestValidateModalModeActions_SpellingsOfOneKeyInOneActionAreNotDuplicates(t *testing.T) {
396+ // The defaults list "M" and "shift+m" together.
397+ errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{
398+ "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}},
399+ })
400+ assert.Empty(t, errs)
401+}
M internal/infrastructure/config/defaults.go +1 -0
@@ -445,6 +445,7 @@ func DefaultConfig() *Config {
445 ModeLegend: "delay",445 ModeLegend: "delay",
446 ModeLegendDelayMs: 500,446 ModeLegendDelayMs: 500,
447 ModeLegendAnimations: true,447 ModeLegendAnimations: true,
448+ ModeLegendCollapsed: false,
448 TransitionDuration: defaultTransitionDuration,449 TransitionDuration: defaultTransitionDuration,
449 },450 },
450 },451 },
M internal/infrastructure/config/defaults_test.go +1 -0
@@ -19,6 +19,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
19 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)19 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
20 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)20 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
21 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)21 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
22+ assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed)
22 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)23 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
23 24
24 // Engine defaults (replaces old Performance/Privacy sections)25 // Engine defaults (replaces old Performance/Privacy sections)
M internal/infrastructure/config/loader.go +63 -12
@@ -24,6 +24,11 @@ type Manager struct {
24 callbacks []func(*Config)24 callbacks []func(*Config)
25 watching bool25 watching bool
26 skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config26 skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
27+
28+ // afterSnapshot is a test seam called right after Get or Update copies the
29+ // current config (Get calls it without the lock held, Update with it held).
30+ // It is nil outside tests.
31+ afterSnapshot func()
27 }32 }
28 33
29 type automaticEnvConfig struct {34 type automaticEnvConfig struct {
@@ -145,15 +150,19 @@ func (m *Manager) readConfigFile() error {
145 }150 }
146 }151 }
147 152
148- // Transform legacy action bindings and engine config before unmarshaling.
149- m.transformLegacyConfig()
150-
151 return nil153 return nil
152 }154 }
153 155
154-// transformLegacyConfig converts old-format action bindings and engine config to the new format.156+// transformedSettings returns a throwaway Viper holding the effective settings
155-// This is called after reading config but before unmarshaling.157+// (config file, defaults and environment as resolved by the long-lived Viper)
156-func (m *Manager) transformLegacyConfig() {158+// after the legacy transforms: old-format action bindings, legacy engine config
159+// and legacy popups.
160+//
161+// The transformed values are deliberately NOT written back to m.viper with
162+// Set(). Set() registers top-priority overrides that ReadInConfig never
163+// clears, so a later reload would keep returning the values of the first read
164+// instead of the file contents.
165+func (m *Manager) transformedSettings() (*viper.Viper, error) {
157 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")166 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
158 167
159 rawConfig := m.viper.AllSettings()168 rawConfig := m.viper.AllSettings()
@@ -175,10 +184,11 @@ func (m *Manager) transformLegacyConfig() {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)184 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }185 }
177 186
178- // Apply transformed config back to viper187+ settings := viper.New()
179- for key, value := range rawConfig {188+ if err := settings.MergeConfigMap(rawConfig); err != nil {
180- m.viper.Set(key, value)189+ return nil, fmt.Errorf("failed to merge transformed config: %w", err)
181 }190 }
191+ return settings, nil
182 }192 }
183 193
184 func (m *Manager) configureAutomaticEnv() {194 func (m *Manager) configureAutomaticEnv() {
@@ -348,9 +358,16 @@ func (m *Manager) hasEngineSectionInConfig() bool {
348 return m.viper.InConfig("engine.type")358 return m.viper.InConfig("engine.type")
349 }359 }
350 360
361+// unmarshalConfig decodes the current file, defaults and environment, with the
362+// legacy transforms applied, into a fresh Config. It is used by both the initial
363+// load and every reload, so neither depends on state left by a previous read.
351 func (m *Manager) unmarshalConfig() (*Config, error) {364 func (m *Manager) unmarshalConfig() (*Config, error) {
365+ settings, err := m.transformedSettings()
366+ if err != nil {
367+ return nil, err
368+ }
352 config := &Config{}369 config := &Config{}
353- if err := m.viper.Unmarshal(config); err != nil {370+ if err := settings.Unmarshal(config); err != nil {
354 configFile := m.viper.ConfigFileUsed()371 configFile := m.viper.ConfigFileUsed()
355 return nil, fmt.Errorf(372 return nil, fmt.Errorf(
356 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",373 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
@@ -374,6 +391,7 @@ func ensureDatabasePath(config *Config) error {
374 }391 }
375 392
376 func normalizeConfig(config *Config) {393 func normalizeConfig(config *Config) {
394+ completeDefaultActions(config)
377 normalizeExternalLinks(config)395 normalizeExternalLinks(config)
378 normalizeAppearance(config)396 normalizeAppearance(config)
379 normalizeMedia(config)397 normalizeMedia(config)
@@ -504,10 +522,13 @@ func normalizeMedia(config *Config) {
504 // Get returns the current configuration (thread-safe).522 // Get returns the current configuration (thread-safe).
505 func (m *Manager) Get() *Config {523 func (m *Manager) Get() *Config {
506 m.mu.RLock()524 m.mu.RLock()
507- defer m.mu.RUnlock()
508-
509 // Return a copy to prevent external modification525 // Return a copy to prevent external modification
510 configCopy := *m.config526 configCopy := *m.config
527+ m.mu.RUnlock()
528+
529+ if m.afterSnapshot != nil {
530+ m.afterSnapshot()
531+ }
511 return &configCopy532 return &configCopy
512 }533 }
513 534
@@ -516,6 +537,35 @@ func (m *Manager) Save(cfg *Config) error {
516 m.mu.Lock()537 m.mu.Lock()
517 defer m.mu.Unlock()538 defer m.mu.Unlock()
518 539
540+ return m.saveLocked(cfg)
541+}
542+
543+// Update applies mutate to a copy of the latest configuration and persists the
544+// result while holding the manager's write lock. Unlike Get() followed by
545+// Save(), no concurrent reload or save can be overwritten by a stale snapshot.
546+//
547+// mutate runs under the lock: it must not call back into the Manager, and it
548+// should only replace fields of the config it receives (the copy is shallow,
549+// so nested maps and slices must be replaced rather than modified in place).
550+func (m *Manager) Update(mutate func(*Config)) error {
551+ if mutate == nil {
552+ return fmt.Errorf("config mutation is nil")
553+ }
554+
555+ m.mu.Lock()
556+ defer m.mu.Unlock()
557+
558+ updated := *m.config
559+ if m.afterSnapshot != nil {
560+ m.afterSnapshot()
561+ }
562+ mutate(&updated)
563+
564+ return m.saveLocked(&updated)
565+}
566+
567+// saveLocked validates, writes and publishes cfg. Must be called with m.mu held for write.
568+func (m *Manager) saveLocked(cfg *Config) error {
519 if cfg == nil {569 if cfg == nil {
520 return fmt.Errorf("config is nil")570 return fmt.Errorf("config is nil")
521 }571 }
@@ -753,6 +803,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
753 m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend)803 m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend)
754 m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs)804 m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs)
755 m.viper.SetDefault("workspace.styling.mode_legend_animations", defaults.Workspace.Styling.ModeLegendAnimations)805 m.viper.SetDefault("workspace.styling.mode_legend_animations", defaults.Workspace.Styling.ModeLegendAnimations)
806+ m.viper.SetDefault("workspace.styling.mode_legend_collapsed", defaults.Workspace.Styling.ModeLegendCollapsed)
756 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)807 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
757 }808 }
758 809
M internal/infrastructure/config/loader_actions_test.go +6 -4
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
37 }37 }
38 38
39-func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {39+func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
40 home := t.TempDir()40 home := t.TempDir()
41 t.Setenv("HOME", home)41 t.Setenv("HOME", home)
42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -65,7 +65,7 @@ desc = "Custom stack pane"
65 require.NotNil(t, cfg)65 require.NotNil(t, cfg)
66 66
67 _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]67 _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- assert.False(t, ok)68+ assert.True(t, ok)
69 69
70 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]70 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
71 assert.Equal(t, "Custom stack pane", stack.Desc)71 assert.Equal(t, "Custom stack pane", stack.Desc)
@@ -123,6 +123,8 @@ desc = "Toggle Favorites in right split"
123 favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]123 favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124 assert.Empty(t, favorites.Keys)124 assert.Empty(t, favorites.Keys)
125 assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)125 assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126- _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]126+ // The renamed default action is a missing default, so it is active in memory.
127- assert.False(t, ok)127+ sidebar, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
128+ assert.True(t, ok)
129+ assert.NotEmpty(t, sidebar.Keys)
128 }130 }
M internal/infrastructure/config/loader_legacy_test.go +18 -14
@@ -149,11 +149,12 @@ func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *
149 m.setDefaults()149 m.setDefaults()
150 require.NoError(t, m.viper.ReadInConfig())150 require.NoError(t, m.viper.ReadInConfig())
151 151
152- m.transformLegacyConfig()152+ settings, err := m.transformedSettings()
153+ require.NoError(t, err)
153 154
154- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))155+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
155- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))156+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
156- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))157+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
157 }158 }
158 159
159 func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {160 func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {
@@ -169,10 +170,11 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigrati
169 m.setDefaults()170 m.setDefaults()
170 require.NoError(t, m.viper.ReadInConfig())171 require.NoError(t, m.viper.ReadInConfig())
171 172
172- m.transformLegacyConfig()173+ settings, err := m.transformedSettings()
174+ require.NoError(t, err)
173 175
174- require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))176+ require.False(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
175- require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate"))177+ require.Equal(t, 60, settings.GetInt("engine.cef.windowless_frame_rate"))
176 }178 }
177 179
178 func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {180 func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {
@@ -187,10 +189,11 @@ func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *t
187 m.setDefaults()189 m.setDefaults()
188 require.NoError(t, m.viper.ReadInConfig())190 require.NoError(t, m.viper.ReadInConfig())
189 191
190- m.transformLegacyConfig()192+ settings, err := m.transformedSettings()
193+ require.NoError(t, err)
191 194
192- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))195+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
193- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))196+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
194 }197 }
195 198
196 func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {199 func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {
@@ -206,9 +209,10 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t
206 m.setDefaults()209 m.setDefaults()
207 require.NoError(t, m.viper.ReadInConfig())210 require.NoError(t, m.viper.ReadInConfig())
208 211
209- m.transformLegacyConfig()212+ settings, err := m.transformedSettings()
213+ require.NoError(t, err)
210 214
211- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))215+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
212- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))216+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
213- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))217+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
214 }218 }
A internal/infrastructure/config/loader_reload_test.go +96 -0
@@ -0,0 +1,96 @@
1+package config
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// setLegendDelayInFile rewrites the mode_legend_delay_ms line of the manager's
12+// config file, simulating an external edit.
13+func setLegendDelayInFile(t *testing.T, mgr *Manager, delayMs string) {
14+ t.Helper()
15+
16+ path := mgr.GetConfigFile()
17+ require.NotEmpty(t, path)
18+ data, err := os.ReadFile(path)
19+ require.NoError(t, err)
20+
21+ lines := strings.Split(string(data), "\n")
22+ replaced := false
23+ for i, line := range lines {
24+ if strings.HasPrefix(strings.TrimSpace(line), "mode_legend_delay_ms") {
25+ lines[i] = "mode_legend_delay_ms = " + delayMs
26+ replaced = true
27+ }
28+ }
29+ require.True(t, replaced, "mode_legend_delay_ms not found in written config")
30+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600))
31+}
32+
33+func TestManagerReload_KeepsSavedValue(t *testing.T) {
34+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
35+ require.Equal(t, 500, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
36+
37+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
38+ require.Equal(t, 700, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
39+
40+ mgr.mu.Lock()
41+ err := mgr.reload()
42+ mgr.mu.Unlock()
43+ require.NoError(t, err)
44+ require.Equal(t, 700, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
45+}
46+
47+func TestManagerReload_PicksUpExternalEditAfterSave(t *testing.T) {
48+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
49+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
50+
51+ setLegendDelayInFile(t, mgr, "900")
52+
53+ mgr.mu.Lock()
54+ err := mgr.reload()
55+ mgr.mu.Unlock()
56+ require.NoError(t, err)
57+ require.Equal(t, 900, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
58+}
59+
60+func TestManagerReload_PicksUpExternalEditWithoutPriorSave(t *testing.T) {
61+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
62+ require.Equal(t, 500, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
63+
64+ setLegendDelayInFile(t, mgr, "900")
65+
66+ mgr.mu.Lock()
67+ err := mgr.reload()
68+ mgr.mu.Unlock()
69+ require.NoError(t, err)
70+ require.Equal(t, 900, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
71+
72+ setLegendDelayInFile(t, mgr, "300")
73+ mgr.mu.Lock()
74+ err = mgr.reload()
75+ mgr.mu.Unlock()
76+ require.NoError(t, err)
77+ require.Equal(t, 300, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
78+}
79+
80+func TestManagerUpdate_ConsecutiveUpdatesEachPersist(t *testing.T) {
81+ configHome := t.TempDir()
82+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
83+
84+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = true }))
85+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
86+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 800 }))
87+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = false }))
88+
89+ for name, got := range map[string]*Config{
90+ "in-memory": mgr.Get(),
91+ "on-disk": newTestOmniboxPreferencesGatewayManager(t, configHome).Get(),
92+ } {
93+ require.False(t, got.Workspace.Styling.ModeLegendCollapsed, name)
94+ require.Equal(t, 800, got.Workspace.Styling.ModeLegendDelayMs, name)
95+ }
96+}
M internal/infrastructure/config/loader_test.go +1 -0
@@ -17,6 +17,7 @@ func TestModeLegendDefaultsThroughLoader(t *testing.T) {
17 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)17 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
18 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)18 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
19 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)19 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
20+ assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed)
20 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)21 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
21 }22 }
22 23
M internal/infrastructure/config/migrate.go +30 -26
@@ -296,19 +296,21 @@ const (
296 296
297 type defaultActionMap struct {297 type defaultActionMap struct {
298 key string298 key string
299+ spec actionMapSpec
299 actions map[string]entity.ActionBinding300 actions map[string]entity.ActionBinding
300 }301 }
301 302
302 func (m *Migrator) defaultActionMaps() []defaultActionMap {303 func (m *Migrator) defaultActionMaps() []defaultActionMap {
303- return []defaultActionMap{304+ specs := actionMapSpecs()
304- {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},305+ maps := make([]defaultActionMap, 0, len(specs))
305- {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},306+ for _, spec := range specs {
306- {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},307+ maps = append(maps, defaultActionMap{
307- {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},308+ key: spec.key,
308- {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},309+ spec: spec,
309- {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},310+ actions: *spec.actions(m.defaultConfig),
310- {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},311+ })
311 }312 }
313+ return maps
312 }314 }
313 315
314 // typesAreCompatible checks if the default types for two keys are compatible.316 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -987,7 +989,21 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
987 return false989 return false
988 }990 }
989 991
992+// userActionKeys extracts name -> keys from a raw user action table. Entries
993+// that cannot be parsed are still reported (with no keys) so they count as
994+// configured.
995+func (m *Migrator) userActionKeys(userActions map[string]any) map[string][]string {
996+ out := make(map[string][]string, len(userActions))
997+ for name, value := range userActions {
998+ binding, _ := m.actionBindingFromAny(value)
999+ out[name] = binding.Keys
1000+ }
1001+ return out
1002+}
1003+
990 // detectMissingDefaultActions finds default shortcut/modal actions not present in user config.1004 // detectMissingDefaultActions finds default shortcut/modal actions not present in user config.
1005+// Defaults whose keys are already used by a user action are not reported because
1006+// they are never added.
991 func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {1007 func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
992 if userKeysWithValues == nil {1008 if userKeysWithValues == nil {
993 return nil1009 return nil
@@ -1005,15 +1021,9 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any
1005 continue1021 continue
1006 }1022 }
1007 1023
1008- for actionName := range actionMap.actions {1024+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1009- if _, exists := userActions[actionName]; exists {1025+ for _, name := range names {
1010- continue1026+ missing = append(missing, actionMap.key+"."+name)
1011- }
1012- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1013- if _, exists := userActions[legacyActionName]; exists {
1014- continue
1015- }
1016- missing = append(missing, actionMap.key+"."+actionName)
1017 }1027 }
1018 }1028 }
1019 1029
@@ -1034,15 +1044,9 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
1034 continue1044 continue
1035 }1045 }
1036 1046
1037- for actionName, defaultAction := range actionMap.actions {1047+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1038- if _, exists := userActions[actionName]; exists {1048+ for _, name := range names {
1039- continue1049+ userActions[name] = actionMap.actions[name]
1040- }
1041- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1042- if _, exists := userActions[legacyActionName]; exists {
1043- continue
1044- }
1045- userActions[actionName] = defaultAction
1046 }1050 }
1047 1051
1048 m.setNestedValue(rawConfig, actionMap.key, userActions)1052 m.setNestedValue(rawConfig, actionMap.key, userActions)
A internal/infrastructure/config/mode_legend_preferences_gateway.go +25 -0
@@ -0,0 +1,25 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+)
7+
8+// ModeLegendPreferencesGateway persists mode legend preferences.
9+type ModeLegendPreferencesGateway struct {
10+ mgr *Manager
11+}
12+
13+func NewModeLegendPreferencesGateway(mgr *Manager) *ModeLegendPreferencesGateway {
14+ return &ModeLegendPreferencesGateway{mgr: mgr}
15+}
16+
17+func (g *ModeLegendPreferencesGateway) SaveModeLegendCollapsed(_ context.Context, collapsed bool) error {
18+ if g == nil || g.mgr == nil {
19+ return fmt.Errorf("config manager not initialized")
20+ }
21+
22+ return g.mgr.Update(func(cfg *Config) {
23+ cfg.Workspace.Styling.ModeLegendCollapsed = collapsed
24+ })
25+}
A internal/infrastructure/config/mode_legend_preferences_gateway_test.go +149 -0
@@ -0,0 +1,149 @@
1+package config
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestModeLegendPreferencesGateway_SaveModeLegendCollapsed_Persists(t *testing.T) {
14+ configHome := t.TempDir()
15+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
16+ gateway := NewModeLegendPreferencesGateway(mgr)
17+ require.False(t, mgr.Get().Workspace.Styling.ModeLegendCollapsed)
18+
19+ require.NoError(t, gateway.SaveModeLegendCollapsed(context.Background(), true))
20+ require.True(t, newTestOmniboxPreferencesGatewayManager(t, configHome).Get().Workspace.Styling.ModeLegendCollapsed)
21+
22+ require.NoError(t, gateway.SaveModeLegendCollapsed(context.Background(), false))
23+ require.False(t, newTestOmniboxPreferencesGatewayManager(t, configHome).Get().Workspace.Styling.ModeLegendCollapsed)
24+}
25+
26+func TestModeLegendPreferencesGateway_NilManagerReturnsError(t *testing.T) {
27+ require.Error(t, NewModeLegendPreferencesGateway(nil).SaveModeLegendCollapsed(context.Background(), true))
28+}
29+
30+// newWatchedTestManager mirrors production, where the manager is in watching mode
31+// so Save() publishes the in-memory config directly instead of re-reading the
32+// file. No fsnotify watcher is started, which keeps the tests deterministic.
33+func newWatchedTestManager(t *testing.T, configHome string) *Manager {
34+ t.Helper()
35+
36+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
37+ mgr.watching = true
38+
39+ return mgr
40+}
41+
42+// TestManagerUpdate_UpdateAfterStaleSnapshotDoesNotLoseWrites forces the
43+// interleaving a Get()-then-Save() implementation loses: another update
44+// publishes right after the snapshot the first update works from was taken.
45+// Update must apply its change on top of that update instead of overwriting it.
46+func TestManagerUpdate_UpdateAfterStaleSnapshotDoesNotLoseWrites(t *testing.T) {
47+ configHome := t.TempDir()
48+ mgr := newWatchedTestManager(t, configHome)
49+ legend := NewModeLegendPreferencesGateway(mgr)
50+ omnibox := NewOmniboxPreferencesGateway(mgr)
51+
52+ saveOther := func() error {
53+ return omnibox.SaveOmniboxInitialBehavior(context.Background(), entity.OmniboxInitialBehaviorMostVisited)
54+ }
55+ queued := make(chan error, 1)
56+ var fired atomic.Bool
57+ mgr.afterSnapshot = func() {
58+ // The nested update calls this hook too; only the first snapshot reacts.
59+ if fired.Swap(true) {
60+ return
61+ }
62+ func() {
63+ if mgr.mu.TryLock() {
64+ // The snapshot was taken without keeping the lock: another
65+ // update publishes before this one continues.
66+ mgr.mu.Unlock()
67+ require.NoError(t, saveOther())
68+ queued <- nil
69+ return
70+ }
71+ // The lock is held across the whole update: the other update has
72+ // to queue behind it.
73+ go func() { queued <- saveOther() }()
74+ }()
75+ }
76+
77+ require.NoError(t, legend.SaveModeLegendCollapsed(context.Background(), true))
78+ require.NoError(t, <-queued)
79+
80+ for name, got := range map[string]*Config{
81+ "in-memory": mgr.Get(),
82+ "on-disk": newTestOmniboxPreferencesGatewayManager(t, configHome).Get(),
83+ } {
84+ require.True(t, got.Workspace.Styling.ModeLegendCollapsed, name)
85+ require.Equal(t, entity.OmniboxInitialBehaviorMostVisited, got.Omnibox.InitialBehavior, name)
86+ }
87+}
88+
89+func TestManagerUpdate_AppliesToLatestConfigAfterSave(t *testing.T) {
90+ mgr := newWatchedTestManager(t, t.TempDir())
91+
92+ cfg := mgr.Get()
93+ cfg.Omnibox.InitialBehavior = entity.OmniboxInitialBehaviorMostVisited
94+ require.NoError(t, mgr.Save(cfg))
95+
96+ require.NoError(t, NewModeLegendPreferencesGateway(mgr).SaveModeLegendCollapsed(context.Background(), true))
97+
98+ got := mgr.Get()
99+ require.True(t, got.Workspace.Styling.ModeLegendCollapsed)
100+ require.Equal(t, entity.OmniboxInitialBehaviorMostVisited, got.Omnibox.InitialBehavior)
101+}
102+
103+func TestManagerUpdate_RejectsNilAndInvalidMutations(t *testing.T) {
104+ mgr := newWatchedTestManager(t, t.TempDir())
105+ before := mgr.Get().Workspace.Styling.ModeLegendDelayMs
106+
107+ require.Error(t, mgr.Update(nil))
108+ require.Error(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = -1 }))
109+ require.Equal(t, before, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
110+}
111+
112+func TestManagerUpdate_ConcurrentUpdatesAreAllPersisted(t *testing.T) {
113+ configHome := t.TempDir()
114+ mgr := newWatchedTestManager(t, configHome)
115+
116+ mutations := []func(*Config){
117+ func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = true },
118+ func(cfg *Config) { cfg.Omnibox.InitialBehavior = entity.OmniboxInitialBehaviorNone },
119+ func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 1234 },
120+ func(cfg *Config) { cfg.Workspace.Styling.TransitionDuration = 321 },
121+ func(cfg *Config) { cfg.Workspace.Styling.ModeBorderWidth = 7 },
122+ func(cfg *Config) { cfg.Workspace.Styling.ModeLegendLinger = false },
123+ }
124+
125+ start := make(chan struct{})
126+ errs := make(chan error, len(mutations))
127+ for _, mutate := range mutations {
128+ go func() {
129+ <-start
130+ errs <- mgr.Update(mutate)
131+ }()
132+ }
133+ close(start)
134+ for range mutations {
135+ require.NoError(t, <-errs)
136+ }
137+
138+ for name, got := range map[string]*Config{
139+ "in-memory": mgr.Get(),
140+ "on-disk": newTestOmniboxPreferencesGatewayManager(t, configHome).Get(),
141+ } {
142+ require.True(t, got.Workspace.Styling.ModeLegendCollapsed, name)
143+ require.Equal(t, entity.OmniboxInitialBehaviorNone, got.Omnibox.InitialBehavior, name)
144+ require.Equal(t, 1234, got.Workspace.Styling.ModeLegendDelayMs, name)
145+ require.Equal(t, 321, got.Workspace.Styling.TransitionDuration, name)
146+ require.Equal(t, 7, got.Workspace.Styling.ModeBorderWidth, name)
147+ require.False(t, got.Workspace.Styling.ModeLegendLinger, name)
148+ }
149+}
M internal/infrastructure/config/omnibox_preferences_gateway.go +3 -4
@@ -20,8 +20,7 @@ func (g *OmniboxPreferencesGateway) SaveOmniboxInitialBehavior(_ context.Context
20 return fmt.Errorf("config manager not initialized")20 return fmt.Errorf("config manager not initialized")
21 }21 }
22 22
23- current := g.mgr.Get()23+ return g.mgr.Update(func(cfg *Config) {
24- current.Omnibox.InitialBehavior = behavior24+ cfg.Omnibox.InitialBehavior = behavior
25-25+ })
26- return g.mgr.Save(current)
27 }26 }
M internal/infrastructure/config/schema_provider.go +8 -1
@@ -740,7 +740,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
740 Key: "workspace.styling.mode_indicator_toaster_enabled",740 Key: "workspace.styling.mode_indicator_toaster_enabled",
741 Type: "bool",741 Type: "bool",
742 Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),742 Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),
743- Description: "Show the mode toast while a mode is active",743+ Description: "Show the bottom-left mode toaster while a mode is active (kept as the legend header while the legend is not off)",
744 Section: SectionWorkspace,744 Section: SectionWorkspace,
745 },745 },
746 {746 {
@@ -771,6 +771,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
771 Description: "Animate the mode legend and pressed keycaps",771 Description: "Animate the mode legend and pressed keycaps",
772 Section: SectionWorkspace,772 Section: SectionWorkspace,
773 },773 },
774+ {
775+ Key: "workspace.styling.mode_legend_collapsed",
776+ Type: "bool",
777+ Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendCollapsed),
778+ Description: "Collapse the mode legend to its header; toggled by clicking the mode toaster and shared by all modes and windows",
779+ Section: SectionWorkspace,
780+ },
774 {781 {
775 Key: "workspace.styling.transition_duration",782 Key: "workspace.styling.transition_duration",
776 Type: "int",783 Type: "int",
M internal/infrastructure/config/schema_provider_test.go +1 -0
@@ -25,6 +25,7 @@ func TestModeLegendSchemaDefaults(t *testing.T) {
25 {"workspace.styling.mode_legend_linger", "bool", "true", ""},25 {"workspace.styling.mode_legend_linger", "bool", "true", ""},
26 {"workspace.styling.vim_mode_color", "string", "#F7768E", ""},26 {"workspace.styling.vim_mode_color", "string", "#F7768E", ""},
27 {"workspace.styling.mode_legend_animations", "bool", "true", ""},27 {"workspace.styling.mode_legend_animations", "bool", "true", ""},
28+ {"workspace.styling.mode_legend_collapsed", "bool", "false", ""},
28 } {29 } {
29 entry, ok := keys[tc.key]30 entry, ok := keys[tc.key]
30 require.True(t, ok, "missing schema key %s", tc.key)31 require.True(t, ok, "missing schema key %s", tc.key)
M internal/infrastructure/config/transform_legacy.go +94 -35
@@ -1,6 +1,12 @@
1 package config1 package config
2 2
3-import "math"3+import (
4+ "math"
5+ "slices"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
4 10
5 const (11 const (
6 sectionWorkspace = "workspace"12 sectionWorkspace = "workspace"
@@ -18,8 +24,29 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
18 return &LegacyConfigTransformer{defaults: DefaultConfig()}24 return &LegacyConfigTransformer{defaults: DefaultConfig()}
19 }25 }
20 26
21-// TransformLegacyActions converts old-format action bindings to new format.27+// legacyActionSlicePaths are the action maps that older configs wrote as plain
22-// This handles migration from:28+// key lists. Vim Mode never had that format.
29+var legacyActionSlicePaths = [][]string{
30+ {"workspace", "prefix_mode", "actions"},
31+ {"workspace", "pane_mode", "actions"},
32+ {"workspace", "tab_mode", "actions"},
33+ {"workspace", "resize_mode", "actions"},
34+ {"workspace", "shortcuts", "actions"},
35+ {"session", "session_mode", "actions"},
36+}
37+
38+// actionNamePaths are all configurable action maps.
39+var actionNamePaths = append(slices.Clone(legacyActionSlicePaths), []string{"workspace", "vim_mode", "actions"})
40+
41+// TransformLegacyActions canonicalizes action names and converts old-format
42+// action bindings to the new format.
43+//
44+// Names: older configs spell actions with underscores (stack_pane); the
45+// canonical spelling is hyphenated (stack-pane). Every action map is renamed to
46+// the hyphen form so the rest of the system only ever sees one spelling. When a
47+// map holds both spellings of an action, the hyphenated one wins.
48+//
49+// Bindings: this handles migration from:
23 //50 //
24 // [workspace.pane_mode.actions]51 // [workspace.pane_mode.actions]
25 // "focus-left" = ["h", "arrowleft"]52 // "focus-left" = ["h", "arrowleft"]
@@ -30,20 +57,72 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
30 // keys = ["h", "arrowleft"]57 // keys = ["h", "arrowleft"]
31 // desc = "Focus pane to the left"58 // desc = "Focus pane to the left"
32 func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {59 func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
33- actionPaths := [][]string{60+ for _, path := range actionNamePaths {
34- {"workspace", "prefix_mode", "actions"},61+ if actions, ok := rawActionSection(rawConfig, path); ok {
35- {"workspace", "pane_mode", "actions"},62+ canonicalizeActionNames(actions, strings.Join(path, "."))
36- {"workspace", "tab_mode", "actions"},63+ }
37- {"workspace", "resize_mode", "actions"},
38- {"workspace", "shortcuts", "actions"},
39- {"session", "session_mode", "actions"},
40 }64 }
65+ for _, path := range legacyActionSlicePaths {
66+ if actions, ok := rawActionSection(rawConfig, path); ok {
67+ t.transformActionSection(actions, path)
68+ }
69+ }
70+}
41 71
42- for _, path := range actionPaths {72+// canonicalActionName returns the canonical (hyphenated) spelling of an action name.
43- t.transformActionSection(rawConfig, path)73+func canonicalActionName(name string) string {
74+ return strings.ReplaceAll(name, "_", "-")
75+}
76+
77+// canonicalizeActionNames renames underscore action names to their hyphenated
78+// form in place. If both spellings exist, the hyphenated entry is kept.
79+func canonicalizeActionNames(actions map[string]any, section string) {
80+ var legacyNames []string
81+ for name := range actions {
82+ if canonicalActionName(name) != name {
83+ legacyNames = append(legacyNames, name)
84+ }
85+ }
86+ slices.Sort(legacyNames)
87+
88+ for _, name := range legacyNames {
89+ canonical := canonicalActionName(name)
90+ value := actions[name]
91+ delete(actions, name)
92+
93+ log := logging.NewFromEnv()
94+ if _, exists := actions[canonical]; exists {
95+ log.Debug().
96+ Str("section", section).
97+ Str("action", name).
98+ Str("canonical", canonical).
99+ Msg("config: legacy action name ignored because the canonical spelling is also configured")
100+ continue
101+ }
102+ actions[canonical] = value
103+ log.Debug().
104+ Str("section", section).
105+ Str("action", name).
106+ Str("canonical", canonical).
107+ Msg("config: legacy action name renamed")
44 }108 }
45 }109 }
46 110
111+// rawActionSection returns the raw action map at path, if the path exists and
112+// holds a table.
113+func rawActionSection(rawConfig map[string]any, path []string) (map[string]any, bool) {
114+ current := rawConfig
115+ for _, key := range path[:len(path)-1] {
116+ next, ok := current[key].(map[string]any)
117+ if !ok {
118+ return nil, false
119+ }
120+ current = next
121+ }
122+ actions, ok := current[path[len(path)-1]].(map[string]any)
123+ return actions, ok
124+}
125+
47 // TransformLegacyEngineConfig removes deprecated engine config keys.126 // TransformLegacyEngineConfig removes deprecated engine config keys.
48 func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {127 func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
49 adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")128 adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
@@ -217,29 +296,9 @@ func int64ConfigValue(value any) (int64, bool) {
217 }296 }
218 }297 }
219 298
220-func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {299+// transformActionSection converts old-format (key list) bindings of one action
221- // Navigate to the parent of the actions section300+// map to the ActionBinding format.
222- current := rawConfig301+func (t *LegacyConfigTransformer) transformActionSection(actions map[string]any, path []string) {
223- for _, key := range path[:len(path)-1] {
224- next, ok := current[key].(map[string]any)
225- if !ok {
226- return // Path doesn't exist
227- }
228- current = next
229- }
230-
231- actionsKey := path[len(path)-1]
232- actionsRaw, ok := current[actionsKey]
233- if !ok {
234- return
235- }
236-
237- actions, ok := actionsRaw.(map[string]any)
238- if !ok {
239- return
240- }
241-
242- // Check each action and transform if needed
243 for actionName, actionValue := range actions {302 for actionName, actionValue := range actions {
244 // If value is a slice (old format), convert to ActionBinding format303 // If value is a slice (old format), convert to ActionBinding format
245 if slice, ok := actionValue.([]any); ok {304 if slice, ok := actionValue.([]any); ok {
M internal/infrastructure/config/transform_legacy_test.go +72 -2
@@ -89,8 +89,9 @@ func TestTransformLegacyActions_AllModes(t *testing.T) {
89 89
90 // Check shortcuts90 // Check shortcuts
91 shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any)91 shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any)
92- closePane, ok := shortcutActions["close_pane"].(map[string]any)92+ closePane, ok := shortcutActions["close-pane"].(map[string]any)
93- require.True(t, ok)93+ require.True(t, ok, "underscore action names are renamed to the hyphen form")
94+ assert.NotContains(t, shortcutActions, "close_pane")
94 assert.Equal(t, []string{"ctrl+w"}, closePane["keys"])95 assert.Equal(t, []string{"ctrl+w"}, closePane["keys"])
95 96
96 // Check session mode97 // Check session mode
@@ -404,3 +405,72 @@ func TestTransformLegacyActions_UnknownAction(t *testing.T) {
404 assert.Equal(t, []string{"x"}, binding["keys"])405 assert.Equal(t, []string{"x"}, binding["keys"])
405 assert.Empty(t, binding["desc"], "unknown action should have empty description")406 assert.Empty(t, binding["desc"], "unknown action should have empty description")
406 }407 }
408+
409+func TestTransformLegacyActions_RenamesUnderscoreNamesInEveryActionMap(t *testing.T) {
410+ raw := map[string]any{
411+ "workspace": map[string]any{
412+ "shortcuts": map[string]any{"actions": map[string]any{"toggle_floating_pane": map[string]any{"keys": []any{"alt+g"}}}},
413+ "prefix_mode": map[string]any{"actions": map[string]any{"enter_resize_mode": map[string]any{"keys": []any{"z"}}}},
414+ "pane_mode": map[string]any{"actions": map[string]any{"focus_left": map[string]any{"keys": []any{"z"}}}},
415+ "tab_mode": map[string]any{"actions": map[string]any{"new_tab": map[string]any{"keys": []any{"z"}}}},
416+ "resize_mode": map[string]any{"actions": map[string]any{"resize_increase_left": map[string]any{"keys": []any{"z"}}}},
417+ "vim_mode": map[string]any{"actions": map[string]any{"vim_scroll_left": map[string]any{"keys": []any{"z"}}}},
418+ },
419+ "session": map[string]any{
420+ "session_mode": map[string]any{"actions": map[string]any{"session_manager": map[string]any{"keys": []any{"z"}}}},
421+ },
422+ }
423+
424+ NewLegacyConfigTransformer().TransformLegacyActions(raw)
425+
426+ want := map[string]string{
427+ "shortcuts": "toggle-floating-pane",
428+ "prefix_mode": "enter-resize-mode",
429+ "pane_mode": "focus-left",
430+ "tab_mode": "new-tab",
431+ "resize_mode": "resize-increase-left",
432+ "vim_mode": "vim-scroll-left",
433+ }
434+ workspace := raw["workspace"].(map[string]any)
435+ for section, name := range want {
436+ actions := workspace[section].(map[string]any)["actions"].(map[string]any)
437+ assert.Len(t, actions, 1, section)
438+ assert.Contains(t, actions, name, section)
439+ }
440+ sessionActions := raw["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any)
441+ assert.Len(t, sessionActions, 1)
442+ assert.Contains(t, sessionActions, "session-manager")
443+}
444+
445+func TestTransformLegacyActions_HyphenSpellingWinsOverUnderscore(t *testing.T) {
446+ raw := map[string]any{
447+ "workspace": map[string]any{
448+ "pane_mode": map[string]any{"actions": map[string]any{
449+ "stack_pane": map[string]any{"keys": []any{"q"}},
450+ "stack-pane": map[string]any{"keys": []any{"w"}},
451+ }},
452+ },
453+ }
454+
455+ NewLegacyConfigTransformer().TransformLegacyActions(raw)
456+
457+ actions := raw["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
458+ require.Len(t, actions, 1)
459+ assert.Equal(t, []any{"w"}, actions["stack-pane"].(map[string]any)["keys"])
460+}
461+
462+func TestTransformLegacyActions_RenamesUnderscoreNamesInSliceFormat(t *testing.T) {
463+ raw := map[string]any{
464+ "workspace": map[string]any{
465+ "pane_mode": map[string]any{"actions": map[string]any{"stack_pane": []any{"q"}}},
466+ },
467+ }
468+
469+ NewLegacyConfigTransformer().TransformLegacyActions(raw)
470+
471+ actions := raw["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any)
472+ binding, ok := actions["stack-pane"].(map[string]any)
473+ require.True(t, ok)
474+ assert.Equal(t, []string{"q"}, binding["keys"])
475+ assert.Equal(t, DefaultConfig().Workspace.PaneMode.Actions["stack-pane"].Desc, binding["desc"])
476+}
M internal/infrastructure/config/validation.go +54 -30
@@ -9,6 +9,7 @@ import (
9 "sort"9 "sort"
10 "strings"10 "strings"
11 11
12+ "github.com/bnema/dumber/internal/domain/keychord"
12 domainurl "github.com/bnema/dumber/internal/domain/url"13 domainurl "github.com/bnema/dumber/internal/domain/url"
13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"14 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14 "github.com/bnema/dumber/internal/domain/vimkeys"15 "github.com/bnema/dumber/internal/domain/vimkeys"
@@ -299,8 +300,12 @@ func validateModalModeActionsWithKeyCanon(
299 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))300 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
300 }301 }
301 302
303+ // Single-chord maps accept several spellings of one key under the same
304+ // action (for example "M" and "shift+m"); that is redundant, not a conflict.
305+ // Vim sequences keep reporting any repeated sequence.
306+ allowSameActionAliases := keyCanon == nil
302 if keyCanon == nil {307 if keyCanon == nil {
303- keyCanon = func(key string) (string, error) { return key, nil }308+ keyCanon = canonicalizeChordKey
304 }309 }
305 310
306 actionNames := make([]string, 0, len(actions))311 actionNames := make([]string, 0, len(actions))
@@ -312,10 +317,7 @@ func validateModalModeActionsWithKeyCanon(
312 seenSequence := make(map[string]string)317 seenSequence := make(map[string]string)
313 for _, action := range actionNames {318 for _, action := range actionNames {
314 binding := actions[action]319 binding := actions[action]
315- if len(binding.Keys) == 0 {320+ // An action with no keys is disabled; it binds nothing and is valid.
316- validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
317- continue
318- }
319 keys := append([]string(nil), binding.Keys...)321 keys := append([]string(nil), binding.Keys...)
320 sort.Strings(keys)322 sort.Strings(keys)
321 for _, key := range keys {323 for _, key := range keys {
@@ -331,32 +333,10 @@ func validateModalModeActionsWithKeyCanon(
331 continue333 continue
332 }334 }
333 if modePath == "vim_mode" {335 if modePath == "vim_mode" {
334- sequence, parseErr := vimkeys.ParseBinding(canonical)336+ validationErrors = append(validationErrors, validateVimBindingKey(modePath, action, canonical)...)
335- if parseErr == nil {
336- if len(sequence) > 0 && isVimCountStartKey(sequence[0]) {
337- validationErrors = append(validationErrors, fmt.Sprintf(
338- "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'",
339- modePath,
340- action,
341- canonical,
342- ))
343- }
344- legacyExit := action == "confirm" || action == "cancel"
345- if !legacyExit {
346- for _, key := range sequence {
347- if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") {
348- validationErrors = append(validationErrors, fmt.Sprintf(
349- "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions",
350- modePath,
351- action,
352- ))
353- break
354- }
355- }
356- }
357- }
358 }337 }
359- if existingAction, exists := seenSequence[canonical]; exists {338+ existingAction, exists := seenSequence[canonical]
339+ if exists && (!allowSameActionAliases || existingAction != action) {
360 validationErrors = append(validationErrors, fmt.Sprintf(340 validationErrors = append(validationErrors, fmt.Sprintf(
361 "duplicate key binding '%s' found in %s actions '%s' and '%s'",341 "duplicate key binding '%s' found in %s actions '%s' and '%s'",
362 canonical,342 canonical,
@@ -371,6 +351,50 @@ func validateModalModeActionsWithKeyCanon(
371 return validationErrors351 return validationErrors
372 }352 }
373 353
354+// validateVimBindingKey checks the Vim-specific rules of one canonical binding.
355+func validateVimBindingKey(modePath, action, canonical string) []string {
356+ sequence, err := vimkeys.ParseBinding(canonical)
357+ if err != nil {
358+ return nil
359+ }
360+ var validationErrors []string
361+ if len(sequence) > 0 && isVimCountStartKey(sequence[0]) {
362+ validationErrors = append(validationErrors, fmt.Sprintf(
363+ "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'",
364+ modePath,
365+ action,
366+ canonical,
367+ ))
368+ }
369+ legacyExit := action == "confirm" || action == "cancel"
370+ if legacyExit {
371+ return validationErrors
372+ }
373+ for _, key := range sequence {
374+ if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") {
375+ validationErrors = append(validationErrors, fmt.Sprintf(
376+ "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions",
377+ modePath,
378+ action,
379+ ))
380+ break
381+ }
382+ }
383+ return validationErrors
384+}
385+
386+// canonicalizeChordKey canonicalizes a single-chord key string the way the
387+// input layer resolves it (aliases, modifier spelling, letter case), so
388+// differently spelled bindings of one key compare equal. A string that is not a
389+// valid chord is returned unchanged: the input layer rejects it with a warning
390+// when it is loaded, and it cannot collide with a valid binding.
391+func canonicalizeChordKey(key string) (string, error) {
392+ if canonical, ok := keychord.Canonicalize(key); ok {
393+ return canonical, nil
394+ }
395+ return key, nil
396+}
397+
374 func isVimCountStartKey(key vimkeys.Key) bool {398 func isVimCountStartKey(key vimkeys.Key) bool {
375 return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'399 return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
376 }400 }
M internal/infrastructure/config/vim_mode_config_test.go +2 -11
@@ -357,16 +357,14 @@ func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
357 }357 }
358 }358 }
359 359
360-func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {360+func TestValidateVimMode_EmptyActionKeysDisableAction(t *testing.T) {
361 cfg := DefaultConfig()361 cfg := DefaultConfig()
362 // Override an action with no key bindings.362 // Override an action with no key bindings.
363 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{363 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
364 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},364 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
365 }365 }
366 366
367- err := validateConfig(cfg)367+ require.NoError(t, validateConfig(cfg))
368- require.Error(t, err)
369- assert.Contains(t, err.Error(), "workspace.vim_mode.actions.vim-scroll-left must have at least one key binding")
370 }368 }
371 369
372 func TestValidateModalModeActions_RegressionTable(t *testing.T) {370 func TestValidateModalModeActions_RegressionTable(t *testing.T) {
@@ -391,13 +389,6 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
391 actions: nil,389 actions: nil,
392 want: "workspace.pane_mode.actions cannot be empty",390 want: "workspace.pane_mode.actions cannot be empty",
393 },391 },
394- {
395- name: "tab empty keys",
396- modePath: "tab_mode",
397- timeout: 0,
398- actions: map[string]ActionBinding{"new-tab": {Keys: []string{}}},
399- want: "workspace.tab_mode.actions.new-tab must have at least one key binding",
400- },
401 {392 {
402 name: "page duplicate vim binding",393 name: "page duplicate vim binding",
403 modePath: "vim_mode",394 modePath: "vim_mode",
M internal/infrastructure/config/watcher.go +4 -12
@@ -87,20 +87,12 @@ func (m *Manager) reload() error {
87 return err87 return err
88 }88 }
89 89
90- m.transformLegacyConfig()90+ config, err := m.unmarshalConfig()
91-91+ if err != nil {
92- config := &Config{}
93- if err := m.viper.Unmarshal(config); err != nil {
94 return err92 return err
95 }93 }
96-94+ if err := ensureDatabasePath(config); err != nil {
97- // Set database path if not specified95+ return err
98- if config.Database.Path == "" {
99- dbPath, err := GetDatabaseFile()
100- if err != nil {
101- return fmt.Errorf("failed to get database path: %w", err)
102- }
103- config.Database.Path = dbPath
104 }96 }
105 97
106 normalizeConfig(config)98 normalizeConfig(config)
M internal/ui/app.go +2 -21
@@ -126,6 +126,7 @@ type App struct {
126 vimModePolicyUC *usecase.VimModePolicyUseCase126 vimModePolicyUC *usecase.VimModePolicyUseCase
127 vimNavigationUC *usecase.VimNavigationUseCase127 vimNavigationUC *usecase.VimNavigationUseCase
128 vimMode vimModeState128 vimMode vimModeState
129+ modeLegend modeLegendPreference
129 130
130 // Omnibox configuration (omnibox is created per workspace view)131 // Omnibox configuration (omnibox is created per workspace view)
131 omniboxCfg component.OmniboxConfig132 omniboxCfg component.OmniboxConfig
@@ -3929,26 +3930,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
3929 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))3930 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
3930 }3931 }
3931 3932
3932-// getModeToastClass returns the CSS class for the given mode's toast styling.
3933-func getModeToastClass(mode input.Mode) string {
3934- switch mode {
3935- case input.ModePane:
3936- return "toast-pane-mode"
3937- case input.ModeVim:
3938- return "toast-vim-mode"
3939- case input.ModeTab:
3940- return "toast-tab-mode"
3941- case input.ModeSession:
3942- return "toast-session-mode"
3943- case input.ModeResize:
3944- return "toast-resize-mode"
3945- case input.ModePrefix:
3946- return "toast-prefix-mode"
3947- default:
3948- return ""
3949- }
3950-}
3951-
3952 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.3933 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
3953 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {3934 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
3954 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {3935 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
@@ -4345,6 +4326,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
4345 a.runtimeConfig = newRuntimeConfigState(nil)4326 a.runtimeConfig = newRuntimeConfigState(nil)
4346 }4327 }
4347 a.runtimeConfig.Update(snapshot)4328 a.runtimeConfig.Update(snapshot)
4329+ a.modeLegend.configReloaded(snapshot.UI.Workspace.Styling.ModeLegendCollapsed)
4348 if a.contentCoord != nil {4330 if a.contentCoord != nil {
4349 a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts)4331 a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts)
4350 }4332 }
@@ -4385,7 +4367,6 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
4385 mode = bw.keyboardHandler.Mode()4367 mode = bw.keyboardHandler.Mode()
4386 }4368 }
4387 a.updateModeFrame(bw, mode)4369 a.updateModeFrame(bw, mode)
4388- a.updateModeIndicatorToaster(ctx, bw, mode)
4389 }4370 }
4390 }4371 }
4391 4372
M internal/ui/app_clipboard_test.go +3 -0
@@ -38,6 +38,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
38 HandlerDeps: port.HandlerDeps{38 HandlerDeps: port.HandlerDeps{
39 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },39 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
40 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },40 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
41+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
41 },42 },
42 Clipboard: nil,43 Clipboard: nil,
43 }44 }
@@ -66,6 +67,7 @@ func TestNew_ClosesHistoryRecorderWhenRegisterHandlersFails(t *testing.T) {
66 HandlerDeps: port.HandlerDeps{67 HandlerDeps: port.HandlerDeps{
67 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },68 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
68 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },69 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
70+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
69 },71 },
70 }72 }
71 73
@@ -101,6 +103,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
101 HandlerDeps: port.HandlerDeps{103 HandlerDeps: port.HandlerDeps{
102 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },104 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
103 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },105 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
106+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
104 },107 },
105 Clipboard: clipboard,108 Clipboard: clipboard,
106 }109 }
M internal/ui/app_mode_frame.go +126 -9
@@ -1,6 +1,10 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
4 "github.com/bnema/dumber/internal/ui/input"8 "github.com/bnema/dumber/internal/ui/input"
5 "github.com/bnema/dumber/internal/ui/layout"9 "github.com/bnema/dumber/internal/ui/layout"
6 )10 )
@@ -33,38 +37,151 @@ func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) {
33 return37 return
34 }38 }
35 a.setModeFrameTab(bw)39 a.setModeFrameTab(bw)
36- newTarget := a.modeFrameTarget(bw, mode)
37 // Rebuilds can temporarily lack a pane widget. Drop the old border while40 // Rebuilds can temporarily lack a pane widget. Drop the old border while
38 // the next focus/rebuild event retries the target.41 // the next focus/rebuild event retries the target.
39- bw.modeFrame.setTarget(newTarget, modeFrameClass(mode))42+ bw.modeFrame.setTarget(a.modeFrameTarget(bw, mode), modeFrameClass(mode))
40- if bw.modeFrame.visible {43+ if bw.modeFrame.target != nil {
41 bw.modeFrame.refreshGeometry()44 bw.modeFrame.refreshGeometry()
42 }45 }
43 }46 }
44 47
48+// updateModeFrame reconciles the window's mode border and mode toaster with
49+// mode and the live configuration.
45 func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {50 func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {
46- if bw == nil || bw.modeFrame == nil {51+ if bw == nil {
47 return52 return
48 }53 }
49 a.setModeFrameTab(bw)54 a.setModeFrameTab(bw)
50 cfg := a.runtimeConfigSnapshot().UI55 cfg := a.runtimeConfigSnapshot().UI
51- bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling,56+ if bw.modeFrame != nil {
52- cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))57+ bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode))
58+ }
59+ if bw.modeToaster != nil {
60+ styling := cfg.Workspace.Styling
61+ styling.ModeLegendCollapsed = a.modeLegendCollapsed()
62+ bw.modeToaster.apply(mode, styling, cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
63+ }
53 }64 }
54 65
55 func (a *App) setModeFrameTab(bw *browserWindow) {66 func (a *App) setModeFrameTab(bw *browserWindow) {
67+ if bw.modeFrame == nil {
68+ return
69+ }
56 tabID := bw.modeFrame.tabID70 tabID := bw.modeFrame.tabID
57 bw.modeFrame.tabID = ""71 bw.modeFrame.tabID = ""
58 if tab := a.activeTabForBrowserWindow(bw); tab != nil {72 if tab := a.activeTabForBrowserWindow(bw); tab != nil {
59 bw.modeFrame.tabID = tab.ID73 bw.modeFrame.tabID = tab.ID
60 }74 }
61 if tabID != bw.modeFrame.tabID {75 if tabID != bw.modeFrame.tabID {
62- bw.modeFrame.dismissLinger()76+ bw.modeToaster.dismissLinger()
63 }77 }
64 }78 }
65 79
66 func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {80 func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
67- if bw != nil && bw.modeFrame != nil {81+ if bw != nil && bw.modeToaster != nil {
68- bw.modeFrame.flash(action)82+ bw.modeToaster.flash(action)
83+ }
84+}
85+
86+// modeLegendPreference is the app-wide "legend collapsed" state, shared by
87+// every mode and window. A click is applied at once and saved in the
88+// background. It keeps priority over the configured value, which may still be
89+// the previous one while saves are in flight, until a configuration reload
90+// received after the writer drained settles it (see configReloaded).
91+type modeLegendPreference struct {
92+ mu sync.Mutex
93+ collapsed bool // last click; meaningful while clicked is set
94+ clicked bool
95+ // pending is the next value to save; saving reports a running writer. The
96+ // writer drains pending until it is empty, so saves go out in click order
97+ // and the file ends on the last click.
98+ pending *bool
99+ saving bool
100+}
101+
102+// current returns the state to show, given the configured one.
103+func (p *modeLegendPreference) current(configured bool) bool {
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
106+ if p.clicked {
107+ return p.collapsed
108+ }
109+ return configured
110+}
111+
112+// click records a click and returns whether a writer must be started.
113+func (p *modeLegendPreference) click(collapsed bool) (startWriter bool) {
114+ p.mu.Lock()
115+ defer p.mu.Unlock()
116+ p.clicked, p.collapsed = true, collapsed
117+ p.pending = &collapsed
118+ if p.saving {
119+ return false
120+ }
121+ p.saving = true
122+ return true
123+}
124+
125+// next hands the writer its next value, or ends it when none is left.
126+func (p *modeLegendPreference) next() (collapsed, ok bool) {
127+ p.mu.Lock()
128+ defer p.mu.Unlock()
129+ if p.pending == nil {
130+ p.saving = false
131+ return false, false
132+ }
133+ collapsed, p.pending = *p.pending, nil
134+ return collapsed, true
135+}
136+
137+// configReloaded hands the state back to the configuration on a reload
138+// received once the writer has drained: that reload reflects the saved clicks
139+// (or an external edit, or a failed save). Reloads received while the writer
140+// runs are ignored: they may come from an earlier save and would undo the
141+// latest click.
142+func (p *modeLegendPreference) configReloaded(_ bool) {
143+ p.mu.Lock()
144+ defer p.mu.Unlock()
145+ if !p.saving {
146+ p.clicked = false
69 }147 }
70 }148 }
149+
150+// modeLegendCollapsed returns the state new windows and mode changes start from.
151+func (a *App) modeLegendCollapsed() bool {
152+ return a.modeLegend.current(a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeLegendCollapsed)
153+}
154+
155+// setModeLegendCollapsed applies a click on a mode toaster: it updates every
156+// open window at once, then saves the choice off the GTK thread. A failed save
157+// is logged; the state stays applied until the next configuration reload.
158+func (a *App) setModeLegendCollapsed(ctx context.Context, collapsed bool) {
159+ for _, bw := range a.browserWindows {
160+ if bw != nil && bw.modeToaster != nil {
161+ bw.modeToaster.setCollapsed(collapsed)
162+ }
163+ }
164+ start := a.modeLegend.click(collapsed)
165+ if !start {
166+ return
167+ }
168+ var save func(context.Context, bool) error
169+ if a.deps != nil {
170+ save = a.deps.HandlerDeps.SaveModeLegendCollapsed
171+ }
172+ go func() {
173+ for {
174+ next, ok := a.modeLegend.next()
175+ if !ok {
176+ return
177+ }
178+ if save == nil {
179+ continue
180+ }
181+ if err := save(ctx, next); err != nil {
182+ logging.FromContext(ctx).Warn().Err(err).Bool("collapsed", next).
183+ Msg("failed to save mode legend collapsed preference")
184+ }
185+ }
186+ }()
187+}
M internal/ui/app_vim_mode_test.go +66 -121
@@ -1019,58 +1019,24 @@ func TestVimMode_CSSClassConstants(t *testing.T) {
1019 // Mode toaster1019 // Mode toaster
1020 // ============================================================================1020 // ============================================================================
1021 1021
1022-func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {1022+func enabledIndicatorSnapshot() entity.RuntimeConfigSnapshot {
1023- t.Helper()1023+ return entity.RuntimeConfigSnapshot{
1024-1024+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1025- container := mocks.NewMockBoxWidget(t)1025+ Styling: entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"},
1026- label := mocks.NewMockLabelWidget(t)1026+ }},
1027- factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()1027+ }
1028- container.EXPECT().AddCssClass("toast").Once()
1029- container.EXPECT().AddCssClass("toast-info").Once()
1030- container.EXPECT().SetHalign(mock.Anything).Once()
1031- container.EXPECT().SetValign(mock.Anything).Once()
1032- container.EXPECT().SetHexpand(false).Once()
1033- container.EXPECT().SetVexpand(false).Once()
1034- container.EXPECT().SetCanTarget(false).Once()
1035- container.EXPECT().SetCanFocus(false).Once()
1036- container.EXPECT().SetVisible(false).Once()
1037- factory.EXPECT().NewLabel("").Return(label).Once()
1038- label.EXPECT().SetCanTarget(false).Once()
1039- label.EXPECT().SetCanFocus(false).Once()
1040- container.EXPECT().Append(label).Once()
1041-
1042- return component.NewToaster(factory), container, label
1043-}
1044-
1045-func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1046- container.EXPECT().RemoveCssClass("toast-info").Once()
1047- container.EXPECT().AddCssClass("toast-vim-mode").Once()
1048- container.EXPECT().SetHalign(mock.Anything).Once()
1049- container.EXPECT().SetValign(mock.Anything).Once()
1050- label.EXPECT().SetText("VIM MODE").Once()
1051- container.EXPECT().SetVisible(true).Once()
1052 }1028 }
1053 1029
1054 func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {1030 func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1055- factory := mocks.NewMockWidgetFactory(t)1031+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1056- toaster, container, label := newModeToasterForVimModeTest(t, factory)1032+ bw, view := newFakeModeToasterWindow("win-1")
1057- expectVimModeToastShown(container, label)
1058- // These optional calls are made only by the old brief auto-dismiss path.
1059- container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1060- container.EXPECT().SetVisible(false).Maybe()
1061-
1062- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1063- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1064- ModeIndicatorToasterEnabled: true,
1065- }}},
1066- })}
1067- bw := &browserWindow{id: "win-1", modeToaster: toaster}
1068 app.browserWindows = map[string]*browserWindow{bw.id: bw}1033 app.browserWindows = map[string]*browserWindow{bw.id: bw}
1069- app.lastFocusedWindowID = bw.id
1070 1034
1071- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)1035+ app.updateModeFrame(bw, input.ModeVim)
1072- require.True(t, toaster.IsVisible())1036+ require.True(t, view.pillShown)
1037+ require.Equal(t, "VIM MODE", view.header)
1073 1038
1039+ // Nothing but a mode change hides it: no timer dismisses the persistent pill.
1074 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)1040 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
1075 mainContext := glib.MainContextDefault()1041 mainContext := glib.MainContextDefault()
1076 for time.Now().Before(deadline) {1042 for time.Now().Before(deadline) {
@@ -1079,116 +1045,95 @@ func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1079 mainContext.Iteration(false)1045 mainContext.Iteration(false)
1080 }1046 }
1081 }1047 }
1082-1048+ assert.True(t, view.pillShown, "Vim Mode pill must remain visible until mode exit")
1083- assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1084 }1049 }
1085 1050
1086 func TestVimMode_ModeExitHidesToaster(t *testing.T) {1051 func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1087- factory := mocks.NewMockWidgetFactory(t)1052+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1088- toaster, container, label := newModeToasterForVimModeTest(t, factory)1053+ bw, view := newFakeModeToasterWindow("win-1")
1089- expectVimModeToastShown(container, label)1054+
1090- container.EXPECT().RemoveCssClass("toast-custom").Once()1055+ app.updateModeFrame(bw, input.ModeVim)
1091- container.EXPECT().RemoveCssClass("toast-vim-mode").Once()1056+ require.True(t, view.pillShown)
1092- container.EXPECT().SetVisible(false).Once()1057+ app.updateModeFrame(bw, input.ModeNormal)
1093 1058
1059+ assert.False(t, view.pillShown, "leaving Vim Mode must hide its persistent pill")
1060+}
1061+
1062+func TestVimMode_DisabledIndicatorWithLegendOffShowsNothing(t *testing.T) {
1094 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{1063 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1095- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{1064+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1096- ModeIndicatorToasterEnabled: true,1065+ Styling: entity.WorkspaceStylingConfig{ModeLegend: "off"},
1097- }}},1066+ }},
1098 })}1067 })}
1099- bw := &browserWindow{id: "win-1", modeToaster: toaster}1068+ bw, view := newFakeModeToasterWindow("win-1")
1100 1069
1101- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)1070+ app.updateModeFrame(bw, input.ModeVim)
1102- require.True(t, toaster.IsVisible())
1103- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1104 1071
1105- assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")1072+ assert.False(t, view.pillShown)
1073+ assert.False(t, view.bodyShown)
1106 }1074 }
1107 1075
1108-func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {1076+func TestVimMode_DisabledIndicatorKeepsPillAsLegendHeader(t *testing.T) {
1109- factory := mocks.NewMockWidgetFactory(t)1077+ snapshot := entity.RuntimeConfigSnapshot{
1110- toaster, _, _ := newModeToasterForVimModeTest(t, factory)1078+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1111- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}1079+ Styling: entity.WorkspaceStylingConfig{ModeLegend: "always"},
1112- bw := &browserWindow{id: "win-1", modeToaster: toaster}1080+ VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}},
1081+ }},
1082+ }
1083+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)}
1084+ bw, view := newFakeModeToasterWindow("win-1")
1113 1085
1114- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)1086+ app.updateModeFrame(bw, input.ModeVim)
1115 1087
1116- assert.False(t, toaster.IsVisible())1088+ assert.True(t, view.pillShown, "the pill is the legend header even when the indicator is disabled")
1089+ assert.True(t, view.bodyShown)
1117 }1090 }
1118 1091
1119 func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {1092 func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1120- factory := mocks.NewMockWidgetFactory(t)1093+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1121- toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)1094+ bw1, view1 := newFakeModeToasterWindow("win-1")
1122- toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)1095+ bw2, view2 := newFakeModeToasterWindow("win-2")
1123- expectVimModeToastShown(container1, label1)
1124- expectVimModeToastShown(container2, label2)
1125- container1.EXPECT().RemoveCssClass("toast-custom").Once()
1126- container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1127- container1.EXPECT().SetVisible(false).Once()
1128-
1129- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1130- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1131- ModeIndicatorToasterEnabled: true,
1132- }}},
1133- })}
1134- bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1135- bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1136 1096
1137- app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)1097+ app.updateModeFrame(bw1, input.ModeVim)
1138- app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)1098+ app.updateModeFrame(bw2, input.ModeVim)
1139- app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)1099+ app.updateModeFrame(bw1, input.ModeNormal)
1140 1100
1141- assert.False(t, toaster1.IsVisible())1101+ assert.False(t, view1.pillShown)
1142- assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")1102+ assert.True(t, view2.pillShown, "exiting one window must not hide another window's pill")
1143 }1103 }
1144 1104
1145 func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {1105 func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1146- factory := mocks.NewMockWidgetFactory(t)
1147- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1148- expectVimModeToastShown(container, label)
1149- container.EXPECT().RemoveCssClass("toast-custom").Once()
1150- container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1151- container.EXPECT().SetVisible(false).Once()
1152-
1153- enabled := entity.RuntimeConfigSnapshot{
1154- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1155- ModeIndicatorToasterEnabled: true,
1156- }}},
1157- }
1158 keyboardHandler := newKeyboardHandlerInVimMode(t)1106 keyboardHandler := newKeyboardHandlerInVimMode(t)
1159- bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}1107+ bw, view := newFakeModeToasterWindow("win-1")
1108+ bw.keyboardHandler = keyboardHandler
1160 app := &App{1109 app := &App{
1161- runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled),1110+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot()),
1162 browserWindows: map[string]*browserWindow{bw.id: bw},1111 browserWindows: map[string]*browserWindow{bw.id: bw},
1163 }1112 }
1164 1113
1165- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)1114+ app.updateModeFrame(bw, input.ModeVim)
1166- require.True(t, toaster.IsVisible())1115+ require.True(t, view.pillShown)
1167- app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})1116+ disabled := enabledIndicatorSnapshot()
1117+ disabled.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
1118+ app.applyRuntimeConfigChange(context.Background(), disabled)
1168 1119
1169- assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")1120+ assert.False(t, view.pillShown, "runtime preference disable must reconcile an existing pill")
1170 }1121 }
1171 1122
1172 func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {1123 func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1173- factory := mocks.NewMockWidgetFactory(t)
1174- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1175- expectVimModeToastShown(container, label)
1176-
1177 keyboardHandler := newKeyboardHandlerInVimMode(t)1124 keyboardHandler := newKeyboardHandlerInVimMode(t)
1178- bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}1125+ bw, view := newFakeModeToasterWindow("win-1")
1126+ bw.keyboardHandler = keyboardHandler
1127+ off := enabledIndicatorSnapshot()
1128+ off.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
1179 app := &App{1129 app := &App{
1180- runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),1130+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(off),
1181 browserWindows: map[string]*browserWindow{bw.id: bw},1131 browserWindows: map[string]*browserWindow{bw.id: bw},
1182 }1132 }
1183- enabled := entity.RuntimeConfigSnapshot{
1184- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1185- ModeIndicatorToasterEnabled: true,
1186- }}},
1187- }
1188 1133
1189- app.applyRuntimeConfigChange(context.Background(), enabled)1134+ app.applyRuntimeConfigChange(context.Background(), enabledIndicatorSnapshot())
1190 1135
1191- assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")1136+ assert.True(t, view.pillShown, "runtime preference enable must reconcile active Vim Mode")
1192 }1137 }
1193 1138
1194 // ============================================================================1139 // ============================================================================
M internal/ui/app_vim_page_test.go +14 -25
@@ -5,41 +5,31 @@ import (
5 "testing"5 "testing"
6 "time"6 "time"
7 7
8- "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
10 9
11 "github.com/bnema/dumber/internal/application/dto"10 "github.com/bnema/dumber/internal/application/dto"
12 portmocks "github.com/bnema/dumber/internal/application/port/mocks"11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/ui/input"13 "github.com/bnema/dumber/internal/ui/input"
15- "github.com/bnema/dumber/internal/ui/layout/mocks"
16 )14 )
17 15
18-// newVimPageTestWindow builds a window in Vim Mode with a real mode toaster16+// newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound
19-// bound to the pending-sequence callback, as initChrome does. It records every17+// to the pending-sequence callback, as initChrome does. It records every header
20-// text shown on the toast so tests can assert what the user sees.18+// the pill shows so tests can assert what the user sees.
21 func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {19 func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
22 t.Helper()20 t.Helper()
23- factory := mocks.NewMockWidgetFactory(t)
24- toaster, container, label := newModeToasterForVimModeTest(t, factory)
25- shown := &[]string{}
26- container.EXPECT().RemoveCssClass(mock.Anything).Maybe()
27- container.EXPECT().AddCssClass(mock.Anything).Maybe()
28- container.EXPECT().SetHalign(mock.Anything).Maybe()
29- container.EXPECT().SetValign(mock.Anything).Maybe()
30- container.EXPECT().SetVisible(mock.Anything).Maybe()
31- label.EXPECT().SetText(mock.Anything).Run(func(text string) { *shown = append(*shown, text) }).Maybe()
32-
33 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{21 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
34 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{22 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
35 ModeIndicatorToasterEnabled: true,23 ModeIndicatorToasterEnabled: true,
24+ ModeLegend: "off",
36 }}},25 }}},
37 })}26 })}
38- bw := &browserWindow{id: "win-1", modeToaster: toaster}27+ bw, view := newFakeModeToasterWindow("win-1")
39 app.browserWindows = map[string]*browserWindow{bw.id: bw}28 app.browserWindows = map[string]*browserWindow{bw.id: bw}
40 bw.keyboardHandler = newKeyboardHandlerInVimMode(t)29 bw.keyboardHandler = newKeyboardHandlerInVimMode(t)
41 app.bindVimModeSequenceToaster(context.Background(), bw)30 app.bindVimModeSequenceToaster(context.Background(), bw)
42- return app, bw, shown31+ app.updateModeFrame(bw, input.ModeVim)
32+ return app, bw, &view.headers
43 }33 }
44 34
45 var visualOutcome = hintsOutcome(dto.VimPageVisual)35 var visualOutcome = hintsOutcome(dto.VimPageVisual)
@@ -83,16 +73,15 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
83 // restored when the interaction ends.73 // restored when the interaction ends.
84 func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {74 func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
85 app, bw, _ := newVimPageTestWindow(t)75 app, bw, _ := newVimPageTestWindow(t)
86- frame := &modeFrame{mode: input.ModeVim, visible: true}76+ toaster := bw.modeToaster
87- bw.modeFrame = frame
88 wv := portmocks.NewMockWebView(t)77 wv := portmocks.NewMockWebView(t)
89 78
90 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)79 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
91- require.True(t, frame.suspended, "legend must be suspended while the page captures keys")80+ require.True(t, toaster.suspended, "legend must be suspended while the page captures keys")
92 81
93 wv.EXPECT().ID().Return(1).Maybe()82 wv.EXPECT().ID().Return(1).Maybe()
94 app.endVimPageInteraction(context.Background(), bw, true)83 app.endVimPageInteraction(context.Background(), bw, true)
95- require.False(t, frame.suspended, "legend must be restored when the interaction ends")84+ require.False(t, toaster.suspended, "legend must be restored when the interaction ends")
96 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())85 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
97 }86 }
98 87
@@ -101,8 +90,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
101 // suspended legend stay in place.90 // suspended legend stay in place.
102 func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {91 func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
103 app, bw, shown := newVimPageTestWindow(t)92 app, bw, shown := newVimPageTestWindow(t)
104- frame := &modeFrame{mode: input.ModeVim, visible: true}93+ toaster := bw.modeToaster
105- bw.modeFrame = frame
106 wv := portmocks.NewMockWebView(t)94 wv := portmocks.NewMockWebView(t)
107 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)95 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
108 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])96 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
@@ -117,16 +105,17 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
117 require.Equal(t, want, (*shown)[len(*shown)-1])105 require.Equal(t, want, (*shown)[len(*shown)-1])
118 require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)106 require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)
119 require.NotNil(t, bw.vimPageInteractionWebView)107 require.NotNil(t, bw.vimPageInteractionWebView)
120- require.True(t, frame.suspended, "legend stays hidden while the page owns the keys")108+ require.True(t, toaster.suspended, "legend stays hidden while the page owns the keys")
121 }109 }
122 }110 }
123 111
124 func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {112 func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
125 app, bw, shown := newVimPageTestWindow(t)113 app, bw, shown := newVimPageTestWindow(t)
114+ before := len(*shown)
126 115
127 app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)116 app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)
128 117
129- require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended")118+ require.Len(t, *shown, before, "a late mode report must not repaint the indicator once the interaction ended")
130 }119 }
131 120
132 // exitModeWithin runs fn and fails the test if it does not return in time,121 // exitModeWithin runs fn and fails the test if it does not return in time,
M internal/ui/browser_window.go +18 -19
@@ -35,7 +35,7 @@ type browserWindow struct {
35 tabs *entity.TabList // per-window tab list (single source of truth for tab state)35 tabs *entity.TabList // per-window tab list (single source of truth for tab state)
36 mainWindow *window.MainWindow36 mainWindow *window.MainWindow
37 appToaster *component.Toaster37 appToaster *component.Toaster
38- modeToaster *component.Toaster38+ modeToaster *modeToaster
39 touchpadNavIndicator *component.TouchpadNavigationIndicator39 touchpadNavIndicator *component.TouchpadNavigationIndicator
40 modeFrame *modeFrame40 modeFrame *modeFrame
41 sessionManager *component.SessionManager41 sessionManager *component.SessionManager
@@ -104,8 +104,11 @@ func (bw *browserWindow) clearShellState() {
104 bw.touchpadNavIndicator.Destroy()104 bw.touchpadNavIndicator.Destroy()
105 }105 }
106 bw.appToaster = nil106 bw.appToaster = nil
107- bw.modeToaster = nil
108 bw.touchpadNavIndicator = nil107 bw.touchpadNavIndicator = nil
108+ if bw.modeToaster != nil {
109+ bw.modeToaster.destroy()
110+ bw.modeToaster = nil
111+ }
109 if bw.modeFrame != nil {112 if bw.modeFrame != nil {
110 bw.modeFrame.destroy()113 bw.modeFrame.destroy()
111 bw.modeFrame = nil114 bw.modeFrame = nil
@@ -141,7 +144,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
141 // no hidden-panel queries or widget row rebuilds.144 // no hidden-panel queries or widget row rebuilds.
142 bw.initToasterOverlay(a)145 bw.initToasterOverlay(a)
143 bw.initTouchpadNavigationIndicator()146 bw.initTouchpadNavigationIndicator()
144- bw.initBorderOverlay(a)147+ bw.initBorderOverlay(ctx, a)
145 bw.initAccentPicker(ctx, a)148 bw.initAccentPicker(ctx, a)
146 bw.initSessionManager(ctx, a)149 bw.initSessionManager(ctx, a)
147 bw.initTabPicker(ctx, a)150 bw.initTabPicker(ctx, a)
@@ -160,15 +163,6 @@ func (bw *browserWindow) initToasterOverlay(a *App) {
160 }163 }
161 }164 }
162 }165 }
163-
164- bw.modeToaster = component.NewToaster(a.widgetFactory)
165- if bw.modeToaster != nil {
166- if widget := bw.modeToaster.Widget(); widget != nil {
167- if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
168- bw.mainWindow.AddOverlay(gtkWidget)
169- }
170- }
171- }
172 }166 }
173 167
174 func (bw *browserWindow) initTouchpadNavigationIndicator() {168 func (bw *browserWindow) initTouchpadNavigationIndicator() {
@@ -184,12 +178,19 @@ func (bw *browserWindow) initTouchpadNavigationIndicator() {
184 }178 }
185 }179 }
186 180
187-func (bw *browserWindow) initBorderOverlay(a *App) {181+// initBorderOverlay creates the mode border and the mode toaster, which share
182+// the window's content overlay.
183+func (bw *browserWindow) initBorderOverlay(ctx context.Context, a *App) {
188 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {184 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
189 return185 return
190 }186 }
191 187
192- bw.modeFrame = newModeFrame(bw.mainWindow.ContentOverlay(), bw.mainWindow.Window())188+ overlay := bw.mainWindow.ContentOverlay()
189+ bw.modeFrame = newModeFrame(overlay)
190+ collapsed := a.modeLegendCollapsed()
191+ bw.modeToaster = newModeToaster(overlay, bw.mainWindow.Window(), collapsed, func(next bool) {
192+ a.setModeLegendCollapsed(ctx, next)
193+ })
193 }194 }
194 195
195 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {196 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
@@ -359,13 +360,11 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
359 360
360 a.releaseFloatingSessionsForTab(ctx, tab.ID)361 a.releaseFloatingSessionsForTab(ctx, tab.ID)
361 if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID {362 if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID {
362- bw.modeFrame.cancelTimers()
363- bw.modeFrame.dismissLinger()
364- bw.modeFrame.generation++
365 bw.modeFrame.setTarget(nil, "")363 bw.modeFrame.setTarget(nil, "")
366 bw.modeFrame.tabID = ""364 bw.modeFrame.tabID = ""
367- bw.modeFrame.hide()365+ // The legend belonged to the released workspace; the header stays for
368- a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode)366+ // the mode.
367+ bw.modeToaster.release()
369 }368 }
370 if a.contentCoord != nil && tab.Workspace != nil {369 if a.contentCoord != nil && tab.Workspace != nil {
371 for _, pane := range tab.Workspace.AllPanes() {370 for _, pane := range tab.Workspace.AllPanes() {
M internal/ui/browser_window_test.go +1 -1
@@ -34,7 +34,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
34 }34 }
35 35
36 setShellField(t, removed, "appToaster", &component.Toaster{})36 setShellField(t, removed, "appToaster", &component.Toaster{})
37- setShellField(t, removed, "modeToaster", &component.Toaster{})37+ setShellField(t, removed, "modeToaster", &modeToaster{})
38 setShellField(t, removed, "modeFrame", &modeFrame{})38 setShellField(t, removed, "modeFrame", &modeFrame{})
39 setShellField(t, removed, "sessionManager", &component.SessionManager{})39 setShellField(t, removed, "sessionManager", &component.SessionManager{})
40 setShellField(t, removed, "tabPicker", &component.TabPicker{})40 setShellField(t, removed, "tabPicker", &component.TabPicker{})
M internal/ui/component/toaster.go +0 -31
@@ -80,9 +80,6 @@ type ToastOptions struct {
80 TextColor string80 TextColor string
81 // Position determines where the toast appears on screen.81 // Position determines where the toast appears on screen.
82 Position ToastPosition82 Position ToastPosition
83- // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode").
84- // When set, this class is applied atomically with Show() to avoid visual flicker.
85- ModeClass string
86 }83 }
87 84
88 // ToastOption is a functional option for configuring toast display.85 // ToastOption is a functional option for configuring toast display.
@@ -117,14 +114,6 @@ func WithPosition(pos ToastPosition) ToastOption {
117 }114 }
118 }115 }
119 116
120-// WithModeClass sets a mode-specific CSS class for styling.
121-// This is applied atomically within Show() to avoid visual flicker.
122-func WithModeClass(class string) ToastOption {
123- return func(o *ToastOptions) {
124- o.ModeClass = class
125- }
126-}
127-
128 // defaultToastOptions returns the default toast options.117 // defaultToastOptions returns the default toast options.
129 func defaultToastOptions() ToastOptions {118 func defaultToastOptions() ToastOptions {
130 return ToastOptions{119 return ToastOptions{
@@ -150,7 +139,6 @@ type Toaster struct {
150 hasCustomStyle bool139 hasCustomStyle bool
151 customBgColor string // Current custom background color140 customBgColor string // Current custom background color
152 customTextColor string // Current custom text color141 customTextColor string // Current custom text color
153- customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode")
154 customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors142 customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
155 143
156 mu sync.Mutex144 mu sync.Mutex
@@ -223,11 +211,6 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op
223 // Apply custom colors if specified211 // Apply custom colors if specified
224 t.applyCustomStyle(options)212 t.applyCustomStyle(options)
225 213
226- // Apply mode class if specified (atomically to avoid flicker)
227- if options.ModeClass != "" {
228- t.applyModeClassInternal(options.ModeClass)
229- }
230-
231 // Apply position214 // Apply position
232 t.applyPosition(options.Position)215 t.applyPosition(options.Position)
233 216
@@ -385,11 +368,6 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) {
385 func (t *Toaster) clearCustomStyle() {368 func (t *Toaster) clearCustomStyle() {
386 if t.hasCustomStyle {369 if t.hasCustomStyle {
387 t.container.RemoveCssClass("toast-custom")370 t.container.RemoveCssClass("toast-custom")
388- // Remove mode-specific class if one was applied
389- if t.customModeClass != "" {
390- t.container.RemoveCssClass(t.customModeClass)
391- t.customModeClass = ""
392- }
393 // Remove custom CSS provider if one was added371 // Remove custom CSS provider if one was added
394 if t.customCSSProvider != nil {372 if t.customCSSProvider != nil {
395 styleCtx := t.container.GtkWidget().GetStyleContext()373 styleCtx := t.container.GtkWidget().GetStyleContext()
@@ -407,15 +385,6 @@ func (t *Toaster) clearCustomStyle() {
407 }385 }
408 }386 }
409 387
410-// applyModeClassInternal applies mode class without locking (must be called with lock held).
411-func (t *Toaster) applyModeClassInternal(modeClass string) {
412- // Remove level class and add mode class
413- t.container.RemoveCssClass(t.currentLevel.cssClass())
414- t.container.AddCssClass(modeClass)
415- t.hasCustomStyle = true
416- t.customModeClass = modeClass // Store class name for cleanup tracking
417-}
418-
419 // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.388 // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
420 // Must be called with lock held.389 // Must be called with lock held.
421 func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {390 func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
M internal/ui/deps.go +3 -0
@@ -130,6 +130,9 @@ func (d *Dependencies) Validate() error {
130 if d.HandlerDeps.SaveOmniboxInitialBehavior == nil {130 if d.HandlerDeps.SaveOmniboxInitialBehavior == nil {
131 return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior")131 return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior")
132 }132 }
133+ if d.HandlerDeps.SaveModeLegendCollapsed == nil {
134+ return ErrMissingDependency("HandlerDeps.SaveModeLegendCollapsed")
135+ }
133 // Use cases are optional - can be nil if not needed136 // Use cases are optional - can be nil if not needed
134 return nil137 return nil
135 }138 }
A internal/ui/input/config_completion_test.go +150 -0
@@ -0,0 +1,150 @@
1+package input
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/puregotk/v4/gdk"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// loadShortcutsFromTOML loads a config file through the real config loader
16+// (legacy transforms, default completion, validation) and builds the shortcut
17+// set the application would build from it.
18+func loadShortcutsFromTOML(t *testing.T, content string) *ShortcutSet {
19+ t.Helper()
20+
21+ home := t.TempDir()
22+ t.Setenv("HOME", home)
23+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
24+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
25+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
26+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
27+
28+ configFile, err := config.GetConfigFile()
29+ require.NoError(t, err)
30+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
31+ require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
32+
33+ mgr, err := config.NewManager()
34+ require.NoError(t, err)
35+ require.NoError(t, mgr.Load())
36+
37+ cfg := mgr.Get()
38+ return NewShortcutSet(context.Background(), &cfg.Workspace, &cfg.Session)
39+}
40+
41+func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) {
42+ return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal)
43+}
44+
45+func TestConfigCompletion_UserKeyConflictIsNotResurrectedByInput(t *testing.T) {
46+ // ctrl+r is a default key of reload, so completion leaves reload out. The
47+ // input layer must not bind reload's other default key (f5) on its own.
48+ set := loadShortcutsFromTOML(t, `
49+[workspace.shortcuts.actions.new-tab]
50+keys = ["ctrl+r"]
51+`)
52+
53+ action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl)
54+ require.True(t, ok)
55+ assert.Equal(t, ActionNewTab, action)
56+
57+ _, ok = lookupGlobal(set, uint(gdk.KEY_F5), ModNone)
58+ assert.False(t, ok, "f5 belongs to the skipped reload default and must stay unbound")
59+
60+ action, ok = lookupGlobal(set, uint(gdk.KEY_l), ModCtrl)
61+ require.True(t, ok, "unrelated defaults are still completed")
62+ assert.Equal(t, ActionOpenOmnibox, action)
63+}
64+
65+func TestConfigCompletion_DisabledBuiltinStaysDisabled(t *testing.T) {
66+ set := loadShortcutsFromTOML(t, `
67+[workspace.shortcuts.actions.open-find]
68+keys = []
69+`)
70+
71+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
72+ assert.False(t, ok)
73+
74+ action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl)
75+ require.True(t, ok)
76+ assert.Equal(t, ActionOpenOmnibox, action)
77+}
78+
79+func TestConfigCompletion_AliasSpellingBlocksDefaultAndInputAgrees(t *testing.T) {
80+ set := loadShortcutsFromTOML(t, `
81+[workspace.pane_mode.actions.close-pane]
82+keys = ["right"]
83+`)
84+
85+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_Right)}, ModePane)
86+ require.True(t, ok)
87+ assert.Equal(t, ActionClosePane, action, "the user binding wins deterministically over split-right's arrowright")
88+
89+ action, ok = set.Lookup(KeyBinding{Keyval: uint('r')}, ModePane)
90+ assert.False(t, ok, "split-right is skipped entirely, including its other key %v", action)
91+}
92+
93+func TestConfigCompletion_UnderscoreNamesWorkEndToEnd(t *testing.T) {
94+ set := loadShortcutsFromTOML(t, `
95+[workspace.pane_mode.actions.stack_pane]
96+keys = ["ctrl+alt+f12"]
97+
98+[workspace.pane_mode.actions.focus_left]
99+keys = ["ctrl+alt+f11"]
100+
101+[workspace.resize_mode.actions.resize_increase_left]
102+keys = ["z"]
103+
104+[workspace.prefix_mode.actions.enter_resize_mode]
105+keys = ["ctrl+alt+f10"]
106+
107+[workspace.tab_mode.actions.new_tab]
108+keys = ["ctrl+alt+f9"]
109+
110+[workspace.shortcuts.actions.toggle_floating_pane]
111+keys = ["alt+g"]
112+
113+[workspace.shortcuts.actions.open_find]
114+keys = ["ctrl+shift+f"]
115+`)
116+
117+ paneBinding := func(keyval uint) KeyBinding {
118+ return KeyBinding{Keyval: keyval, Modifiers: ModCtrl | ModAlt}
119+ }
120+
121+ tests := []struct {
122+ name string
123+ binding KeyBinding
124+ mode Mode
125+ want Action
126+ }{
127+ {"pane stack_pane", paneBinding(uint(gdk.KEY_F12)), ModePane, ActionStackPane},
128+ {"pane focus_left", paneBinding(uint(gdk.KEY_F11)), ModePane, ActionFocusLeft},
129+ {"resize resize_increase_left", KeyBinding{Keyval: uint('z')}, ModeResize, ActionResizeIncreaseLeft},
130+ {"prefix enter_resize_mode", paneBinding(uint(gdk.KEY_F10)), ModePrefix, ActionEnterResizeMode},
131+ {"tab new_tab", paneBinding(uint(gdk.KEY_F9)), ModeTab, ActionNewTab},
132+ {"global toggle_floating_pane", KeyBinding{Keyval: uint('g'), Modifiers: ModAlt}, ModeNormal, ActionToggleFloatingPane},
133+ {"global open_find", KeyBinding{Keyval: uint('f'), Modifiers: ModCtrl | ModShift}, ModeNormal, ActionOpenFind},
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ got, ok := set.Lookup(tt.binding, tt.mode)
138+ require.True(t, ok)
139+ assert.Equal(t, tt.want, got)
140+ })
141+ }
142+
143+ // The underscore-named user bindings replaced the defaults they renamed.
144+ _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl)
145+ assert.False(t, ok, "default ctrl+f is replaced by the user's open_find binding")
146+ _, ok = set.Lookup(KeyBinding{Keyval: uint('s')}, ModePane)
147+ assert.False(t, ok, "default stack-pane key is replaced by the user's stack_pane binding")
148+ _, ok = set.Lookup(KeyBinding{Keyval: uint('h')}, ModeResize)
149+ assert.False(t, ok, "default resize-increase-left key is replaced")
150+}
M internal/ui/input/floating_shortcuts_test.go +4 -3
@@ -16,9 +16,10 @@ func defaultWorkspaceConfig() *entity.WorkspaceConfig {
16 return &entity.WorkspaceConfig{16 return &entity.WorkspaceConfig{
17 Shortcuts: entity.GlobalShortcutsConfig{17 Shortcuts: entity.GlobalShortcutsConfig{
18 Actions: map[string]entity.ActionBinding{18 Actions: map[string]entity.ActionBinding{
19- "toggle_floating_pane": {Keys: []string{"alt+f"}},19+ "toggle-floating-pane": {Keys: []string{"alt+f"}},
20- "consume_or_expel_left": {Keys: []string{"alt+["}},20+ "consume-or-expel-left": {Keys: []string{"alt+["}},
21- "consume_or_expel_right": {Keys: []string{"alt+]"}},21+ "consume-or-expel-right": {Keys: []string{"alt+]"}},
22+ "open-omnibox": {Keys: []string{"ctrl+l"}},
22 },23 },
23 },24 },
24 }25 }
M internal/ui/input/global_shortcuts.go +0 -9
@@ -504,23 +504,14 @@ func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
504 504
505 func globalShortcutActionMap() map[string]Action {505 func globalShortcutActionMap() map[string]Action {
506 return map[string]Action{506 return map[string]Action{
507- "toggle_floating_pane": ActionToggleFloatingPane,
508 "toggle-floating-pane": ActionToggleFloatingPane,507 "toggle-floating-pane": ActionToggleFloatingPane,
509- "consume_or_expel_left": ActionConsumeOrExpelLeft,
510 "consume-or-expel-left": ActionConsumeOrExpelLeft,508 "consume-or-expel-left": ActionConsumeOrExpelLeft,
511- "consume_or_expel_right": ActionConsumeOrExpelRight,
512 "consume-or-expel-right": ActionConsumeOrExpelRight,509 "consume-or-expel-right": ActionConsumeOrExpelRight,
513- "consume_or_expel_up": ActionConsumeOrExpelUp,
514 "consume-or-expel-up": ActionConsumeOrExpelUp,510 "consume-or-expel-up": ActionConsumeOrExpelUp,
515- "consume_or_expel_down": ActionConsumeOrExpelDown,
516 "consume-or-expel-down": ActionConsumeOrExpelDown,511 "consume-or-expel-down": ActionConsumeOrExpelDown,
517- "toggle_history_systemview": ActionToggleHistorySystemView,
518 "toggle-history-systemview": ActionToggleHistorySystemView,512 "toggle-history-systemview": ActionToggleHistorySystemView,
519- "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
520 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,513 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
521- "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,514 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523- "toggle_config_systemview": ActionToggleConfigSystemView,
524 "toggle-config-systemview": ActionToggleConfigSystemView,515 "toggle-config-systemview": ActionToggleConfigSystemView,
525 }516 }
526 }517 }
M internal/ui/input/keyboard_test.go +2 -1
@@ -16,6 +16,7 @@ import (
16 // Used by tests that construct a KeyboardHandler.16 // Used by tests that construct a KeyboardHandler.
17 func newTestWorkspace() *entity.WorkspaceConfig {17 func newTestWorkspace() *entity.WorkspaceConfig {
18 return &entity.WorkspaceConfig{18 return &entity.WorkspaceConfig{
19+ Shortcuts: entity.GlobalShortcutsConfig{Actions: entity.BuiltinGlobalShortcuts()},
19 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},20 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
20 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},21 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
21 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},22 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
@@ -331,7 +332,7 @@ func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing
331 func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {332 func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
332 workspace := newTestWorkspace()333 workspace := newTestWorkspace()
333 workspace.Shortcuts.Actions = map[string]entity.ActionBinding{334 workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
334- "toggle_floating_pane": {Keys: []string{"alt+f"}},335+ "toggle-floating-pane": {Keys: []string{"alt+f"}},
335 }336 }
336 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())337 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
337 calls := 0338 calls := 0
M internal/ui/input/prefix_mode_test.go +4 -6
@@ -108,12 +108,10 @@ func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
108 assert.Equal(t, ModeNormal, h.Mode())108 assert.Equal(t, ModeNormal, h.Mode())
109 }109 }
110 110
111-func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {111+func TestShortcutSet_BuiltinShortcutsAndOverrides(t *testing.T) {
112 ws := newTestWorkspace()112 ws := newTestWorkspace()
113- ws.Shortcuts.Actions = map[string]entity.ActionBinding{113+ ws.Shortcuts.Actions["reload"] = entity.ActionBinding{Keys: []string{}} // disabled by user
114- "reload": {Keys: []string{}}, // disabled by user114+ ws.Shortcuts.Actions["quit"] = entity.ActionBinding{Keys: []string{"ctrl+shift+q"}} // rebound by user
115- "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user
116- }
117 set := NewShortcutSet(context.Background(), ws, newTestSession())115 set := NewShortcutSet(context.Background(), ws, newTestSession())
118 116
119 lookup := func(key string) (Action, bool) {117 lookup := func(key string) (Action, bool) {
@@ -124,7 +122,7 @@ func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
124 }122 }
125 123
126 action, ok := lookup("ctrl+l")124 action, ok := lookup("ctrl+l")
127- assert.True(t, ok, "missing builtin action falls back to its default key")125+ assert.True(t, ok, "untouched builtin action keeps its default key")
128 assert.Equal(t, ActionOpenOmnibox, action)126 assert.Equal(t, ActionOpenOmnibox, action)
129 127
130 _, ok = lookup("ctrl+r")128 _, ok = lookup("ctrl+r")
M internal/ui/input/shortcuts.go +58 -173
@@ -9,6 +9,7 @@ import (
9 "strings"9 "strings"
10 10
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/keychord"
12 "github.com/bnema/dumber/internal/domain/vimkeys"13 "github.com/bnema/dumber/internal/domain/vimkeys"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk/v4/gdk"15 "github.com/bnema/puregotk/v4/gdk"
@@ -31,67 +32,42 @@ const (
31 // modifierMask filters out non-standard modifiers from GDK state.32 // modifierMask filters out non-standard modifiers from GDK state.
32 const modifierMask = ModCtrl | ModShift | ModAlt33 const modifierMask = ModCtrl | ModShift | ModAlt
33 34
35+// keyvalByName maps the canonical key names of the keychord package to GDK
36+// keyvals. Printable ASCII keys map to their character code and need no entry.
34 var keyvalByName = map[string]uint{37 var keyvalByName = map[string]uint{
35- "escape": uint(gdk.KEY_Escape),38+ "escape": uint(gdk.KEY_Escape),
36- "esc": uint(gdk.KEY_Escape),39+ "return": uint(gdk.KEY_Return),
37- "return": uint(gdk.KEY_Return),40+ "tab": uint(gdk.KEY_Tab),
38- "enter": uint(gdk.KEY_Return),41+ "space": uint(gdk.KEY_space),
39- "tab": uint(gdk.KEY_Tab),42+ "backspace": uint(gdk.KEY_BackSpace),
40- "space": uint(gdk.KEY_space),43+ "delete": uint(gdk.KEY_Delete),
41- "backspace": uint(gdk.KEY_BackSpace),44+ "home": uint(gdk.KEY_Home),
42- "delete": uint(gdk.KEY_Delete),45+ "end": uint(gdk.KEY_End),
43- "del": uint(gdk.KEY_Delete),46+ "pageup": uint(gdk.KEY_Page_Up),
44- "home": uint(gdk.KEY_Home),47+ "pagedown": uint(gdk.KEY_Page_Down),
45- "end": uint(gdk.KEY_End),48+ "left": uint(gdk.KEY_Left),
46- "pageup": uint(gdk.KEY_Page_Up),49+ "right": uint(gdk.KEY_Right),
47- "page_up": uint(gdk.KEY_Page_Up),50+ "up": uint(gdk.KEY_Up),
48- "pagedown": uint(gdk.KEY_Page_Down),51+ "down": uint(gdk.KEY_Down),
49- "page_down": uint(gdk.KEY_Page_Down),52+ "f1": uint(gdk.KEY_F1),
50- "left": uint(gdk.KEY_Left),53+ "f2": uint(gdk.KEY_F2),
51- "arrowleft": uint(gdk.KEY_Left),54+ "f3": uint(gdk.KEY_F3),
52- "right": uint(gdk.KEY_Right),55+ "f4": uint(gdk.KEY_F4),
53- "arrowright": uint(gdk.KEY_Right),56+ "f5": uint(gdk.KEY_F5),
54- "up": uint(gdk.KEY_Up),57+ "f6": uint(gdk.KEY_F6),
55- "arrowup": uint(gdk.KEY_Up),58+ "f7": uint(gdk.KEY_F7),
56- "down": uint(gdk.KEY_Down),59+ "f8": uint(gdk.KEY_F8),
57- "arrowdown": uint(gdk.KEY_Down),60+ "f9": uint(gdk.KEY_F9),
58- "f1": uint(gdk.KEY_F1),61+ "f10": uint(gdk.KEY_F10),
59- "f2": uint(gdk.KEY_F2),62+ "f11": uint(gdk.KEY_F11),
60- "f3": uint(gdk.KEY_F3),63+ "f12": uint(gdk.KEY_F12),
61- "f4": uint(gdk.KEY_F4),64+ "plus": uint(gdk.KEY_plus),
62- "f5": uint(gdk.KEY_F5),65+ "minus": uint(gdk.KEY_minus),
63- "f6": uint(gdk.KEY_F6),66+ "equal": uint(gdk.KEY_equal),
64- "f7": uint(gdk.KEY_F7),
65- "f8": uint(gdk.KEY_F8),
66- "f9": uint(gdk.KEY_F9),
67- "f10": uint(gdk.KEY_F10),
68- "f11": uint(gdk.KEY_F11),
69- "f12": uint(gdk.KEY_F12),
70- "plus": uint(gdk.KEY_plus),
71- "+": uint(gdk.KEY_plus),
72- "minus": uint(gdk.KEY_minus),
73- "-": uint(gdk.KEY_minus),
74- "equal": uint(gdk.KEY_equal),
75- "=": uint(gdk.KEY_equal),
76- "0": uint(gdk.KEY_0),
77- "1": uint(gdk.KEY_1),
78- "2": uint(gdk.KEY_2),
79- "3": uint(gdk.KEY_3),
80- "4": uint(gdk.KEY_4),
81- "5": uint(gdk.KEY_5),
82- "6": uint(gdk.KEY_6),
83- "7": uint(gdk.KEY_7),
84- "8": uint(gdk.KEY_8),
85- "9": uint(gdk.KEY_9),
86-
87 "bracketleft": uint(gdk.KEY_bracketleft),67 "bracketleft": uint(gdk.KEY_bracketleft),
88 "bracketright": uint(gdk.KEY_bracketright),68 "bracketright": uint(gdk.KEY_bracketright),
89- "[": uint(gdk.KEY_bracketleft),
90- "]": uint(gdk.KEY_bracketright),
91 "braceleft": uint(gdk.KEY_braceleft),69 "braceleft": uint(gdk.KEY_braceleft),
92 "braceright": uint(gdk.KEY_braceright),70 "braceright": uint(gdk.KEY_braceright),
93- "{": uint(gdk.KEY_braceleft),
94- "}": uint(gdk.KEY_braceright),
95 }71 }
96 72
97 // KeyBinding represents a single key combination.73 // KeyBinding represents a single key combination.
@@ -285,7 +261,6 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
285 261
286 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.262 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
287 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {263 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288- s.registerBuiltinShortcutFallbacks(workspace)
289 s.registerActivationShortcutsFromParts(ctx, workspace, session)264 s.registerActivationShortcutsFromParts(ctx, workspace, session)
290 s.registerConfiguredShortcuts(workspace)265 s.registerConfiguredShortcuts(workspace)
291 // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler;266 // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler;
@@ -376,28 +351,6 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a
376 Msg("mode activation registered")351 Msg("mode activation registered")
377 }352 }
378 353
379-// registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose
380-// action is missing from the configured global shortcuts, so configs written
381-// before these actions became configurable keep working until migrated.
382-func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) {
383- for name, binding := range entity.BuiltinGlobalShortcuts() {
384- if cfg != nil {
385- if _, configured := cfg.Shortcuts.Actions[name]; configured {
386- continue
387- }
388- }
389- action, ok := configActionToAction[name]
390- if !ok {
391- continue
392- }
393- for _, key := range binding.Keys {
394- if kb, ok := ParseKeyString(key); ok {
395- s.Global[kb] = action
396- }
397- }
398- }
399-}
400-
401 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {354 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
402 if cfg == nil {355 if cfg == nil {
403 return356 return
@@ -421,10 +374,13 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
421 // key conflict the same across restarts.374 // key conflict the same across restarts.
422 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {375 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423 builtins := entity.BuiltinGlobalShortcuts()376 builtins := entity.BuiltinGlobalShortcuts()
377+ isBuiltin := func(name string) bool {
378+ _, ok := builtins[name]
379+ return ok
380+ }
424 names := slices.Sorted(maps.Keys(actions))381 names := slices.Sorted(maps.Keys(actions))
425 slices.SortStableFunc(names, func(a, b string) int {382 slices.SortStableFunc(names, func(a, b string) int {
426- _, aBuiltin := builtins[a]383+ aBuiltin, bBuiltin := isBuiltin(a), isBuiltin(b)
427- _, bBuiltin := builtins[b]
428 switch {384 switch {
429 case aBuiltin == bBuiltin:385 case aBuiltin == bBuiltin:
430 return 0386 return 0
@@ -573,57 +529,37 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
573 Msg(mode + " mode shortcuts built")529 Msg(mode + " mode shortcuts built")
574 }530 }
575 531
532+// configActionToAction maps canonical (hyphenated) config action names to
533+// actions. Config loading canonicalizes legacy underscore spellings, so only
534+// hyphenated names are looked up here.
576 var configActionToAction = map[string]Action{535 var configActionToAction = map[string]Action{
577- "toggle_floating_pane": ActionToggleFloatingPane,
578 "toggle-floating-pane": ActionToggleFloatingPane,536 "toggle-floating-pane": ActionToggleFloatingPane,
579- "toggle_history_systemview": ActionToggleHistorySystemView,
580 "toggle-history-systemview": ActionToggleHistorySystemView,537 "toggle-history-systemview": ActionToggleHistorySystemView,
581- "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
582 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,538 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
583- "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
584 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,539 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
585- "toggle_config_systemview": ActionToggleConfigSystemView,
586 "toggle-config-systemview": ActionToggleConfigSystemView,540 "toggle-config-systemview": ActionToggleConfigSystemView,
587 541
588 // Tab actions542 // Tab actions
589- "new_tab": ActionNewTab,
590 "new-tab": ActionNewTab,543 "new-tab": ActionNewTab,
591- "close_tab": ActionCloseTab,
592 "close-tab": ActionCloseTab,544 "close-tab": ActionCloseTab,
593- "next_tab": ActionNextTab,
594 "next-tab": ActionNextTab,545 "next-tab": ActionNextTab,
595- "previous_tab": ActionPreviousTab,
596 "previous-tab": ActionPreviousTab,546 "previous-tab": ActionPreviousTab,
597- "rename_tab": ActionRenameTab,
598 "rename-tab": ActionRenameTab,547 "rename-tab": ActionRenameTab,
599 548
600 // Pane actions549 // Pane actions
601- "split_right": ActionSplitRight,
602 "split-right": ActionSplitRight,550 "split-right": ActionSplitRight,
603- "split_left": ActionSplitLeft,
604 "split-left": ActionSplitLeft,551 "split-left": ActionSplitLeft,
605- "split_up": ActionSplitUp,
606 "split-up": ActionSplitUp,552 "split-up": ActionSplitUp,
607- "split_down": ActionSplitDown,
608 "split-down": ActionSplitDown,553 "split-down": ActionSplitDown,
609- "close_pane": ActionClosePane,
610 "close-pane": ActionClosePane,554 "close-pane": ActionClosePane,
611- "stack_pane": ActionStackPane,
612 "stack-pane": ActionStackPane,555 "stack-pane": ActionStackPane,
613- "move_pane_to_tab": ActionMovePaneToTab,
614 "move-pane-to-tab": ActionMovePaneToTab,556 "move-pane-to-tab": ActionMovePaneToTab,
615- "move_pane_to_next_tab": ActionMovePaneToNextTab,
616 "move-pane-to-next-tab": ActionMovePaneToNextTab,557 "move-pane-to-next-tab": ActionMovePaneToNextTab,
617- "eject_pane_to_window": ActionEjectPaneToWindow,
618 "eject-pane-to-window": ActionEjectPaneToWindow,558 "eject-pane-to-window": ActionEjectPaneToWindow,
619 559
620- "consume_or_expel_left": ActionConsumeOrExpelLeft,
621 "consume-or-expel-left": ActionConsumeOrExpelLeft,560 "consume-or-expel-left": ActionConsumeOrExpelLeft,
622- "consume_or_expel_right": ActionConsumeOrExpelRight,
623 "consume-or-expel-right": ActionConsumeOrExpelRight,561 "consume-or-expel-right": ActionConsumeOrExpelRight,
624- "consume_or_expel_up": ActionConsumeOrExpelUp,
625 "consume-or-expel-up": ActionConsumeOrExpelUp,562 "consume-or-expel-up": ActionConsumeOrExpelUp,
626- "consume_or_expel_down": ActionConsumeOrExpelDown,
627 "consume-or-expel-down": ActionConsumeOrExpelDown,563 "consume-or-expel-down": ActionConsumeOrExpelDown,
628 564
629 // Focus navigation565 // Focus navigation
@@ -679,17 +615,11 @@ var configActionToAction = map[string]Action{
679 "enter-vim-mode": ActionEnterVimMode,615 "enter-vim-mode": ActionEnterVimMode,
680 616
681 // Vim mode scroll actions617 // Vim mode scroll actions
682- "vim_scroll_left": ActionVimScrollLeft,
683 "vim-scroll-left": ActionVimScrollLeft,618 "vim-scroll-left": ActionVimScrollLeft,
684- "vim_scroll_down": ActionVimScrollDown,
685 "vim-scroll-down": ActionVimScrollDown,619 "vim-scroll-down": ActionVimScrollDown,
686- "vim_scroll_up": ActionVimScrollUp,
687 "vim-scroll-up": ActionVimScrollUp,620 "vim-scroll-up": ActionVimScrollUp,
688- "vim_scroll_right": ActionVimScrollRight,
689 "vim-scroll-right": ActionVimScrollRight,621 "vim-scroll-right": ActionVimScrollRight,
690- "vim_scroll_down_fast": ActionVimScrollDownFast,
691 "vim-scroll-down-fast": ActionVimScrollDownFast,622 "vim-scroll-down-fast": ActionVimScrollDownFast,
692- "vim_scroll_up_fast": ActionVimScrollUpFast,
693 "vim-scroll-up-fast": ActionVimScrollUpFast,623 "vim-scroll-up-fast": ActionVimScrollUpFast,
694 }624 }
695 625
@@ -760,87 +690,42 @@ func mapConfigAction(configAction string) Action {
760 }690 }
761 691
762 // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.692 // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
693+// Alias, modifier and case handling is shared with the config layer through
694+// keychord, so strings that canonicalize equal always parse to the same binding.
763 // Returns false if the string cannot be parsed.695 // Returns false if the string cannot be parsed.
764 func ParseKeyString(s string) (KeyBinding, bool) {696 func ParseKeyString(s string) (KeyBinding, bool) {
765- if s == "" {697+ chord, ok := keychord.Parse(s)
698+ if !ok {
766 return KeyBinding{}, false699 return KeyBinding{}, false
767 }700 }
768-701+ keyval, ok := keychordKeyval(chord.Key)
769- s = strings.TrimSpace(s)702+ if !ok {
770- if s == "" {
771 return KeyBinding{}, false703 return KeyBinding{}, false
772 }704 }
773- if s == "+" {
774- return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
775- }
776-
777- parts := strings.Split(s, "+")
778 705
779 var modifiers Modifier706 var modifiers Modifier
780- var keyPart string707+ if chord.Mods&keychord.Ctrl != 0 {
781-708+ modifiers |= ModCtrl
782- for _, part := range parts {
783- part = strings.TrimSpace(part)
784- if part == "" {
785- continue
786- }
787-
788- lower := strings.ToLower(part)
789- switch lower {
790- case "ctrl", "control":
791- modifiers |= ModCtrl
792- case "shift":
793- modifiers |= ModShift
794- case "alt":
795- modifiers |= ModAlt
796- default:
797- if keyPart != "" {
798- return KeyBinding{}, false
799- }
800- keyPart = part
801- }
802- }
803-
804- // Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
805- if keyPart == "" && strings.HasSuffix(s, "++") {
806- keyPart = "+"
807 }709 }
808-710+ if chord.Mods&keychord.Alt != 0 {
809- if keyPart == "" {711+ modifiers |= ModAlt
810- return KeyBinding{}, false
811 }712 }
812-713+ if chord.Mods&keychord.Shift != 0 {
813- // Treat uppercase single-letter keys as Shift+<letter>.
814- if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
815 modifiers |= ModShift714 modifiers |= ModShift
816- keyPart = strings.ToLower(keyPart)
817 }715 }
818-716+ return KeyBinding{Keyval: keyval, Modifiers: modifiers}, true
819- keyval, ok := stringToKeyval(keyPart)
820- if !ok {
821- return KeyBinding{}, false
822- }
823-
824- return KeyBinding{
825- Keyval: keyval,
826- Modifiers: modifiers,
827- }, true
828 }717 }
829 718
830-// stringToKeyval converts a key name to its GDK keyval.719+// keychordKeyval converts a canonical keychord key name to its GDK keyval.
831-func stringToKeyval(s string) (uint, bool) {720+func keychordKeyval(name string) (uint, bool) {
832- if s == "" {721+ if keyval, ok := keyvalByName[name]; ok {
833- return 0, false
834- }
835-
836- if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
837 return keyval, true722 return keyval, true
838 }723 }
839 724
840- // Single printable ASCII keys (a-z, symbols such as %, ", &).725+ // Single printable ASCII keys (a-z, 0-9, symbols such as %, ", &).
841 // GDK keyvals for printable ASCII match their character codes.726 // GDK keyvals for printable ASCII match their character codes.
842- if len(s) == 1 && s[0] > ' ' && s[0] <= '~' {727+ if len(name) == 1 && name[0] > ' ' && name[0] <= '~' {
843- return uint(s[0]), true728+ return uint(name[0]), true
844 }729 }
845 730
846 return 0, false731 return 0, false
M internal/ui/input/shortcuts_test.go +3 -29
@@ -380,7 +380,7 @@ func TestShouldAutoExitMode(t *testing.T) {
380 }380 }
381 381
382 func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {382 func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
383- tests := []string{"eject_pane_to_window", "eject-pane-to-window"}383+ tests := []string{"eject-pane-to-window"}
384 for _, name := range tests {384 for _, name := range tests {
385 t.Run(name, func(t *testing.T) {385 t.Run(name, func(t *testing.T) {
386 if got := mapConfigAction(name); got != ActionEjectPaneToWindow {386 if got := mapConfigAction(name); got != ActionEjectPaneToWindow {
@@ -391,29 +391,9 @@ func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
391 }391 }
392 392
393 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {393 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
394- action := mapConfigAction("toggle_floating_pane")394+ action := mapConfigAction("toggle-floating-pane")
395 if action != ActionToggleFloatingPane {395 if action != ActionToggleFloatingPane {
396- t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)396+ t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
397- }
398-}
399-
400-func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
401- tests := []struct {
402- name string
403- want Action
404- }{
405- {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
406- {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar},
407- {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
408- {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
409- }
410-
411- for _, tt := range tests {
412- t.Run(tt.name, func(t *testing.T) {
413- if got := mapConfigAction(tt.name); got != tt.want {
414- t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
415- }
416- })
417 }397 }
418 }398 }
419 399
@@ -580,17 +560,11 @@ func TestMapConfigAction_VimScrollActions(t *testing.T) {
580 config string560 config string
581 expected Action561 expected Action
582 }{562 }{
583- {name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft},
584 {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},563 {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},
585- {name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown},
586 {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},564 {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},
587- {name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp},
588 {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},565 {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},
589- {name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight},
590 {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},566 {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},
591- {name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast},
592 {name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast},567 {name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast},
593- {name: "vim_scroll_up_fast", config: "vim_scroll_up_fast", expected: ActionVimScrollUpFast},
594 {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},568 {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},
595 }569 }
596 570
M internal/ui/mode_frame.go +35 -481
@@ -1,106 +1,41 @@
1 package ui1 package ui
2 2
3 import (3 import (
4- "strings"
5-
6 "github.com/bnema/dumber/internal/domain/entity"4 "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/ui/input"5 "github.com/bnema/dumber/internal/ui/input"
8 "github.com/bnema/dumber/internal/ui/layout"6 "github.com/bnema/dumber/internal/ui/layout"
9- "github.com/bnema/puregotk/v4/gdk"
10 "github.com/bnema/puregotk/v4/glib"7 "github.com/bnema/puregotk/v4/glib"
11 "github.com/bnema/puregotk/v4/gobject"8 "github.com/bnema/puregotk/v4/gobject"
12 "github.com/bnema/puregotk/v4/graphene"9 "github.com/bnema/puregotk/v4/graphene"
13 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
14 )11 )
15 12
16-const (13+const borderRefreshMs = 100
17- legendColumnSpacing = 8
18- legendMaxWidth = 700
19- legendCompactWidth = 460
20- legendTwoColumnWidth = 650
21- legendInset = 16
22- legendHeaderSpace = 80
23- legendRefreshMs = 100
24-)
25 14
26-// modeFrame owns the per-window mode border and its non-interactive legend.15+// modeFrame owns the per-window mode border drawn around its target pane or
27-// GTK access is restricted to the main thread, as with the rest of the UI.16+// workspace, and the Vim pulse on it. The mode indicator and legend belong to
17+// modeToaster. GTK access is restricted to the main thread, as with the rest of
18+// the UI.
28 type modeFrame struct {19 type modeFrame struct {
29 mode input.Mode20 mode input.Mode
30 tabID entity.TabID21 tabID entity.TabID
31 target layout.Widget22 target layout.Widget
32 frameClass string23 frameClass string
33- root *gtk.Box
34 border *gtk.Box24 border *gtk.Box
35 borderStyle cssClassTarget // border's CSS classes; a seam for tests25 borderStyle cssClassTarget // border's CSS classes; a seam for tests
36 borderRect func() (x, y, width, height int, ok bool)26 borderRect func() (x, y, width, height int, ok bool)
37- panel *gtk.Box27+ overlay *gtk.Overlay
38- content *gtk.FlowBox28+
39- scroller *gtk.ScrolledWindow29+ refreshSource uint
40- heading *gtk.Label30+ positionHandlerID uint
41- rows map[string][]*gtk.Widget31+ pulseCycle bool
42- keycaps map[string][]*gtk.Widget
43- pending string
44- visible bool
45- // suspended hides the legend while an in-page interaction (link hints,
46- // visual selection) owns the keys; the border keeps tracking its target.
47- suspended bool
48- style string
49- modeClass string
50- config entity.WorkspaceStylingConfig
51- showTimer uint
52- flashTimer uint
53- generation uint64
54- placement func() (x, y, width, height int, ok bool)
55- refreshSource uint
56- lastColumns uint
57- lastPanelWidth int
58- lastMaxHeight int
59- overlay *gtk.Overlay
60- window *gtk.ApplicationWindow
61- pressInFlight bool
62- pressIdle uint
63- positionHandlerID uint
64- pulseCycle bool
65- lingering bool
66- pointerX, pointerY float64
67- pointerInside bool
68- motionController *gtk.EventControllerMotion
69- keyController *gtk.EventControllerKey
70- clickController *gtk.GestureClick
71- motionCb func(gtk.EventControllerMotion, float64, float64)
72- enterCb func(gtk.EventControllerMotion, float64, float64)
73- leaveCb func(gtk.EventControllerMotion)
74- keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
75- clickCb func(gtk.GestureClick, int, float64, float64)
76 }32 }
77 33
78-func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {34+func newModeFrame(overlay *gtk.Overlay) *modeFrame {
79- if overlay == nil || window == nil {35+ if overlay == nil {
80 return nil36 return nil
81 }37 }
82- f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}38+ f := &modeFrame{mode: input.ModeNormal, overlay: overlay}
83- f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
84- f.root.SetHalign(gtk.AlignStartValue)
85- f.root.SetValign(gtk.AlignStartValue)
86- f.root.SetCanFocus(false)
87- f.root.SetCanTarget(false)
88- f.root.SetVisible(false)
89- f.root.AddCssClass("mode-legend-anchor")
90- f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
91- f.panel.SetCanFocus(false)
92- f.panel.SetCanTarget(false)
93- f.panel.AddCssClass("omnibox-container")
94- f.panel.AddCssClass("mode-legend-panel")
95- f.root.Append(&f.panel.Widget)
96- title := ""
97- f.heading = gtk.NewLabel(&title)
98- f.heading.SetCanTarget(false)
99- f.heading.SetCanFocus(false)
100- f.heading.SetHalign(gtk.AlignStartValue)
101- f.heading.AddCssClass("mode-legend-title")
102- f.panel.Append(&f.heading.Widget)
103- f.initLegendContent()
104 // The border is its own overlay: inset shadows on pane containers are39 // The border is its own overlay: inset shadows on pane containers are
105 // painted below web content and would be invisible.40 // painted below web content and would be invisible.
106 f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)41 f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
@@ -112,129 +47,38 @@ func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFram
112 overlay.AddOverlay(&f.border.Widget)47 overlay.AddOverlay(&f.border.Widget)
113 overlay.SetClipOverlay(&f.border.Widget, false)48 overlay.SetClipOverlay(&f.border.Widget, false)
114 overlay.SetMeasureOverlay(&f.border.Widget, false)49 overlay.SetMeasureOverlay(&f.border.Widget, false)
115- overlay.AddOverlay(&f.root.Widget)
116- overlay.SetClipOverlay(&f.root.Widget, false)
117- overlay.SetMeasureOverlay(&f.root.Widget, false)
118 // A single positioned overlay tracks the active pane without reparenting it.50 // A single positioned overlay tracks the active pane without reparenting it.
119 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {51 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
120- placement := f.placement52+ if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil {
121- if f.border != nil && widgetPtr == f.border.GoPointer() {
122- placement = f.borderRect
123- } else if widgetPtr != f.root.GoPointer() {
124- return false
125- }
126- if placement == nil {
127 return false53 return false
128 }54 }
129- x, y, w, h, ok := placement()55+ x, y, w, h, ok := f.borderRect()
130 if !ok {56 if !ok {
131 return false57 return false
132 }58 }
133 return writeOverlayAllocation(allocation, x, y, w, h)59 return writeOverlayAllocation(allocation, x, y, w, h)
134 }60 }
135 f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)61 f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
136- f.installDismissControllers()
137 return f62 return f
138 }63 }
139 64
140-func (f *modeFrame) installDismissControllers() {
141- overlay := f.overlay
142- window := f.window
143- f.motionController = gtk.NewEventControllerMotion()
144- f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
145- f.pointerX, f.pointerY, f.pointerInside = x, y, true
146- }
147- f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
148- f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
149- f.pointerX, f.pointerY, f.pointerInside = x, y, true
150- }
151- f.motionController.ConnectEnter(&f.enterCb)
152- f.motionController.ConnectMotion(&f.motionCb)
153- f.motionController.ConnectLeave(&f.leaveCb)
154- overlay.AddController(&f.motionController.EventController)
155- f.keyController = gtk.NewEventControllerKey()
156- f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
157- f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
158- f.dismissLinger()
159- return false
160- }
161- f.keyController.ConnectKeyPressed(&f.keyCb)
162- window.AddController(&f.keyController.EventController)
163- f.clickController = gtk.NewGestureClick()
164- f.clickController.SetButton(0)
165- f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
166- f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
167- f.dismissLinger()
168- f.pressInFlight = true
169- if f.pressIdle != 0 {
170- glib.SourceRemove(f.pressIdle)
171- }
172- cb := glib.SourceFunc(func(_ uintptr) bool {
173- f.pressInFlight = false
174- f.pressIdle = 0
175- return false
176- })
177- f.pressIdle = glib.IdleAdd(&cb, 0)
178- }
179- f.clickController.ConnectPressed(&f.clickCb)
180- window.AddController(&f.clickController.EventController)
181-}
182-
183-func (f *modeFrame) initLegendContent() {
184- f.content = gtk.NewFlowBox()
185- f.content.SetCanFocus(false)
186- f.content.SetCanTarget(false)
187- f.content.SetOrientation(gtk.OrientationHorizontalValue)
188- f.content.SetSelectionMode(gtk.SelectionNoneValue)
189- f.content.SetMinChildrenPerLine(1)
190- f.content.SetMaxChildrenPerLine(3)
191- f.content.SetColumnSpacing(legendColumnSpacing)
192- f.content.SetRowSpacing(4)
193- f.content.AddCssClass("mode-legend-content")
194- f.scroller = gtk.NewScrolledWindow()
195- f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
196- f.scroller.SetPropagateNaturalHeight(true)
197- f.scroller.SetHasFrame(false)
198- f.scroller.SetCanFocus(false)
199- f.scroller.SetCanTarget(false)
200- f.scroller.SetChild(&f.content.Widget)
201- f.panel.Append(&f.scroller.Widget)
202-}
203-
204 func (f *modeFrame) destroy() {65 func (f *modeFrame) destroy() {
205 if f == nil {66 if f == nil {
206 return67 return
207 }68 }
208- f.cancelTimers()
209- f.dismissLinger()
210 f.stopGeometryRefresh()69 f.stopGeometryRefresh()
211- if f.pressIdle != 0 {
212- glib.SourceRemove(f.pressIdle)
213- f.pressIdle = 0
214- }
215- if f.overlay != nil {
216- f.overlay.RemoveController(&f.motionController.EventController)
217- }
218- if f.window != nil {
219- f.window.RemoveController(&f.keyController.EventController)
220- f.window.RemoveController(&f.clickController.EventController)
221- }
222 f.setTarget(nil, "")70 f.setTarget(nil, "")
223 if f.overlay != nil && f.positionHandlerID != 0 {71 if f.overlay != nil && f.positionHandlerID != 0 {
224 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)72 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
225 f.positionHandlerID = 073 f.positionHandlerID = 0
226 }74 }
227- f.placement = nil
228 f.borderRect = nil75 f.borderRect = nil
229- if f.overlay != nil && f.root != nil {
230- f.overlay.RemoveOverlay(&f.root.Widget)
231- }
232 if f.overlay != nil && f.border != nil {76 if f.overlay != nil && f.border != nil {
233 f.overlay.RemoveOverlay(&f.border.Widget)77 f.overlay.RemoveOverlay(&f.border.Widget)
78+ // NewBox's reference is not released by the removal.
79+ f.border.Unref()
234 }80 }
235 f.overlay = nil81 f.overlay = nil
236- f.window = nil
237- f.root = nil
238 f.border = nil82 f.border = nil
239 f.borderStyle = nil83 f.borderStyle = nil
240 }84 }
@@ -244,6 +88,17 @@ type cssClassTarget interface {
244 RemoveCssClass(string)88 RemoveCssClass(string)
245 }89 }
246 90
91+// setMode retargets the border for mode; it clears the border for Normal mode,
92+// which has no target.
93+func (f *modeFrame) setMode(mode input.Mode, target layout.Widget) {
94+ if f == nil {
95+ return
96+ }
97+ f.mode = mode
98+ f.pulseCycle = false
99+ f.setTarget(target, modeFrameClass(mode))
100+}
101+
247 func (f *modeFrame) setTarget(target layout.Widget, class string) {102 func (f *modeFrame) setTarget(target layout.Widget, class string) {
248 if f == nil {103 if f == nil {
249 return104 return
@@ -314,167 +169,6 @@ func modeFrameClass(mode input.Mode) string {
314 }169 }
315 }170 }
316 171
317-func (f *modeFrame) setMode(
318- mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
319- style string, actions map[string]entity.ActionBinding,
320-) {
321- if f == nil {
322- return
323- }
324- f.cancelTimers()
325- // Normal-mode refreshes must preserve an already lingering legend.
326- keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
327- linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
328- cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
329- if !keepLinger {
330- f.dismissLinger()
331- }
332- f.generation++
333- f.mode, f.config = mode, cfg
334- f.pending = ""
335- f.pulseCycle = false
336- f.suspended = false
337- if linger {
338- f.lingering = true
339- f.visible = false
340- f.clearLegendFeedback()
341- f.panel.AddCssClass("mode-legend-lingering")
342- } else if !keepLinger {
343- f.hide()
344- }
345- f.setTarget(target, modeFrameClass(mode))
346- f.setLegendStyle(style, mode, linger || keepLinger)
347- if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
348- if !linger && !keepLinger {
349- f.content.RemoveAll()
350- }
351- f.rows = nil
352- f.keycaps = nil
353- return
354- }
355- f.build(actions)
356- if cfg.ModeLegendAnimations {
357- f.panel.RemoveCssClass("mode-legend-no-motion")
358- } else {
359- f.panel.AddCssClass("mode-legend-no-motion")
360- }
361- if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
362- generation := f.generation
363- cb := glib.SourceFunc(func(_ uintptr) bool {
364- f.showTimer = 0
365- if generation == f.generation {
366- f.show()
367- }
368- return false
369- })
370- f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
371- return
372- }
373- f.show()
374-}
375-
376-func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
377- if f.style != style {
378- if f.style != "" {
379- f.panel.RemoveCssClass("omnibox-style-" + f.style)
380- }
381- f.style = style
382- if style != "" {
383- f.panel.AddCssClass("omnibox-style-" + style)
384- }
385- }
386- if f.modeClass != "" && !preserveModeClass {
387- f.panel.RemoveCssClass(f.modeClass)
388- }
389- if !preserveModeClass {
390- f.modeClass = "mode-legend-" + mode.String()
391- f.panel.AddCssClass(f.modeClass)
392- }
393-}
394-
395-func (f *modeFrame) clearLegendFeedback() {
396- for _, rows := range f.rows {
397- for _, row := range rows {
398- row.RemoveCssClass("mode-legend-dim")
399- }
400- }
401- for _, caps := range f.keycaps {
402- for _, cap := range caps {
403- cap.RemoveCssClass("mode-legend-flash")
404- }
405- }
406-}
407-
408-func (f *modeFrame) cancelTimers() {
409- if f == nil {
410- return
411- }
412- if f.showTimer != 0 {
413- glib.SourceRemove(f.showTimer)
414- f.showTimer = 0
415- }
416- if f.flashTimer != 0 {
417- glib.SourceRemove(f.flashTimer)
418- f.flashTimer = 0
419- }
420-}
421-
422-func pointInRect(x, y float64, rx, ry, width, height int) bool {
423- return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
424-}
425-
426-func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
427- if !enabled || !inside || placement == nil {
428- return false
429- }
430- rx, ry, w, h, ok := placement()
431- return ok && pointInRect(x, y, rx, ry, w, h)
432-}
433-
434-func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
435- return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
436-}
437-
438-// setSuspended hides the legend while suspended is true and brings it back
439-// otherwise, without touching the mode, its border, or a pending show timer.
440-// Used while an in-page interaction owns the keys, when the legend would list
441-// bindings that do not apply.
442-func (f *modeFrame) setSuspended(suspended bool) {
443- if f == nil || f.suspended == suspended {
444- return
445- }
446- f.suspended = suspended
447- if f.root == nil || f.lingering {
448- return
449- }
450- if suspended {
451- f.clearLegend()
452- return
453- }
454- if f.visible {
455- f.refreshGeometry()
456- }
457-}
458-
459-func (f *modeFrame) dismissLinger() {
460- if f == nil || !f.lingering {
461- return
462- }
463- f.lingering = false
464- f.panel.RemoveCssClass("mode-legend-lingering")
465- f.hide()
466-}
467-
468-func (f *modeFrame) hide() {
469- if f == nil || f.root == nil {
470- return
471- }
472- f.visible = false
473- f.placement = nil
474- f.resetLegendHeight()
475- f.root.SetVisible(false)
476-}
477-
478 func (f *modeFrame) stopGeometryRefresh() {172 func (f *modeFrame) stopGeometryRefresh() {
479 if f == nil || f.refreshSource == 0 {173 if f == nil || f.refreshSource == 0 {
480 return174 return
@@ -483,92 +177,40 @@ func (f *modeFrame) stopGeometryRefresh() {
483 f.refreshSource = 0177 f.refreshSource = 0
484 }178 }
485 179
486-func (f *modeFrame) show() {180+// Measure outside GTK allocation while the border is active, including window
487- if f == nil || f.mode == input.ModeNormal || f.target == nil {
488- return
489- }
490- f.visible = true
491- f.startGeometryRefresh()
492-}
493-
494-// Measure outside GTK allocation while the legend is active, including window
495 // resizes and splitter drags after the initial layout has settled.181 // resizes and splitter drags after the initial layout has settled.
496 func (f *modeFrame) startGeometryRefresh() {182 func (f *modeFrame) startGeometryRefresh() {
497 f.stopGeometryRefresh()183 f.stopGeometryRefresh()
498 f.refreshGeometry()184 f.refreshGeometry()
499 cb := glib.SourceFunc(func(_ uintptr) bool {185 cb := glib.SourceFunc(func(_ uintptr) bool {
500- if f.target == nil || f.root == nil || f.lingering {186+ if f.target == nil || f.border == nil {
501 f.refreshSource = 0187 f.refreshSource = 0
502 return false188 return false
503 }189 }
504 f.refreshGeometry()190 f.refreshGeometry()
505 return true191 return true
506 })192 })
507- f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)193+ f.refreshSource = glib.TimeoutAdd(borderRefreshMs, &cb, 0)
508-}
509-
510-func (f *modeFrame) resetLegendHeight() {
511- if f.lastMaxHeight != 0 {
512- f.scroller.SetSizeRequest(-1, -1)
513- f.scroller.SetPropagateNaturalHeight(true)
514- f.lastMaxHeight = 0
515- }
516 }194 }
517 195
518-// clearPlacement hides both the border and the legend: the target is gone.196+// clearPlacement hides the border: the target is gone.
519 func (f *modeFrame) clearPlacement() {197 func (f *modeFrame) clearPlacement() {
520 if f.borderRect != nil {198 if f.borderRect != nil {
521 f.borderRect = nil199 f.borderRect = nil
522 f.border.SetVisible(false)200 f.border.SetVisible(false)
523 }201 }
524- f.clearLegend()
525-}
526-
527-// clearLegend hides only the legend; the border keeps tracking its target.
528-func (f *modeFrame) clearLegend() {
529- if f.placement == nil {
530- return
531- }
532- f.placement = nil
533- f.root.SetVisible(false)
534- f.root.QueueAllocate()
535 }202 }
536 203
537-func (f *modeFrame) measureLegendHeight(panelWidth int) int {204+// refreshGeometry measures the target outside GTK's positioning callback.
538- maxHeight := f.overlay.GetAllocatedHeight() - legendInset
539- if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
540- f.resetLegendHeight()
541- }
542- // Measure the panel: GTK reports 0 for the root while it is still hidden.
543- _, naturalHeight := 0, 0
544- f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
545- if naturalHeight > maxHeight && maxHeight > 0 {
546- if f.lastMaxHeight != maxHeight {
547- f.scroller.SetPropagateNaturalHeight(false)
548- f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
549- f.lastMaxHeight = maxHeight
550- }
551- return maxHeight
552- }
553- return naturalHeight
554-}
555-
556-// refreshGeometry measures the legend outside GTK's positioning callback.
557-// A narrow target falls back to the window width, keeping the legend reachable.
558 func (f *modeFrame) refreshGeometry() {205 func (f *modeFrame) refreshGeometry() {
559- if f.lingering {
560- return
561- }
562- previous := f.placement
563 if f.target == nil || f.overlay == nil {206 if f.target == nil || f.overlay == nil {
564 f.clearPlacement()207 f.clearPlacement()
565 return208 return
566 }209 }
567- overlayWidget := &f.overlay.Widget
568 point := &graphene.Point{X: 0, Y: 0}210 point := &graphene.Point{X: 0, Y: 0}
569 out := &graphene.Point{}211 out := &graphene.Point{}
570 target := f.target.GtkWidget()212 target := f.target.GtkWidget()
571- if target == nil || !target.ComputePoint(overlayWidget, point, out) {213+ if target == nil || !target.ComputePoint(&f.overlay.Widget, point, out) {
572 f.clearPlacement()214 f.clearPlacement()
573 return215 return
574 }216 }
@@ -577,48 +219,7 @@ func (f *modeFrame) refreshGeometry() {
577 f.clearPlacement()219 f.clearPlacement()
578 return220 return
579 }221 }
580- x, bottom := int(out.X), int(out.Y)+height222+ f.placeBorder(int(out.X), int(out.Y), width, height)
581- f.placeBorder(x, int(out.Y), width, height)
582- if !f.visible || f.suspended {
583- f.clearLegend()
584- return
585- }
586- // Keep every keycap visible even in a narrow split. No webview resize.
587- panelWidth := min(width-legendInset, legendMaxWidth)
588- columns := uint(3)
589- if panelWidth < legendCompactWidth {
590- panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
591- x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
592- columns = 1
593- } else {
594- if panelWidth < legendTwoColumnWidth {
595- columns = 2
596- }
597- x += (width - panelWidth) / 2
598- }
599- if panelWidth <= 0 {
600- f.clearLegend()
601- return
602- }
603- f.sizeLegendColumns(columns, panelWidth)
604- naturalHeight := f.measureLegendHeight(panelWidth)
605- if naturalHeight <= 0 {
606- f.clearLegend()
607- return
608- }
609- y := bottom - naturalHeight - 4
610- if y < 0 {
611- y = 0
612- }
613- f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
614- f.root.SetVisible(true)
615- if previous != nil {
616- oldX, oldY, oldWidth, oldHeight, _ := previous()
617- if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
618- return
619- }
620- }
621- f.root.QueueAllocate()
622 }223 }
623 224
624 func (f *modeFrame) placeBorder(x, y, width, height int) {225 func (f *modeFrame) placeBorder(x, y, width, height int) {
@@ -631,50 +232,3 @@ func (f *modeFrame) placeBorder(x, y, width, height int) {
631 f.border.SetVisible(true)232 f.border.SetVisible(true)
632 f.border.QueueAllocate()233 f.border.QueueAllocate()
633 }234 }
634-
635-func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
636- if columns != f.lastColumns {
637- f.content.SetMaxChildrenPerLine(columns)
638- f.lastColumns = columns
639- }
640- if width != f.lastPanelWidth {
641- f.panel.SetSizeRequest(width, -1)
642- f.lastPanelWidth = width
643- }
644-}
645-
646-// vimLegendGroup returns the legend group of a Vim Mode action, or "" when the
647-// action belongs to a generic group.
648-func vimLegendGroup(name string) string {
649- switch {
650- case strings.HasPrefix(name, "hint-") || strings.HasPrefix(name, "yank-") || name == "visual":
651- return "SELECT"
652- case strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline":
653- return "JUMP"
654- default:
655- return ""
656- }
657-}
658-
659-func modeLegendGroup(mode input.Mode, name string) string {
660- switch {
661- case name == "confirm" || name == "cancel":
662- return "EXIT"
663- case strings.HasPrefix(name, "split-"):
664- return "SPLIT"
665- case strings.HasPrefix(name, "enter-"):
666- return "MODES"
667- case strings.HasPrefix(name, "focus-"):
668- return "FOCUS"
669- case strings.HasPrefix(name, "resize-"):
670- return "RESIZE"
671- case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
672- return "SCROLL"
673- case mode == input.ModeVim && vimLegendGroup(name) != "":
674- return vimLegendGroup(name)
675- case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
676- return "SWITCH"
677- default:
678- return "MANAGE"
679- }
680-}
D internal/ui/mode_frame_legend.go +0 -193
@@ -1,193 +0,0 @@
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- case input.ModePrefix:
29- return workspace.PrefixMode.Actions
30- default:
31- return nil
32- }
33-}
34-
35-func (f *modeFrame) build(actions map[string]entity.ActionBinding) {
36- if f.flashTimer != 0 {
37- glib.SourceRemove(f.flashTimer)
38- f.flashTimer = 0
39- }
40- f.content.RemoveAll()
41- f.rows = make(map[string][]*gtk.Widget)
42- f.keycaps = make(map[string][]*gtk.Widget)
43- f.heading.SetText(f.mode.DisplayName() + " · ESC to exit")
44- groups := make(map[string][]string)
45- for name, binding := range actions {
46- if len(binding.Keys) == 0 {
47- continue
48- }
49- group := modeLegendGroup(f.mode, name)
50- groups[group] = append(groups[group], name)
51- }
52- order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "SELECT", "MANAGE", "MODES", "EXIT"}
53- index := 0
54- for _, groupName := range order {
55- names := groups[groupName]
56- if len(names) == 0 {
57- continue
58- }
59- slices.Sort(names)
60- column := gtk.NewBox(gtk.OrientationVerticalValue, 0)
61- column.SetCanFocus(false)
62- column.SetCanTarget(false)
63- column.AddCssClass("mode-legend-group")
64- title := gtk.NewLabel(&groupName)
65- title.SetCanFocus(false)
66- title.SetCanTarget(false)
67- title.SetHalign(gtk.AlignStartValue)
68- title.AddCssClass("mode-legend-group-title")
69- column.Append(&title.Widget)
70- for _, name := range names {
71- column.Append(&f.actionRow(name, actions[name]).Widget)
72- }
73- f.content.Append(&column.Widget)
74- if child := f.content.GetChildAtIndex(index); child != nil {
75- child.SetCanFocus(false)
76- child.SetCanTarget(false)
77- }
78- index++
79- }
80-}
81-
82-func (f *modeFrame) actionRow(name string, binding entity.ActionBinding) *gtk.Box {
83- row := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
84- row.SetCanFocus(false)
85- row.SetCanTarget(false)
86- row.AddCssClass("mode-legend-row")
87- f.rows[name] = append(f.rows[name], &row.Widget)
88- for _, key := range binding.Keys {
89- displayKey := displayLegendKey(key)
90- keycap := gtk.NewLabel(&displayKey)
91- keycap.SetCanFocus(false)
92- keycap.SetCanTarget(false)
93- keycap.AddCssClass("mode-legend-keycap")
94- row.Append(&keycap.Widget)
95- f.keycaps[name] = append(f.keycaps[name], &keycap.Widget)
96- }
97- label := binding.Desc
98- if label == "" {
99- label = strings.ReplaceAll(name, "-", " ")
100- }
101- text := gtk.NewLabel(&label)
102- text.SetCanFocus(false)
103- text.SetCanTarget(false)
104- text.SetHalign(gtk.AlignStartValue)
105- text.SetEllipsize(3) // PangoEllipsizeEnd
106- text.AddCssClass("mode-legend-description")
107- row.Append(&text.Widget)
108- return row
109-}
110-
111-func displayLegendKey(key string) string {
112- switch strings.ToLower(key) {
113- case "arrowright":
114- return "→"
115- case "arrowleft":
116- return "←"
117- case "arrowup":
118- return "↑"
119- case "arrowdown":
120- return "↓"
121- case "escape":
122- return "esc"
123- case "enter":
124- return "↵"
125- default:
126- return key
127- }
128-}
129-
130-func (f *modeFrame) setPending(pending string, actions map[string]entity.ActionBinding) {
131- if f == nil || f.lingering || f.mode != input.ModeVim {
132- return
133- }
134- f.pending = pending
135- for name, rows := range f.rows {
136- matches := legendSequenceMatches(pending, actions[name].Keys)
137- for _, row := range rows {
138- if matches {
139- row.RemoveCssClass("mode-legend-dim")
140- } else {
141- row.AddCssClass("mode-legend-dim")
142- }
143- }
144- }
145-}
146-
147-func legendSequenceMatches(pending string, keys []string) bool {
148- sequence := strings.TrimLeftFunc(pending, unicode.IsDigit)
149- if sequence == "" {
150- return true
151- }
152- for _, key := range keys {
153- if strings.HasPrefix(key, sequence) {
154- return true
155- }
156- }
157- return false
158-}
159-
160-func (f *modeFrame) flash(action input.Action) {
161- if f == nil || f.lingering || !f.visible || !f.config.ModeLegendAnimations {
162- return
163- }
164- name := string(action)
165- keycaps := f.keycaps[name]
166- if len(keycaps) == 0 {
167- name = strings.ReplaceAll(name, "_", "-")
168- keycaps = f.keycaps[name]
169- }
170- if len(keycaps) == 0 {
171- return
172- }
173- if f.flashTimer != 0 {
174- glib.SourceRemove(f.flashTimer)
175- f.flashTimer = 0
176- }
177- for _, caps := range f.keycaps {
178- for _, cap := range caps {
179- cap.RemoveCssClass("mode-legend-flash")
180- }
181- }
182- for _, cap := range keycaps {
183- cap.AddCssClass("mode-legend-flash")
184- }
185- cb := glib.SourceFunc(func(_ uintptr) bool {
186- for _, cap := range keycaps {
187- cap.RemoveCssClass("mode-legend-flash")
188- }
189- f.flashTimer = 0
190- return false
191- })
192- f.flashTimer = glib.TimeoutAdd(legendFlashDurationMs, &cb, 0)
193-}
A internal/ui/mode_toaster.go +434 -0
@@ -0,0 +1,434 @@
1+package ui
2+
3+import (
4+ "reflect"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/input"
9+ "github.com/bnema/puregotk/v4/glib"
10+)
11+
12+const legendFlashDurationMs = 120
13+
14+// chevronState is the expand/collapse affordance in the toaster header.
15+type chevronState int
16+
17+const (
18+ // chevronNone: nothing to expand (legend off, or no bindings).
19+ chevronNone chevronState = iota
20+ chevronExpanded
21+ chevronCollapsed
22+)
23+
24+// modeToasterView is the toolkit side of the mode toaster: a header pill and
25+// the legend body that expands from it. Implementations ignore calls that
26+// change nothing, since the toaster reconciles on every state change.
27+type modeToasterView interface {
28+ setHeader(text string)
29+ // setMode colors the pill and the legend for mode.
30+ setMode(mode input.Mode)
31+ setChevron(chevronState)
32+ setPillVisible(bool)
33+ // setPillInteractive makes the pill, and only the pill, receive pointer
34+ // input.
35+ setPillInteractive(bool)
36+ setLegend(groups []legendGroup)
37+ setStyle(style string)
38+ setAnimations(bool)
39+ setBodyVisible(bool)
40+ setBodyLingering(bool)
41+ setActionDimmed(action string, dimmed bool)
42+ // flashAction lights the keycaps of action and reports whether it has any.
43+ flashAction(action string) bool
44+ clearFlash()
45+ // lingerRect is the screen area of the legend body, in overlay coordinates,
46+ // while it is on screen.
47+ lingerRect() (x, y, width, height int, ok bool)
48+ destroy()
49+}
50+
51+// modeToaster is the per-window mode indicator: a pill anchored bottom-left
52+// that names the active mode and any pending key sequence or Vim sub-mode,
53+// plus the key legend that expands from it. The mode border around the target
54+// pane is modeFrame's.
55+//
56+// It holds all state and decisions; its view only paints. Main thread only.
57+type modeToaster struct {
58+ view modeToasterView
59+
60+ mode input.Mode
61+ detail string // pending sequence or Vim sub-mode, appended to the header
62+ pending string // pending sequence the legend rows are dimmed for
63+ cfg entity.WorkspaceStylingConfig
64+ style string
65+ actions map[string]entity.ActionBinding
66+ groups []legendGroup
67+
68+ // collapsed hides the legend body; the pill stays. It is shared by every
69+ // mode and window: the app owns it (modeLegendPreference), pushes it with
70+ // each configuration and broadcasts it on a click.
71+ collapsed bool
72+ // ready: the legend's show delay has elapsed.
73+ ready bool
74+ // suspended hides the legend while an in-page interaction (link hints,
75+ // visual selection) owns the keys.
76+ suspended bool
77+ // lingering keeps a read-only legend on screen after its mode ended while
78+ // the pointer was over it, until the next key press or click.
79+ lingering bool
80+ generation uint64
81+ showTimer uint
82+ flashTimer uint
83+ // onToggle receives the collapsed state a click on the pill asks for. The
84+ // app applies it to every window. Without it the toaster applies it alone.
85+ onToggle func(collapsed bool)
86+
87+ pressInFlight bool
88+ pressIdle uint
89+ pointerInside bool
90+ pointerX, pointerY float64
91+}
92+
93+func newModeToasterWithView(view modeToasterView, onToggle func(bool)) *modeToaster {
94+ return &modeToaster{view: view, mode: input.ModeNormal, onToggle: onToggle}
95+}
96+
97+// modeToasterHeader is the pill text: the mode name, then " · detail".
98+func modeToasterHeader(mode input.Mode, detail string) string {
99+ if detail == "" {
100+ return mode.DisplayName()
101+ }
102+ return mode.DisplayName() + " · " + detail
103+}
104+
105+func (t *modeToaster) live() bool { return t != nil && t.view != nil }
106+
107+// hasLegend reports whether a legend can expand from the pill.
108+func (t *modeToaster) hasLegend() bool {
109+ return t.mode != input.ModeNormal && modeLegendEnabled(t.cfg) && len(t.groups) > 0
110+}
111+
112+func (t *modeToaster) bodyShown() bool {
113+ return t.lingering || (t.hasLegend() && legendBodyShown(t.ready, t.collapsed, t.suspended))
114+}
115+
116+func (t *modeToaster) chevron() chevronState {
117+ switch {
118+ case !t.hasLegend():
119+ return chevronNone
120+ case t.collapsed:
121+ return chevronCollapsed
122+ default:
123+ return chevronExpanded
124+ }
125+}
126+
127+// reconcile pushes the current state to the view.
128+func (t *modeToaster) reconcile() {
129+ if !t.live() {
130+ return
131+ }
132+ pill := modeToasterVisible(t.mode, t.cfg)
133+ t.view.setPillVisible(pill)
134+ if pill {
135+ // A lingering legend has no pill (its mode ended) and keeps the colors
136+ // of the mode it was shown for.
137+ t.view.setMode(t.mode)
138+ t.view.setHeader(modeToasterHeader(t.mode, t.detail))
139+ t.view.setChevron(t.chevron())
140+ t.view.setPillInteractive(t.hasLegend())
141+ }
142+ t.view.setBodyVisible(t.bodyShown())
143+}
144+
145+// sameLegendInputs reports whether cfg, style and actions leave the built
146+// legend as it is. The indicator preference and the collapsed state are not
147+// part of it.
148+func (t *modeToaster) sameLegendInputs(cfg entity.WorkspaceStylingConfig, style string, actions map[string]entity.ActionBinding) bool {
149+ return cfg.ModeLegend == t.cfg.ModeLegend &&
150+ cfg.ModeLegendDelayMs == t.cfg.ModeLegendDelayMs &&
151+ cfg.ModeLegendAnimations == t.cfg.ModeLegendAnimations &&
152+ cfg.ModeLegendLinger == t.cfg.ModeLegendLinger &&
153+ style == t.style && reflect.DeepEqual(actions, t.actions)
154+}
155+
156+// apply reconciles the toaster with mode and the live configuration. A mode
157+// change, or a change to what the legend lists or how it shows, rebuilds it.
158+// Anything else (a config reload caused by saving the collapsed state, say)
159+// only updates the preferences, so the legend neither flickers nor restarts
160+// its show delay.
161+func (t *modeToaster) apply(mode input.Mode, cfg entity.WorkspaceStylingConfig, style string, actions map[string]entity.ActionBinding) {
162+ if !t.live() {
163+ return
164+ }
165+ t.collapsed = cfg.ModeLegendCollapsed
166+ if mode == t.mode && (mode == input.ModeNormal || t.sameLegendInputs(cfg, style, actions)) {
167+ t.cfg = cfg
168+ t.reconcile()
169+ return
170+ }
171+ t.setMode(mode, cfg, style, actions)
172+}
173+
174+func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig, style string, actions map[string]entity.ActionBinding) {
175+ t.cancelTimers()
176+ // Normal-mode refreshes must preserve an already lingering legend.
177+ keepLinger := mode == input.ModeNormal && t.mode == input.ModeNormal && t.lingering
178+ linger := shouldStartLinger(mode == input.ModeNormal && !t.lingering && t.bodyShown(), t.pressInFlight,
179+ cfg.ModeLegendLinger, t.pointerInside, t.pointerX, t.pointerY, t.view.lingerRect)
180+ if !keepLinger {
181+ t.clearLinger()
182+ }
183+ t.generation++
184+ // A rebuild within the same mode (a config hot reload during a Vim page
185+ // capture, say) keeps the interaction state: the sub-mode label, the pending
186+ // sequence and the suspension belong to what the user is doing, not to the
187+ // legend being rebuilt.
188+ if mode != t.mode {
189+ t.detail, t.pending = "", ""
190+ t.suspended = false
191+ }
192+ t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions
193+ t.ready = false
194+ t.view.setStyle(style)
195+ if linger {
196+ t.lingering = true
197+ t.clearFeedback()
198+ t.view.setBodyLingering(true)
199+ }
200+ if mode == input.ModeNormal || !modeLegendEnabled(cfg) {
201+ if !linger && !keepLinger {
202+ t.groups = nil
203+ t.view.setLegend(nil)
204+ }
205+ t.reconcile()
206+ return
207+ }
208+ t.groups = buildLegendGroups(mode, actions)
209+ t.view.setLegend(t.groups)
210+ if t.pending != "" {
211+ t.dimRulesOutPending()
212+ }
213+ t.view.setAnimations(cfg.ModeLegendAnimations)
214+ if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
215+ generation := t.generation
216+ cb := glib.SourceFunc(func(_ uintptr) bool {
217+ t.showTimer = 0
218+ if generation == t.generation && t.live() {
219+ t.ready = true
220+ t.reconcile()
221+ }
222+ return false
223+ })
224+ t.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
225+ } else {
226+ t.ready = true
227+ }
228+ t.reconcile()
229+}
230+
231+// setDetail shows detail after the Vim Mode name in the header: the sub-mode of
232+// an in-page interaction, or "" for the plain name. Other modes have none.
233+func (t *modeToaster) setDetail(detail string) {
234+ if !t.live() || t.mode != input.ModeVim || t.detail == detail {
235+ return
236+ }
237+ t.detail = detail
238+ t.reconcile()
239+}
240+
241+// setPending shows the pending key sequence in the header and dims the legend
242+// rows it rules out.
243+func (t *modeToaster) setPending(pending string) {
244+ if !t.live() || t.mode != input.ModeVim {
245+ return
246+ }
247+ t.detail = pending
248+ t.pending = pending
249+ t.dimRulesOutPending()
250+ t.reconcile()
251+}
252+
253+// dimRulesOutPending dims the legend rows the pending sequence rules out. A
254+// rebuilt legend starts undimmed, so it is applied again after each rebuild.
255+func (t *modeToaster) dimRulesOutPending() {
256+ for _, group := range t.groups {
257+ for _, row := range group.Rows {
258+ t.view.setActionDimmed(row.Action, !legendSequenceMatches(t.pending, row.Keys))
259+ }
260+ }
261+}
262+
263+// setSuspended hides the legend while suspended is true and brings it back
264+// otherwise, without touching the mode or a pending show delay. Used while an
265+// in-page interaction owns the keys, when the legend would list bindings that
266+// do not apply. The header keeps showing the sub-mode.
267+func (t *modeToaster) setSuspended(suspended bool) {
268+ if !t.live() || t.suspended == suspended {
269+ return
270+ }
271+ t.suspended = suspended
272+ t.reconcile()
273+}
274+
275+// setCollapsed collapses or expands the legend.
276+func (t *modeToaster) setCollapsed(collapsed bool) {
277+ if !t.live() || t.collapsed == collapsed {
278+ return
279+ }
280+ t.collapsed = collapsed
281+ if !collapsed && t.showTimer != 0 {
282+ // Expanding is a request to see the legend now, not after its delay.
283+ glib.SourceRemove(t.showTimer)
284+ t.showTimer = 0
285+ t.ready = true
286+ }
287+ t.reconcile()
288+}
289+
290+// pillClicked handles a click on the header: it toggles the legend.
291+func (t *modeToaster) pillClicked() {
292+ if !t.live() || t.lingering || !t.hasLegend() {
293+ return
294+ }
295+ next := nextModeLegendCollapsed(t.collapsed)
296+ if t.onToggle != nil {
297+ t.onToggle(next)
298+ return
299+ }
300+ t.setCollapsed(next)
301+}
302+
303+// release drops the legend of a tab whose workspace is going away. The header
304+// stays for the active mode.
305+func (t *modeToaster) release() {
306+ if !t.live() {
307+ return
308+ }
309+ t.cancelTimers()
310+ t.clearLinger()
311+ t.generation++
312+ t.ready = false
313+ t.reconcile()
314+}
315+
316+func (t *modeToaster) clearLinger() {
317+ if !t.lingering {
318+ return
319+ }
320+ t.lingering = false
321+ t.view.setBodyLingering(false)
322+}
323+
324+func (t *modeToaster) dismissLinger() {
325+ if !t.live() || !t.lingering {
326+ return
327+ }
328+ t.clearLinger()
329+ t.reconcile()
330+}
331+
332+func (t *modeToaster) clearFeedback() {
333+ for _, group := range t.groups {
334+ for _, row := range group.Rows {
335+ t.view.setActionDimmed(row.Action, false)
336+ }
337+ }
338+ t.view.clearFlash()
339+}
340+
341+func (t *modeToaster) cancelTimers() {
342+ if t.showTimer != 0 {
343+ glib.SourceRemove(t.showTimer)
344+ t.showTimer = 0
345+ }
346+ if t.flashTimer != 0 {
347+ glib.SourceRemove(t.flashTimer)
348+ t.flashTimer = 0
349+ }
350+}
351+
352+// flash lights the keycaps of a dispatched action.
353+func (t *modeToaster) flash(action input.Action) {
354+ if !t.live() || t.lingering || !t.bodyShown() || !t.cfg.ModeLegendAnimations {
355+ return
356+ }
357+ name := string(action)
358+ if !t.view.flashAction(name) {
359+ name = strings.ReplaceAll(name, "_", "-")
360+ if !t.view.flashAction(name) {
361+ return
362+ }
363+ }
364+ if t.flashTimer != 0 {
365+ glib.SourceRemove(t.flashTimer)
366+ }
367+ cb := glib.SourceFunc(func(_ uintptr) bool {
368+ t.flashTimer = 0
369+ if t.live() {
370+ t.view.clearFlash()
371+ }
372+ return false
373+ })
374+ t.flashTimer = glib.TimeoutAdd(legendFlashDurationMs, &cb, 0)
375+}
376+
377+func (t *modeToaster) pointerMoved(x, y float64) {
378+ t.pointerX, t.pointerY, t.pointerInside = x, y, true
379+}
380+
381+func (t *modeToaster) pointerLeft() { t.pointerInside = false }
382+
383+// keyPressed ends a lingering legend: the next key press means the user moved on.
384+func (t *modeToaster) keyPressed() { t.dismissLinger() }
385+
386+// pressStarted records a click outside the pill: it ends a lingering legend,
387+// and a mode that exits because of it must not start one.
388+func (t *modeToaster) pressStarted() {
389+ if !t.live() {
390+ return
391+ }
392+ t.dismissLinger()
393+ t.pressInFlight = true
394+ if t.pressIdle != 0 {
395+ glib.SourceRemove(t.pressIdle)
396+ }
397+ cb := glib.SourceFunc(func(_ uintptr) bool {
398+ t.pressInFlight = false
399+ t.pressIdle = 0
400+ return false
401+ })
402+ t.pressIdle = glib.IdleAdd(&cb, 0)
403+}
404+
405+func (t *modeToaster) destroy() {
406+ if t == nil {
407+ return
408+ }
409+ t.cancelTimers()
410+ if t.pressIdle != 0 {
411+ glib.SourceRemove(t.pressIdle)
412+ t.pressIdle = 0
413+ }
414+ if t.view != nil {
415+ t.view.destroy()
416+ t.view = nil
417+ }
418+}
419+
420+func pointInRect(x, y float64, rx, ry, width, height int) bool {
421+ return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
422+}
423+
424+func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
425+ if !enabled || !inside || placement == nil {
426+ return false
427+ }
428+ rx, ry, w, h, ok := placement()
429+ return ok && pointInRect(x, y, rx, ry, w, h)
430+}
431+
432+func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
433+ return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
434+}
A internal/ui/mode_toaster_fake_test.go +89 -0
@@ -0,0 +1,89 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/puregotk/v4/glib"
5+
6+ "github.com/bnema/dumber/internal/ui/input"
7+)
8+
9+// fakeModeToasterView records what the mode toaster asks its view to paint.
10+type fakeModeToasterView struct {
11+ header string
12+ headers []string // every distinct header, in order
13+ mode input.Mode
14+ chevron chevronState
15+ pillShown bool
16+ interactive bool
17+ bodyShown bool
18+ lingering bool
19+ animations bool
20+ style string
21+ groups []legendGroup
22+ dimmed map[string]bool
23+ flashed map[string]bool
24+ flashable map[string]bool
25+ rect func() (int, int, int, int, bool)
26+ destroyed bool
27+}
28+
29+func newFakeModeToasterView() *fakeModeToasterView {
30+ return &fakeModeToasterView{dimmed: map[string]bool{}, flashed: map[string]bool{}, flashable: map[string]bool{}}
31+}
32+
33+func (f *fakeModeToasterView) setHeader(text string) {
34+ if text != f.header {
35+ f.headers = append(f.headers, text)
36+ }
37+ f.header = text
38+}
39+func (f *fakeModeToasterView) setMode(mode input.Mode) { f.mode = mode }
40+func (f *fakeModeToasterView) setChevron(c chevronState) { f.chevron = c }
41+func (f *fakeModeToasterView) setPillVisible(v bool) { f.pillShown = v }
42+func (f *fakeModeToasterView) setPillInteractive(v bool) { f.interactive = v }
43+func (f *fakeModeToasterView) setStyle(style string) { f.style = style }
44+func (f *fakeModeToasterView) setAnimations(v bool) { f.animations = v }
45+func (f *fakeModeToasterView) setBodyVisible(v bool) { f.bodyShown = v }
46+func (f *fakeModeToasterView) setBodyLingering(v bool) { f.lingering = v }
47+func (f *fakeModeToasterView) setActionDimmed(a string, d bool) { f.dimmed[a] = d }
48+func (f *fakeModeToasterView) clearFlash() { f.flashed = map[string]bool{} }
49+func (f *fakeModeToasterView) destroy() { f.destroyed = true }
50+func (f *fakeModeToasterView) setLegend(groups []legendGroup) {
51+ f.groups = groups
52+ f.dimmed = map[string]bool{} // new rows start undimmed
53+ f.flashable = map[string]bool{}
54+ for _, group := range groups {
55+ for _, row := range group.Rows {
56+ f.flashable[row.Action] = true
57+ }
58+ }
59+}
60+
61+func (f *fakeModeToasterView) flashAction(action string) bool {
62+ if !f.flashable[action] {
63+ return false
64+ }
65+ f.flashed = map[string]bool{action: true}
66+ return true
67+}
68+
69+func (f *fakeModeToasterView) lingerRect() (int, int, int, int, bool) {
70+ if f.rect == nil {
71+ return 0, 0, 0, 0, false
72+ }
73+ return f.rect()
74+}
75+
76+// newFakeModeToasterWindow returns a window whose mode toaster paints into a
77+// fake view.
78+func newFakeModeToasterWindow(id string) (*browserWindow, *fakeModeToasterView) {
79+ view := newFakeModeToasterView()
80+ return &browserWindow{id: id, modeToaster: newModeToasterWithView(view, nil)}, view
81+}
82+
83+// pumpMainContext runs pending GLib sources (show delays, flash timers).
84+func pumpMainContext() {
85+ ctx := glib.MainContextDefault()
86+ for ctx.Pending() {
87+ ctx.Iteration(false)
88+ }
89+}
A internal/ui/mode_toaster_layout.go +163 -0
@@ -0,0 +1,163 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/dumber/internal/domain/entity"
5+ "github.com/bnema/dumber/internal/ui/input"
6+)
7+
8+// Legend band geometry. The legend is a wide band anchored on the mode toaster,
9+// not a full-width bar: its width follows the window up to a cap, and its
10+// column count follows its width.
11+const (
12+ // legendMaxWidth caps the band width.
13+ legendMaxWidth = 1100
14+ // legendMinColumnWidth is the narrowest a column may get before the band
15+ // drops a column and grows taller instead.
16+ legendMinColumnWidth = 240
17+ // legendComfortColumnWidth is the column width the band shrinks to when the
18+ // mode has fewer groups than the width could hold columns.
19+ legendComfortColumnWidth = 360
20+ legendMaxColumns = 4
21+ // legendColumnGap is the horizontal space between columns.
22+ legendColumnGap = 12
23+ // legendPanelPadding is the horizontal chrome (border and padding) around
24+ // the columns.
25+ legendPanelPadding = 28
26+ // legendMaxHeightPercent bounds the legend's scrolling body, as a share of
27+ // the content height.
28+ legendMaxHeightPercent = 45
29+ // legendChromeHeight is the vertical chrome (border, padding, hint line)
30+ // around the scrolling body, subtracted from the height budget.
31+ legendChromeHeight = 40
32+ legendMinBodyHeight = 60
33+ // modeToasterMargin separates the toaster from the window edges, and the
34+ // legend band from the right edge.
35+ modeToasterMargin = 8
36+)
37+
38+// legendColumnCount returns how many columns of at least legendMinColumnWidth
39+// fit a band of panelWidth, from 1 to legendMaxColumns.
40+func legendColumnCount(panelWidth int) int {
41+ columns := (panelWidth - legendPanelPadding + legendColumnGap) / (legendMinColumnWidth + legendColumnGap)
42+ return min(max(columns, 1), legendMaxColumns)
43+}
44+
45+// legendBandWidth returns the band width and column count for a legend of
46+// groups groups when availableWidth pixels lie between the toaster's left edge
47+// and the right margin. A band for few groups does not stretch over columns it
48+// cannot fill.
49+func legendBandWidth(availableWidth, groups int) (width, columns int) {
50+ width = min(availableWidth, legendMaxWidth)
51+ if width <= 0 || groups <= 0 {
52+ return 0, 0
53+ }
54+ columns = legendColumnCount(width)
55+ if groups < columns {
56+ columns = groups
57+ width = min(width, legendPanelPadding+columns*legendComfortColumnWidth+(columns-1)*legendColumnGap)
58+ }
59+ return width, columns
60+}
61+
62+// legendBodyMaxHeight returns the height past which the legend body scrolls.
63+func legendBodyMaxHeight(contentHeight int) int {
64+ return max(contentHeight*legendMaxHeightPercent/100-legendChromeHeight, legendMinBodyHeight)
65+}
66+
67+// distributeLegendGroups splits groups, given by their heights in display
68+// order, into at most columns columns. Each column holds consecutive groups, so
69+// the order reads down the first column, then the next, and no group is split.
70+// Among all splits it picks the one with the shortest tallest column, then the
71+// most even one; remaining ties go to the earliest split. The result lists
72+// group indices per column and has no empty column.
73+func distributeLegendGroups(heights []int, columns int) [][]int {
74+ if len(heights) == 0 || columns < 1 {
75+ return nil
76+ }
77+ columns = min(columns, len(heights))
78+ best := legendSplit{tallest: -1}
79+ cuts := make([]int, 0, columns-1)
80+ var search func(start int)
81+ search = func(start int) {
82+ if len(cuts) == columns-1 {
83+ candidate := scoreLegendSplit(heights, cuts)
84+ if best.tallest < 0 || candidate.betterThan(best) {
85+ best = candidate
86+ }
87+ return
88+ }
89+ // Leave one group for each column still to come.
90+ remaining := columns - 1 - len(cuts)
91+ for cut := start; cut <= len(heights)-remaining; cut++ {
92+ cuts = append(cuts, cut)
93+ search(cut + 1)
94+ cuts = cuts[:len(cuts)-1]
95+ }
96+ }
97+ search(1)
98+
99+ out := make([][]int, 0, columns)
100+ first := 0
101+ for _, cut := range append(best.cuts, len(heights)) {
102+ column := make([]int, 0, cut-first)
103+ for i := first; i < cut; i++ {
104+ column = append(column, i)
105+ }
106+ out = append(out, column)
107+ first = cut
108+ }
109+ return out
110+}
111+
112+type legendSplit struct {
113+ cuts []int
114+ tallest int
115+ sumSquares int
116+}
117+
118+func (s legendSplit) betterThan(other legendSplit) bool {
119+ if s.tallest != other.tallest {
120+ return s.tallest < other.tallest
121+ }
122+ return s.sumSquares < other.sumSquares
123+}
124+
125+func scoreLegendSplit(heights, cuts []int) legendSplit {
126+ split := legendSplit{cuts: append([]int(nil), cuts...)}
127+ first, sum := 0, 0
128+ for _, cut := range append(append([]int(nil), cuts...), len(heights)) {
129+ sum = 0
130+ for _, h := range heights[first:cut] {
131+ sum += h
132+ }
133+ split.tallest = max(split.tallest, sum)
134+ split.sumSquares += sum * sum
135+ first = cut
136+ }
137+ return split
138+}
139+
140+// modeToasterVisible reports whether the toaster is shown for mode. The
141+// indicator preference alone does not hide it while a legend can expand from
142+// it: the toaster is the legend's header.
143+func modeToasterVisible(mode input.Mode, cfg entity.WorkspaceStylingConfig) bool {
144+ return mode != input.ModeNormal && (cfg.ModeIndicatorToasterEnabled || modeLegendEnabled(cfg))
145+}
146+
147+// modeLegendEnabled reports whether a legend can expand from the toaster.
148+// With the legend off the toaster is a plain indicator: no chevron, no toggle.
149+func modeLegendEnabled(cfg entity.WorkspaceStylingConfig) bool {
150+ return cfg.ModeLegend != "off"
151+}
152+
153+// legendBodyShown reports whether the expanded legend is on screen. ready is
154+// set once the legend's show delay has elapsed. A suspended legend (an in-page
155+// interaction owns the keys) and a collapsed one stay hidden.
156+func legendBodyShown(ready, collapsed, suspended bool) bool {
157+ return ready && !collapsed && !suspended
158+}
159+
160+// nextModeLegendCollapsed is the collapsed state after a click on the toaster.
161+func nextModeLegendCollapsed(collapsed bool) bool {
162+ return !collapsed
163+}
A internal/ui/mode_toaster_layout_test.go +230 -0
@@ -0,0 +1,230 @@
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/stretchr/testify/require"
9+)
10+
11+func TestLegendColumnCount(t *testing.T) {
12+ for _, tc := range []struct {
13+ width, want int
14+ }{
15+ {0, 1},
16+ {200, 1},
17+ {legendPanelPadding + legendMinColumnWidth, 1},
18+ {legendPanelPadding + legendMinColumnWidth + legendColumnGap + legendMinColumnWidth - 1, 1},
19+ {legendPanelPadding + 2*legendMinColumnWidth + legendColumnGap, 2},
20+ {legendPanelPadding + 3*legendMinColumnWidth + 2*legendColumnGap, 3},
21+ {legendPanelPadding + 4*legendMinColumnWidth + 3*legendColumnGap, 4},
22+ {legendMaxWidth, 4},
23+ {5000, legendMaxColumns},
24+ } {
25+ require.Equal(t, tc.want, legendColumnCount(tc.width), "width %d", tc.width)
26+ }
27+}
28+
29+func TestLegendColumnCountIsMonotonic(t *testing.T) {
30+ previous := 0
31+ for width := 0; width <= 2000; width++ {
32+ got := legendColumnCount(width)
33+ require.GreaterOrEqual(t, got, previous, "width %d", width)
34+ require.GreaterOrEqual(t, got, 1)
35+ require.LessOrEqual(t, got, legendMaxColumns)
36+ previous = got
37+ }
38+}
39+
40+func TestLegendBandWidthIsCappedAndNeverFullWidth(t *testing.T) {
41+ width, columns := legendBandWidth(2400, 10)
42+ require.Equal(t, legendMaxWidth, width)
43+ require.Equal(t, 4, columns)
44+
45+ width, columns = legendBandWidth(800, 10)
46+ require.Equal(t, 800, width)
47+ require.Equal(t, legendColumnCount(800), columns)
48+
49+ width, columns = legendBandWidth(300, 10)
50+ require.Equal(t, 300, width)
51+ require.Equal(t, 1, columns)
52+}
53+
54+func TestLegendBandWidthShrinksForFewGroups(t *testing.T) {
55+ width, columns := legendBandWidth(2400, 2)
56+ require.Equal(t, 2, columns)
57+ require.Less(t, width, legendMaxWidth)
58+ require.GreaterOrEqual(t, legendColumnCount(width), columns, "the shrunk band still fits its columns")
59+
60+ width, columns = legendBandWidth(2400, 1)
61+ require.Equal(t, 1, columns)
62+ require.GreaterOrEqual(t, width, legendMinColumnWidth+legendPanelPadding)
63+}
64+
65+func TestLegendBandWidthWithoutRoom(t *testing.T) {
66+ width, columns := legendBandWidth(0, 5)
67+ require.Zero(t, width)
68+ require.Zero(t, columns)
69+ width, columns = legendBandWidth(500, 0)
70+ require.Zero(t, width)
71+ require.Zero(t, columns)
72+}
73+
74+func TestLegendBodyMaxHeight(t *testing.T) {
75+ require.Equal(t, 1000*45/100-legendChromeHeight, legendBodyMaxHeight(1000))
76+ require.Equal(t, legendMinBodyHeight, legendBodyMaxHeight(100), "a tiny window still shows a few rows")
77+}
78+
79+// columnsOf flattens a distribution back into group order.
80+func flattenDistribution(columns [][]int) []int {
81+ var out []int
82+ for _, column := range columns {
83+ out = append(out, column...)
84+ }
85+ return out
86+}
87+
88+func tallest(heights []int, columns [][]int) int {
89+ best := 0
90+ for _, column := range columns {
91+ sum := 0
92+ for _, i := range column {
93+ sum += heights[i]
94+ }
95+ best = max(best, sum)
96+ }
97+ return best
98+}
99+
100+func TestDistributeLegendGroupsKeepsOrderAndNeverSplitsGroups(t *testing.T) {
101+ heights := []int{4, 1, 7, 3, 2, 9, 2, 5, 1, 3}
102+ want := make([]int, len(heights))
103+ for i := range want {
104+ want[i] = i
105+ }
106+ for columns := 1; columns <= legendMaxColumns; columns++ {
107+ got := distributeLegendGroups(heights, columns)
108+ require.Len(t, got, columns)
109+ require.Equal(t, want, flattenDistribution(got), "columns=%d keeps the fixed group order, each group once", columns)
110+ for _, column := range got {
111+ require.NotEmpty(t, column, "no empty column")
112+ }
113+ }
114+}
115+
116+func TestDistributeLegendGroupsBalancesColumnHeights(t *testing.T) {
117+ // One 8-row group next to four 1-row groups, in two columns: the tall
118+ // group alone is the tallest column, the rest share the other.
119+ heights := []int{8, 2, 2, 2, 2}
120+ got := distributeLegendGroups(heights, 2)
121+ require.Equal(t, [][]int{{0}, {1, 2, 3, 4}}, got)
122+ require.Equal(t, 8, tallest(heights, got))
123+
124+ // Even groups split evenly.
125+ got = distributeLegendGroups([]int{3, 3, 3, 3}, 2)
126+ require.Equal(t, [][]int{{0, 1}, {2, 3}}, got)
127+}
128+
129+func TestDistributeLegendGroupsMinimizesTallestColumn(t *testing.T) {
130+ heights := []int{5, 1, 1, 1, 6, 2, 2, 4}
131+ for columns := 1; columns <= 4; columns++ {
132+ got := distributeLegendGroups(heights, columns)
133+ require.Equal(t, bruteForceTallest(heights, columns), tallest(heights, got), "columns=%d", columns)
134+ }
135+}
136+
137+// bruteForceTallest tries every way to cut heights into columns runs.
138+func bruteForceTallest(heights []int, columns int) int {
139+ if columns == 1 {
140+ sum := 0
141+ for _, h := range heights {
142+ sum += h
143+ }
144+ return sum
145+ }
146+ best := -1
147+ sum := 0
148+ for cut := 1; cut <= len(heights)-columns+1; cut++ {
149+ sum += heights[cut-1]
150+ rest := bruteForceTallest(heights[cut:], columns-1)
151+ candidate := max(sum, rest)
152+ if best < 0 || candidate < best {
153+ best = candidate
154+ }
155+ }
156+ return best
157+}
158+
159+func TestDistributeLegendGroupsIsDeterministic(t *testing.T) {
160+ heights := []int{2, 2, 2, 2, 2, 2}
161+ first := distributeLegendGroups(heights, 3)
162+ for range 20 {
163+ require.Equal(t, first, distributeLegendGroups(heights, 3))
164+ }
165+ require.Equal(t, [][]int{{0, 1}, {2, 3}, {4, 5}}, first)
166+}
167+
168+func TestDistributeLegendGroupsEdgeCases(t *testing.T) {
169+ require.Nil(t, distributeLegendGroups(nil, 3))
170+ require.Nil(t, distributeLegendGroups([]int{2, 3}, 0))
171+ // More columns than groups: one group per column.
172+ require.Equal(t, [][]int{{0}, {1}}, distributeLegendGroups([]int{2, 3}, 4))
173+ require.Equal(t, [][]int{{0}}, distributeLegendGroups([]int{7}, 3))
174+ require.Equal(t, [][]int{{0, 1, 2}}, distributeLegendGroups([]int{1, 1, 1}, 1))
175+}
176+
177+func TestBuildLegendGroupsOrdersGroupsAndRows(t *testing.T) {
178+ actions := map[string]entity.ActionBinding{
179+ "enter-tab-mode": {Keys: []string{"t"}, Desc: "Tab mode"},
180+ "split-right": {Keys: []string{"r"}},
181+ "split-left": {Keys: []string{"l"}},
182+ "focus-up": {Keys: []string{"k"}},
183+ "confirm": {Keys: []string{"enter"}},
184+ "close-pane": {Keys: []string{"x"}},
185+ "unbound": {},
186+ "resize-increase": {Keys: []string{"+"}},
187+ }
188+ groups := buildLegendGroups(input.ModePane, actions)
189+ var names []string
190+ for _, group := range groups {
191+ names = append(names, group.Name)
192+ }
193+ require.Equal(t, []string{"SPLIT", "FOCUS", "RESIZE", "MANAGE", "MODES", "EXIT"}, names)
194+ require.Equal(t, "split left", groups[0].Rows[0].Desc, "rows are sorted and fall back to the action name")
195+ require.Equal(t, "split-left", groups[0].Rows[0].Action)
196+ require.Equal(t, "Tab mode", groups[4].Rows[0].Desc)
197+ require.Equal(t, []int{3, 2, 2, 2, 2, 2}, legendGroupHeights(groups))
198+}
199+
200+func TestBuildLegendGroupsEmpty(t *testing.T) {
201+ require.Empty(t, buildLegendGroups(input.ModeVim, nil))
202+ require.Empty(t, buildLegendGroups(input.ModeVim, map[string]entity.ActionBinding{"visual": {}}))
203+}
204+
205+func TestModeToasterVisibility(t *testing.T) {
206+ on := entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "delay"}
207+ require.True(t, modeToasterVisible(input.ModeVim, on))
208+ require.False(t, modeToasterVisible(input.ModeNormal, on))
209+
210+ // The indicator preference off, legend on: the pill is the legend's header.
211+ headerOnly := entity.WorkspaceStylingConfig{ModeLegend: "always"}
212+ require.True(t, modeToasterVisible(input.ModePane, headerOnly))
213+
214+ // Both off: nothing.
215+ require.False(t, modeToasterVisible(input.ModePane, entity.WorkspaceStylingConfig{ModeLegend: "off"}))
216+ // Legend off, indicator on: plain pill.
217+ require.True(t, modeToasterVisible(input.ModePane, entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"}))
218+}
219+
220+func TestLegendBodyShown(t *testing.T) {
221+ require.True(t, legendBodyShown(true, false, false))
222+ require.False(t, legendBodyShown(false, false, false), "show delay not elapsed")
223+ require.False(t, legendBodyShown(true, true, false), "collapsed")
224+ require.False(t, legendBodyShown(true, false, true), "suspended by a page interaction")
225+}
226+
227+func TestNextModeLegendCollapsed(t *testing.T) {
228+ require.True(t, nextModeLegendCollapsed(false))
229+ require.False(t, nextModeLegendCollapsed(true))
230+}
A internal/ui/mode_toaster_legend.go +155 -0
@@ -0,0 +1,155 @@
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+)
11+
12+// legendGroupOrder is the fixed display order of legend groups.
13+var legendGroupOrder = []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "SELECT", "MANAGE", "MODES", "EXIT"}
14+
15+// legendRow is one action of a legend group.
16+type legendRow struct {
17+ Action string
18+ Keys []string // as configured; the view formats them for display
19+ Desc string
20+}
21+
22+// legendGroup is a titled run of related actions. A group is never split
23+// across columns.
24+type legendGroup struct {
25+ Name string
26+ Rows []legendRow
27+}
28+
29+func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session entity.SessionConfig) map[string]entity.ActionBinding {
30+ switch mode {
31+ case input.ModePane:
32+ return workspace.PaneMode.Actions
33+ case input.ModeResize:
34+ return workspace.ResizeMode.Actions
35+ case input.ModeVim:
36+ return workspace.VimMode.Actions
37+ case input.ModeTab:
38+ return workspace.TabMode.Actions
39+ case input.ModeSession:
40+ return session.SessionMode.Actions
41+ case input.ModePrefix:
42+ return workspace.PrefixMode.Actions
43+ default:
44+ return nil
45+ }
46+}
47+
48+// buildLegendGroups arranges the bound actions of mode in legendGroupOrder,
49+// each group sorted by action name. Actions without keys are left out.
50+func buildLegendGroups(mode input.Mode, actions map[string]entity.ActionBinding) []legendGroup {
51+ names := make(map[string][]string)
52+ for name, binding := range actions {
53+ if len(binding.Keys) == 0 {
54+ continue
55+ }
56+ group := modeLegendGroup(mode, name)
57+ names[group] = append(names[group], name)
58+ }
59+ var groups []legendGroup
60+ for _, groupName := range legendGroupOrder {
61+ members := names[groupName]
62+ if len(members) == 0 {
63+ continue
64+ }
65+ slices.Sort(members)
66+ group := legendGroup{Name: groupName}
67+ for _, name := range members {
68+ binding := actions[name]
69+ desc := binding.Desc
70+ if desc == "" {
71+ desc = strings.ReplaceAll(name, "-", " ")
72+ }
73+ group.Rows = append(group.Rows, legendRow{Action: name, Keys: binding.Keys, Desc: desc})
74+ }
75+ groups = append(groups, group)
76+ }
77+ return groups
78+}
79+
80+// legendGroupHeights measures groups in rows: the title counts as one.
81+func legendGroupHeights(groups []legendGroup) []int {
82+ heights := make([]int, len(groups))
83+ for i, group := range groups {
84+ heights[i] = 1 + len(group.Rows)
85+ }
86+ return heights
87+}
88+
89+func displayLegendKey(key string) string {
90+ switch strings.ToLower(key) {
91+ case "arrowright":
92+ return "→"
93+ case "arrowleft":
94+ return "←"
95+ case "arrowup":
96+ return "↑"
97+ case "arrowdown":
98+ return "↓"
99+ case "escape":
100+ return "esc"
101+ case "enter":
102+ return "↵"
103+ default:
104+ return key
105+ }
106+}
107+
108+func legendSequenceMatches(pending string, keys []string) bool {
109+ sequence := strings.TrimLeftFunc(pending, unicode.IsDigit)
110+ if sequence == "" {
111+ return true
112+ }
113+ for _, key := range keys {
114+ if strings.HasPrefix(key, sequence) {
115+ return true
116+ }
117+ }
118+ return false
119+}
120+
121+// vimLegendGroup returns the legend group of a Vim Mode action, or "" when the
122+// action belongs to a generic group.
123+func vimLegendGroup(name string) string {
124+ switch {
125+ case strings.HasPrefix(name, "hint-") || strings.HasPrefix(name, "yank-") || name == "visual":
126+ return "SELECT"
127+ case strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline":
128+ return "JUMP"
129+ default:
130+ return ""
131+ }
132+}
133+
134+func modeLegendGroup(mode input.Mode, name string) string {
135+ switch {
136+ case name == "confirm" || name == "cancel":
137+ return "EXIT"
138+ case strings.HasPrefix(name, "split-"):
139+ return "SPLIT"
140+ case strings.HasPrefix(name, "enter-"):
141+ return "MODES"
142+ case strings.HasPrefix(name, "focus-"):
143+ return "FOCUS"
144+ case strings.HasPrefix(name, "resize-"):
145+ return "RESIZE"
146+ case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
147+ return "SCROLL"
148+ case mode == input.ModeVim && vimLegendGroup(name) != "":
149+ return vimLegendGroup(name)
150+ case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"):
151+ return "SWITCH"
152+ default:
153+ return "MANAGE"
154+ }
155+}
A internal/ui/mode_toaster_test.go +628 -0
@@ -0,0 +1,628 @@
1+package ui
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/input"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+var testVimActions = map[string]entity.ActionBinding{
15+ "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll down"},
16+ "heading-next": {Keys: []string{"]]"}},
17+ "visual": {Keys: []string{"v"}},
18+ "cancel": {Keys: []string{"esc"}},
19+}
20+
21+func legendOn(mode string) entity.WorkspaceStylingConfig {
22+ return entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: mode, ModeLegendAnimations: true}
23+}
24+
25+func newTestToaster(onToggle func(bool)) (*modeToaster, *fakeModeToasterView) {
26+ view := newFakeModeToasterView()
27+ return newModeToasterWithView(view, onToggle), view
28+}
29+
30+func TestModeToasterHeader(t *testing.T) {
31+ require.Equal(t, "VIM MODE", modeToasterHeader(input.ModeVim, ""))
32+ require.Equal(t, "VIM MODE · 2g", modeToasterHeader(input.ModeVim, "2g"))
33+ require.Equal(t, "VIM MODE · HINTS · NEW PANE", modeToasterHeader(input.ModeVim, "HINTS · NEW PANE"))
34+ require.Equal(t, "PANE MODE", modeToasterHeader(input.ModePane, ""))
35+}
36+
37+func TestModeToasterShowsPillAndLegendOnEnter(t *testing.T) {
38+ toaster, view := newTestToaster(nil)
39+
40+ toaster.apply(input.ModeVim, legendOn("always"), "command", testVimActions)
41+
42+ require.True(t, view.pillShown)
43+ require.Equal(t, "VIM MODE", view.header)
44+ require.Equal(t, input.ModeVim, view.mode)
45+ require.True(t, view.bodyShown)
46+ require.Equal(t, chevronExpanded, view.chevron)
47+ require.True(t, view.interactive)
48+ require.Equal(t, "command", view.style)
49+ var names []string
50+ for _, group := range view.groups {
51+ names = append(names, group.Name)
52+ }
53+ require.Equal(t, []string{"SCROLL", "JUMP", "SELECT", "EXIT"}, names)
54+}
55+
56+func TestModeToasterDelayWithholdsLegendNotPill(t *testing.T) {
57+ toaster, view := newTestToaster(nil)
58+ cfg := legendOn("delay")
59+ cfg.ModeLegendDelayMs = 20
60+
61+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
62+ require.True(t, view.pillShown, "the pill appears at once")
63+ require.False(t, view.bodyShown, "the legend waits for the delay")
64+
65+ waitFor(t, func() bool { return view.bodyShown })
66+}
67+
68+func TestModeToasterDelayZeroShowsLegendAtOnce(t *testing.T) {
69+ toaster, view := newTestToaster(nil)
70+ cfg := legendOn("delay")
71+ cfg.ModeLegendDelayMs = 0
72+
73+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
74+
75+ require.True(t, view.bodyShown)
76+}
77+
78+func TestModeToasterModeExitBeforeDelayNeverShowsLegend(t *testing.T) {
79+ toaster, view := newTestToaster(nil)
80+ cfg := legendOn("delay")
81+ cfg.ModeLegendDelayMs = 30
82+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
83+ toaster.apply(input.ModeNormal, cfg, "", nil)
84+
85+ time.Sleep(80 * time.Millisecond)
86+ pumpMainContext()
87+
88+ require.False(t, view.bodyShown)
89+ require.False(t, view.pillShown)
90+}
91+
92+func TestModeToasterLegendOffIsPillOnlyWithoutChevron(t *testing.T) {
93+ toaster, view := newTestToaster(nil)
94+
95+ toaster.apply(input.ModeVim, legendOn("off"), "", testVimActions)
96+
97+ require.True(t, view.pillShown)
98+ require.False(t, view.bodyShown)
99+ require.Equal(t, chevronNone, view.chevron)
100+ require.False(t, view.interactive, "nothing to toggle: the pill ignores the pointer")
101+ toaster.pillClicked()
102+ require.False(t, toaster.collapsed, "a click on a legend-less pill does nothing")
103+}
104+
105+func TestModeToasterNothingWhenIndicatorAndLegendOff(t *testing.T) {
106+ toaster, view := newTestToaster(nil)
107+ cfg := entity.WorkspaceStylingConfig{ModeLegend: "off"}
108+
109+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
110+
111+ require.False(t, view.pillShown)
112+ require.False(t, view.bodyShown)
113+}
114+
115+func TestModeToasterPillClickTogglesThroughTheApp(t *testing.T) {
116+ var asked []bool
117+ toaster, view := newTestToaster(func(collapsed bool) { asked = append(asked, collapsed) })
118+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
119+
120+ toaster.pillClicked()
121+
122+ require.Equal(t, []bool{true}, asked)
123+ require.True(t, view.bodyShown, "the toaster waits for the app to broadcast the new state")
124+}
125+
126+func TestModeToasterCollapseHidesBodyKeepsPill(t *testing.T) {
127+ toaster, view := newTestToaster(nil)
128+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
129+
130+ toaster.setCollapsed(true)
131+
132+ require.False(t, view.bodyShown)
133+ require.True(t, view.pillShown, "collapsed leaves the pill, clickable to re-expand")
134+ require.True(t, view.interactive)
135+ require.Equal(t, chevronCollapsed, view.chevron)
136+
137+ toaster.setCollapsed(false)
138+ require.True(t, view.bodyShown)
139+ require.Equal(t, chevronExpanded, view.chevron)
140+}
141+
142+func TestModeToasterPillClickWithoutAppTogglesLocally(t *testing.T) {
143+ toaster, view := newTestToaster(nil)
144+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
145+
146+ toaster.pillClicked()
147+ require.False(t, view.bodyShown)
148+ toaster.pillClicked()
149+ require.True(t, view.bodyShown)
150+}
151+
152+func TestModeToasterStartsCollapsedFromConfiguration(t *testing.T) {
153+ toaster, view := newTestToaster(nil)
154+ cfg := legendOn("always")
155+ cfg.ModeLegendCollapsed = true
156+
157+ toaster.apply(input.ModePane, cfg, "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
158+
159+ require.True(t, view.pillShown)
160+ require.False(t, view.bodyShown)
161+ require.Equal(t, chevronCollapsed, view.chevron)
162+}
163+
164+func TestModeToasterCollapsedStateSurvivesModeChanges(t *testing.T) {
165+ toaster, view := newTestToaster(nil)
166+ cfg := legendOn("always")
167+ cfg.ModeLegendCollapsed = true
168+ pane := map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}}
169+
170+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
171+ toaster.apply(input.ModePane, cfg, "", pane)
172+
173+ require.False(t, view.bodyShown, "the collapsed state is shared by all modes")
174+ require.Equal(t, input.ModePane, view.mode)
175+}
176+
177+func TestModeToasterExpandingSkipsRemainingDelay(t *testing.T) {
178+ toaster, view := newTestToaster(nil)
179+ cfg := legendOn("delay")
180+ cfg.ModeLegendDelayMs = 10_000
181+ cfg.ModeLegendCollapsed = true
182+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
183+
184+ require.False(t, view.bodyShown)
185+
186+ toaster.setCollapsed(false)
187+ require.True(t, view.bodyShown, "expanding is a request to see the legend now, not after the delay")
188+ require.Zero(t, toaster.showTimer)
189+}
190+
191+func TestModeToasterConfigReloadDoesNotRestartLegend(t *testing.T) {
192+ toaster, view := newTestToaster(nil)
193+ cfg := legendOn("always")
194+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
195+ generation := toaster.generation
196+ view.groups = nil
197+
198+ // A reload that only flips the collapsed preference (the app's own save).
199+ cfg.ModeLegendCollapsed = true
200+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
201+
202+ require.Equal(t, generation, toaster.generation, "not a mode change")
203+ require.Nil(t, view.groups, "the legend is not rebuilt")
204+ require.False(t, view.bodyShown)
205+}
206+
207+func TestModeToasterConfigReloadRebuildsWhenBindingsChange(t *testing.T) {
208+ toaster, view := newTestToaster(nil)
209+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
210+
211+ changed := map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}
212+ toaster.apply(input.ModeVim, legendOn("always"), "", changed)
213+
214+ require.Len(t, view.groups, 1)
215+ require.Equal(t, "SELECT", view.groups[0].Name)
216+}
217+
218+func TestModeToasterIndicatorPreferenceReloadKeepsLegend(t *testing.T) {
219+ toaster, view := newTestToaster(nil)
220+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
221+
222+ noIndicator := legendOn("always")
223+ noIndicator.ModeIndicatorToasterEnabled = false
224+ toaster.apply(input.ModeVim, noIndicator, "", testVimActions)
225+ require.True(t, view.pillShown, "legend still on: the pill remains as its header")
226+ require.True(t, view.bodyShown)
227+
228+ toaster.apply(input.ModeVim, legendOn("off"), "", testVimActions)
229+ require.True(t, view.pillShown, "indicator back on, legend off: plain pill")
230+ require.False(t, view.bodyShown)
231+}
232+
233+func TestModeToasterPendingSequenceShowsInHeaderAndDimsRows(t *testing.T) {
234+ toaster, view := newTestToaster(nil)
235+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
236+
237+ toaster.setPending("]")
238+
239+ require.Equal(t, "VIM MODE · ]", view.header)
240+ require.False(t, view.dimmed["heading-next"])
241+ require.True(t, view.dimmed["vim-scroll-down"])
242+ require.True(t, view.dimmed["visual"])
243+
244+ toaster.setPending("")
245+ require.Equal(t, "VIM MODE", view.header)
246+ require.False(t, view.dimmed["vim-scroll-down"])
247+}
248+
249+func TestModeToasterPendingOnlyAppliesToVimMode(t *testing.T) {
250+ toaster, view := newTestToaster(nil)
251+ toaster.apply(input.ModePane, legendOn("always"), "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
252+
253+ toaster.setPending("x")
254+
255+ require.Equal(t, "PANE MODE", view.header)
256+ require.False(t, view.dimmed["split-right"])
257+}
258+
259+func TestModeToasterSubModeSuspendsLegendButKeepsHeader(t *testing.T) {
260+ toaster, view := newTestToaster(nil)
261+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
262+
263+ toaster.setDetail("HINTS")
264+ toaster.setSuspended(true)
265+ require.Equal(t, "VIM MODE · HINTS", view.header)
266+ require.False(t, view.bodyShown, "the page owns the keys: the legend would list keys that do not apply")
267+ require.True(t, view.pillShown)
268+
269+ toaster.setDetail("")
270+ toaster.setSuspended(false)
271+ require.Equal(t, "VIM MODE", view.header)
272+ require.True(t, view.bodyShown)
273+}
274+
275+func TestModeToasterModeChangeResetsDetailAndSuspension(t *testing.T) {
276+ toaster, view := newTestToaster(nil)
277+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
278+ toaster.setDetail("VISUAL")
279+ toaster.setSuspended(true)
280+
281+ toaster.apply(input.ModeNormal, legendOn("always"), "", nil)
282+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
283+
284+ require.Equal(t, "VIM MODE", view.header)
285+ require.True(t, view.bodyShown)
286+}
287+
288+func TestModeToasterSameModeRebuildKeepsInteractionState(t *testing.T) {
289+ toaster, view := newTestToaster(nil)
290+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
291+ toaster.setDetail("HINTS")
292+ toaster.setSuspended(true)
293+ require.False(t, view.bodyShown)
294+
295+ // A hot reload that changes the bindings rebuilds the legend mid-capture.
296+ changed := map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}, "cancel": {Keys: []string{"esc"}}}
297+ toaster.apply(input.ModeVim, legendOn("always"), "", changed)
298+
299+ require.Equal(t, "VIM MODE · HINTS", view.header, "the sub-mode label survives the rebuild")
300+ require.False(t, view.bodyShown, "the legend stays suspended while the page owns the keys")
301+ require.True(t, toaster.suspended)
302+
303+ toaster.setDetail("")
304+ toaster.setSuspended(false)
305+ require.True(t, view.bodyShown)
306+ require.Equal(t, "VIM MODE", view.header)
307+}
308+
309+func TestModeToasterSameModeRebuildReappliesPendingDimming(t *testing.T) {
310+ toaster, view := newTestToaster(nil)
311+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
312+ toaster.setPending("]")
313+
314+ changed := map[string]entity.ActionBinding{
315+ "heading-next": {Keys: []string{"]]"}},
316+ "visual": {Keys: []string{"v"}},
317+ }
318+ toaster.apply(input.ModeVim, legendOn("always"), "", changed)
319+ // The rebuilt legend starts undimmed, as a real view's new rows would.
320+ require.Equal(t, "]", toaster.pending)
321+ require.Equal(t, "VIM MODE · ]", view.header)
322+ require.False(t, view.dimmed["heading-next"])
323+ require.True(t, view.dimmed["visual"], "rows the pending sequence rules out are dimmed again")
324+}
325+
326+func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) {
327+ toaster, view := newTestToaster(nil)
328+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
329+
330+ toaster.flash(input.Action("vim-scroll-down"))
331+ require.True(t, view.flashed["vim-scroll-down"])
332+
333+ // Underscore action names map onto dashed legend rows.
334+ toaster.flash(input.Action("heading_next"))
335+ require.True(t, view.flashed["heading-next"])
336+
337+ toaster.flash(input.Action("unknown-action"))
338+ require.True(t, view.flashed["heading-next"], "an action without keycaps leaves the flash alone")
339+
340+ waitFor(t, func() bool { return len(view.flashed) == 0 })
341+}
342+
343+func TestModeToasterFlashNeedsVisibleLegendAndAnimations(t *testing.T) {
344+ toaster, view := newTestToaster(nil)
345+ cfg := legendOn("always")
346+ cfg.ModeLegendAnimations = false
347+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
348+ toaster.flash(input.Action("vim-scroll-down"))
349+ require.Empty(t, view.flashed)
350+
351+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
352+ toaster.setCollapsed(true)
353+ toaster.flash(input.Action("vim-scroll-down"))
354+ require.Empty(t, view.flashed, "collapsed legend: nothing to flash")
355+}
356+
357+func TestModeToasterLingersWhenPointerIsOverLegend(t *testing.T) {
358+ toaster, view := newTestToaster(nil)
359+ cfg := legendOn("always")
360+ cfg.ModeLegendLinger = true
361+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
362+ view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
363+ toaster.pointerMoved(50, 50)
364+
365+ toaster.apply(input.ModeNormal, cfg, "", nil)
366+
367+ require.True(t, toaster.lingering)
368+ require.True(t, view.lingering)
369+ require.True(t, view.bodyShown, "the legend stays on screen, read-only")
370+ require.False(t, view.pillShown, "the mode ended: the header goes")
371+ require.True(t, toaster.hasLegend() || toaster.lingering)
372+
373+ toaster.keyPressed()
374+ require.False(t, toaster.lingering)
375+ require.False(t, view.bodyShown)
376+}
377+
378+func TestModeToasterDoesNotLingerWhenPointerElsewhereOrPressInFlight(t *testing.T) {
379+ cfg := legendOn("always")
380+ cfg.ModeLegendLinger = true
381+
382+ toaster, view := newTestToaster(nil)
383+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
384+ view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
385+ toaster.pointerMoved(900, 900)
386+ toaster.apply(input.ModeNormal, cfg, "", nil)
387+ require.False(t, toaster.lingering)
388+
389+ toaster, view = newTestToaster(nil)
390+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
391+ view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
392+ toaster.pointerMoved(50, 50)
393+ toaster.pressStarted() // the exit was caused by a click
394+ toaster.apply(input.ModeNormal, cfg, "", nil)
395+ require.False(t, toaster.lingering)
396+}
397+
398+func TestModeToasterLingerEndsOnClickAndTabSwitch(t *testing.T) {
399+ cfg := legendOn("always")
400+ cfg.ModeLegendLinger = true
401+ toaster, view := newTestToaster(nil)
402+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
403+ view.rect = func() (int, int, int, int, bool) { return 0, 0, 500, 500, true }
404+ toaster.pointerMoved(5, 5)
405+ toaster.apply(input.ModeNormal, cfg, "", nil)
406+ require.True(t, toaster.lingering)
407+
408+ toaster.pressStarted()
409+ require.False(t, toaster.lingering)
410+ pumpMainContext() // the press is over; a later exit may linger again
411+
412+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
413+ toaster.pointerMoved(5, 5)
414+ toaster.apply(input.ModeNormal, cfg, "", nil)
415+ require.True(t, toaster.lingering)
416+ toaster.dismissLinger()
417+ require.False(t, toaster.lingering)
418+}
419+
420+func TestModeToasterReleaseDropsLegendKeepsPill(t *testing.T) {
421+ toaster, view := newTestToaster(nil)
422+ cfg := legendOn("delay")
423+ cfg.ModeLegendDelayMs = 40
424+ toaster.apply(input.ModeVim, cfg, "", testVimActions)
425+
426+ toaster.release()
427+ time.Sleep(90 * time.Millisecond)
428+ pumpMainContext()
429+
430+ require.True(t, view.pillShown, "tab teardown keeps the mode header")
431+ require.False(t, view.bodyShown, "the pending show for the released workspace never fires")
432+}
433+
434+func TestModeToasterNilAndDestroyedAreSafe(t *testing.T) {
435+ var nilToaster *modeToaster
436+ nilToaster.destroy()
437+ nilToaster.release()
438+ nilToaster.dismissLinger()
439+ nilToaster.setSuspended(true)
440+ nilToaster.setCollapsed(true)
441+ nilToaster.setPending("x")
442+ nilToaster.setDetail("x")
443+ nilToaster.flash(input.Action("x"))
444+ nilToaster.apply(input.ModeVim, legendOn("always"), "", nil)
445+
446+ toaster, view := newTestToaster(nil)
447+ toaster.destroy()
448+ require.True(t, view.destroyed)
449+ toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
450+ toaster.setPending("x")
451+ toaster.pillClicked()
452+}
453+
454+func TestModeLegendPreferenceClickWinsUntilSettled(t *testing.T) {
455+ var p modeLegendPreference
456+ require.False(t, p.current(false))
457+ require.True(t, p.current(true), "no click yet: the configuration decides")
458+
459+ require.True(t, p.click(true), "first click starts the writer")
460+ require.True(t, p.current(false), "the click wins while the config still says otherwise")
461+
462+ p.configReloaded(false) // a reload from before the save landed
463+ require.True(t, p.current(false), "a stale reload does not undo the click")
464+
465+ next, ok := p.next()
466+ require.True(t, ok)
467+ require.True(t, next)
468+ _, ok = p.next()
469+ require.False(t, ok, "writer drained")
470+
471+ p.configReloaded(true) // the save's own reload
472+ require.False(t, p.current(false), "settled: the configuration is the truth again")
473+ require.True(t, p.current(true))
474+}
475+
476+func TestModeLegendPreferenceReloadsDuringSavesDoNotUndoLastClick(t *testing.T) {
477+ var p modeLegendPreference
478+ require.True(t, p.click(true))
479+ first, ok := p.next() // the writer takes the first value
480+ require.True(t, ok)
481+ require.True(t, first)
482+ require.False(t, p.click(false), "the writer is still active")
483+
484+ p.configReloaded(false) // matches the latest click, but the save is still pending
485+ p.configReloaded(true) // the first save's own reload, arriving late
486+ require.False(t, p.current(true), "the latest click survives intermediate reloads")
487+
488+ second, ok := p.next()
489+ require.True(t, ok)
490+ require.False(t, second)
491+ require.False(t, p.current(true), "still the latest click until the writer drains")
492+ _, ok = p.next()
493+ require.False(t, ok)
494+ require.False(t, p.current(true), "drained: reloads have not been received yet")
495+
496+ p.configReloaded(false) // first reload after the drain
497+ require.False(t, p.current(false))
498+ require.True(t, p.current(true), "the configuration decides again")
499+}
500+
501+func TestModeLegendPreferenceReloadAfterDrain(t *testing.T) {
502+ tests := []struct {
503+ name string
504+ reloaded bool
505+ configured bool
506+ }{
507+ {"own save reloads", true, true},
508+ {"external edit", false, false},
509+ {"failed save leaves the file unchanged", false, false},
510+ }
511+ for _, tt := range tests {
512+ t.Run(tt.name, func(t *testing.T) {
513+ var p modeLegendPreference
514+ require.True(t, p.click(true))
515+ _, _ = p.next()
516+ _, _ = p.next()
517+ p.configReloaded(tt.reloaded)
518+ require.Equal(t, tt.configured, p.current(tt.configured))
519+ require.Equal(t, !tt.configured, p.current(!tt.configured))
520+ })
521+ }
522+}
523+
524+func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) {
525+ var p modeLegendPreference
526+ require.True(t, p.click(true))
527+ require.False(t, p.click(false), "a running writer picks the new value up")
528+ require.False(t, p.click(true))
529+
530+ next, ok := p.next()
531+ require.True(t, ok)
532+ require.True(t, next, "the file ends on the last click")
533+ _, ok = p.next()
534+ require.False(t, ok)
535+
536+ require.True(t, p.click(false), "after draining, a new click starts a new writer")
537+ _, _ = p.next()
538+ _, _ = p.next()
539+}
540+
541+func TestModeLegendPreferenceExternalEditWinsWhenNoSaveInFlight(t *testing.T) {
542+ var p modeLegendPreference
543+ require.True(t, p.click(true))
544+ _, _ = p.next()
545+ _, _ = p.next() // save finished (and possibly failed)
546+
547+ p.configReloaded(false) // the file still says false
548+ require.False(t, p.current(false))
549+}
550+
551+func TestSetModeLegendCollapsedUpdatesEveryWindowAndSavesAsync(t *testing.T) {
552+ saved := make(chan bool, 4)
553+ var mu sync.Mutex
554+ app := &App{}
555+ app.deps = &Dependencies{}
556+ app.deps.HandlerDeps.SaveModeLegendCollapsed = func(_ context.Context, collapsed bool) error {
557+ mu.Lock()
558+ defer mu.Unlock()
559+ saved <- collapsed
560+ return nil
561+ }
562+ bw1, view1 := newFakeModeToasterWindow("win-1")
563+ bw2, view2 := newFakeModeToasterWindow("win-2")
564+ app.browserWindows = map[string]*browserWindow{bw1.id: bw1, bw2.id: bw2}
565+ bw1.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
566+ bw2.modeToaster.apply(input.ModePane, legendOn("always"), "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
567+
568+ app.setModeLegendCollapsed(context.Background(), true)
569+
570+ require.False(t, view1.bodyShown)
571+ require.False(t, view2.bodyShown, "the toggle applies immediately in every window")
572+ require.True(t, view1.pillShown)
573+ require.True(t, view2.pillShown)
574+ select {
575+ case got := <-saved:
576+ require.True(t, got)
577+ case <-time.After(2 * time.Second):
578+ t.Fatal("collapsed preference was not saved")
579+ }
580+ require.True(t, app.modeLegendCollapsed())
581+}
582+
583+func TestSetModeLegendCollapsedLogsSaveFailureAndKeepsState(t *testing.T) {
584+ done := make(chan struct{}, 1)
585+ app := &App{deps: &Dependencies{}}
586+ app.deps.HandlerDeps.SaveModeLegendCollapsed = func(context.Context, bool) error {
587+ defer func() { done <- struct{}{} }()
588+ return context.DeadlineExceeded
589+ }
590+ bw, view := newFakeModeToasterWindow("win-1")
591+ app.browserWindows = map[string]*browserWindow{bw.id: bw}
592+ bw.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
593+
594+ app.setModeLegendCollapsed(context.Background(), true)
595+ <-done
596+
597+ require.False(t, view.bodyShown, "a failed save does not revert the visible state")
598+}
599+
600+func TestUpdateModeFrameUsesSharedCollapsedState(t *testing.T) {
601+ snapshot := entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
602+ Styling: legendOn("always"),
603+ VimMode: entity.VimModeConfig{Actions: testVimActions},
604+ }}}
605+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)}
606+ require.True(t, app.modeLegend.click(true))
607+ bw, view := newFakeModeToasterWindow("win-1")
608+
609+ app.updateModeFrame(bw, input.ModeVim)
610+
611+ require.True(t, view.pillShown)
612+ require.False(t, view.bodyShown, "a window entering a mode honors the shared collapsed state")
613+ _, _ = app.modeLegend.next()
614+ _, _ = app.modeLegend.next()
615+}
616+
617+func waitFor(t *testing.T, cond func() bool) {
618+ t.Helper()
619+ deadline := time.Now().Add(2 * time.Second)
620+ for time.Now().Before(deadline) {
621+ pumpMainContext()
622+ if cond() {
623+ return
624+ }
625+ time.Sleep(5 * time.Millisecond)
626+ }
627+ t.Fatal("condition not reached in time")
628+}
A internal/ui/mode_toaster_view.go +774 -0
@@ -0,0 +1,774 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/input"
5+ "github.com/bnema/puregotk/v4/gdk"
6+ "github.com/bnema/puregotk/v4/glib"
7+ "github.com/bnema/puregotk/v4/gobject"
8+ "github.com/bnema/puregotk/v4/graphene"
9+ "github.com/bnema/puregotk/v4/gtk"
10+)
11+
12+const (
13+ legendRefreshMs = 100
14+ // legendGap separates the legend band from the pill under it.
15+ legendGap = 6
16+ // legendScrollStep is how far one wheel notch over the pill scrolls a
17+ // legend taller than its height budget.
18+ legendScrollStep = 40
19+)
20+
21+// modeToasterEvents carries window events to the toaster. The view only
22+// forwards them; modeToaster decides what they mean.
23+type modeToasterEvents struct {
24+ pillClicked func()
25+ pointerMoved func(x, y float64)
26+ pointerLeft func()
27+ keyPressed func()
28+ // pressedOutside reports a pointer press anywhere but on the pill.
29+ pressedOutside func()
30+}
31+
32+// gtkModeToasterView paints the mode toaster into the window's content
33+// overlay as two overlay children:
34+//
35+// - the pill, the only targetable widget of the component. It is a sibling
36+// of the legend rather than its parent because GTK skips the descendants of
37+// a widget that cannot be targeted.
38+// - the legend body, which never receives the pointer. Its rectangle is
39+// measured outside GTK's allocation pass and handed to the overlay's
40+// get-child-position callback, like the mode border's.
41+//
42+// Main thread only.
43+type gtkModeToasterView struct {
44+ overlay *gtk.Overlay
45+ window *gtk.ApplicationWindow
46+ events modeToasterEvents
47+
48+ pill *gtk.Box
49+ pillLabel *gtk.Label
50+ chevron *gtk.Label
51+ root *gtk.Box // overlay child of the body; its panel is what is measured
52+ panel *gtk.Box
53+ scroller *gtk.ScrolledWindow
54+ columnBox *gtk.Box
55+ hint *gtk.Label
56+
57+ pillClick *gtk.GestureClick
58+ pillScroll *gtk.EventControllerScroll
59+ motion *gtk.EventControllerMotion
60+ keys *gtk.EventControllerKey
61+ windowClick *gtk.GestureClick
62+
63+ // Callbacks are retained here: GTK holds only a pointer to them.
64+ pillClickCb func(gtk.GestureClick, int, float64, float64)
65+ pillScrollCb func(gtk.EventControllerScroll, float64, float64) bool
66+ motionCb func(gtk.EventControllerMotion, float64, float64)
67+ enterCb func(gtk.EventControllerMotion, float64, float64)
68+ leaveCb func(gtk.EventControllerMotion)
69+ keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
70+ windowClickC func(gtk.GestureClick, int, float64, float64)
71+
72+ positionHandlerID uint
73+ refreshSource uint
74+
75+ // Painted state, kept to skip redundant GTK calls.
76+ headerText string
77+ modeClass string // CSS class of the pill color
78+ legendClass string // CSS class of the panel color
79+ styleClass string
80+ chevronState chevronState
81+ pillShown bool
82+ interactive bool
83+ animations bool
84+
85+ // legendWidgets are the widgets built for the current legend. puregotk
86+ // constructors hand back a reference we own, which GTK does not drop when
87+ // the widget is removed from its parent, so they are unreferenced once the
88+ // legend is replaced or the view destroyed.
89+ legendWidgets []*gtk.Widget
90+
91+ groups []legendGroup
92+ groupBoxes []*gtk.Box
93+ columns [][]*gtk.Box // group boxes per column box, as placed
94+ columnBoxes []*gtk.Box
95+ rows map[string][]*gtk.Widget
96+ keycaps map[string][]*gtk.Widget
97+
98+ bodyWanted bool
99+ lingering bool
100+ // placement is the legend rectangle in overlay coordinates; it is only
101+ // meaningful while placed is set, which otherwise hides the legend.
102+ placement overlayRect
103+ placed bool
104+ lastColumns int
105+ lastWidth, lastMaxContent int
106+}
107+
108+// overlayRect is a rectangle in overlay coordinates.
109+type overlayRect struct{ x, y, width, height int }
110+
111+func newGTKModeToasterView(overlay *gtk.Overlay, window *gtk.ApplicationWindow, events modeToasterEvents) *gtkModeToasterView {
112+ if overlay == nil || window == nil {
113+ return nil
114+ }
115+ v := &gtkModeToasterView{overlay: overlay, window: window, events: events, animations: true}
116+ v.buildPill()
117+ v.buildBody()
118+ overlay.AddOverlay(&v.pill.Widget)
119+ overlay.SetClipOverlay(&v.pill.Widget, false)
120+ overlay.SetMeasureOverlay(&v.pill.Widget, false)
121+ overlay.AddOverlay(&v.root.Widget)
122+ overlay.SetClipOverlay(&v.root.Widget, false)
123+ overlay.SetMeasureOverlay(&v.root.Widget, false)
124+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
125+ if v.root == nil || widgetPtr != v.root.GoPointer() || !v.placed {
126+ return false
127+ }
128+ r := v.placement
129+ return writeOverlayAllocation(allocation, r.x, r.y, r.width, r.height)
130+ }
131+ v.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
132+ v.installWindowControllers()
133+ return v
134+}
135+
136+// buildPill creates the header. It starts out inert: pointer input reaches the
137+// pill only while it can toggle a legend (setPillInteractive).
138+func (v *gtkModeToasterView) buildPill() {
139+ v.pill = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
140+ v.pill.SetHalign(gtk.AlignStartValue)
141+ v.pill.SetValign(gtk.AlignEndValue)
142+ v.pill.SetHexpand(false)
143+ v.pill.SetVexpand(false)
144+ // Widget margins keep the allocation equal to the visible pill, which the
145+ // click filter and the legend anchor rely on.
146+ v.pill.SetMarginStart(modeToasterMargin)
147+ v.pill.SetMarginBottom(modeToasterMargin)
148+ v.pill.SetVisible(false)
149+ // A click must never move keyboard focus off the pane's WebView: nothing in
150+ // the pill can take focus, and a press is claimed by the pill's own gesture.
151+ v.pill.SetCanFocus(false)
152+ v.pill.SetFocusable(false)
153+ v.pill.SetFocusOnClick(false)
154+ v.pill.SetCanTarget(false)
155+ v.pill.AddCssClass("mode-toaster-pill")
156+
157+ empty := ""
158+ v.pillLabel = gtk.NewLabel(&empty)
159+ v.pillLabel.SetCanTarget(false)
160+ v.pillLabel.SetCanFocus(false)
161+ v.pillLabel.SetFocusable(false)
162+ v.pillLabel.AddCssClass("mode-toaster-label")
163+ v.pill.Append(&v.pillLabel.Widget)
164+
165+ v.chevron = gtk.NewLabel(&empty)
166+ v.chevron.SetCanTarget(false)
167+ v.chevron.SetCanFocus(false)
168+ v.chevron.SetFocusable(false)
169+ v.chevron.SetVisible(false)
170+ v.chevron.AddCssClass("mode-toaster-chevron")
171+ v.pill.Append(&v.chevron.Widget)
172+
173+ v.pillClick = gtk.NewGestureClick()
174+ v.pillClick.SetButton(1) // GDK_BUTTON_PRIMARY
175+ v.pillClickCb = func(_ gtk.GestureClick, nPress int, _, _ float64) {
176+ // Claim the sequence so no ancestor controller sees this press.
177+ v.pillClick.SetState(gtk.EventSequenceClaimedValue)
178+ if nPress == 1 && v.events.pillClicked != nil {
179+ v.events.pillClicked()
180+ }
181+ }
182+ v.pillClick.ConnectPressed(&v.pillClickCb)
183+ v.pill.AddController(&v.pillClick.EventController)
184+
185+ // The legend itself is out of reach of the pointer, so the wheel over the
186+ // pill scrolls a legend taller than its height budget.
187+ v.pillScroll = gtk.NewEventControllerScroll(gtk.EventControllerScrollVerticalValue)
188+ v.pillScrollCb = func(_ gtk.EventControllerScroll, _, dy float64) bool {
189+ return v.scrollLegend(dy * legendScrollStep)
190+ }
191+ v.pillScroll.ConnectScroll(&v.pillScrollCb)
192+ v.pill.AddController(&v.pillScroll.EventController)
193+}
194+
195+func (v *gtkModeToasterView) buildBody() {
196+ v.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
197+ v.root.SetHalign(gtk.AlignStartValue)
198+ v.root.SetValign(gtk.AlignStartValue)
199+ v.root.SetCanFocus(false)
200+ v.root.SetCanTarget(false)
201+ v.root.SetVisible(false)
202+ v.root.AddCssClass("mode-legend-anchor")
203+
204+ v.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
205+ v.panel.SetCanFocus(false)
206+ v.panel.SetCanTarget(false)
207+ v.panel.AddCssClass("omnibox-container")
208+ v.panel.AddCssClass("mode-legend-panel")
209+ v.root.Append(&v.panel.Widget)
210+
211+ v.columnBox = gtk.NewBox(gtk.OrientationHorizontalValue, legendColumnGap)
212+ v.columnBox.SetHomogeneous(true)
213+ v.columnBox.SetCanFocus(false)
214+ v.columnBox.SetCanTarget(false)
215+ v.columnBox.AddCssClass("mode-legend-columns")
216+
217+ v.scroller = gtk.NewScrolledWindow()
218+ v.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
219+ v.scroller.SetPropagateNaturalHeight(true)
220+ v.scroller.SetHasFrame(false)
221+ v.scroller.SetCanFocus(false)
222+ v.scroller.SetCanTarget(false)
223+ v.scroller.SetChild(&v.columnBox.Widget)
224+ v.panel.Append(&v.scroller.Widget)
225+
226+ hint := "esc to exit"
227+ v.hint = gtk.NewLabel(&hint)
228+ v.hint.SetCanFocus(false)
229+ v.hint.SetCanTarget(false)
230+ v.hint.SetHalign(gtk.AlignEndValue)
231+ v.hint.AddCssClass("mode-legend-hint")
232+ v.panel.Append(&v.hint.Widget)
233+}
234+
235+// installWindowControllers watches the whole window, because a lingering
236+// legend ends on any key press or click, wherever it lands.
237+func (v *gtkModeToasterView) installWindowControllers() {
238+ v.motion = gtk.NewEventControllerMotion()
239+ v.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
240+ if v.events.pointerMoved != nil {
241+ v.events.pointerMoved(x, y)
242+ }
243+ }
244+ v.enterCb = v.motionCb
245+ v.leaveCb = func(_ gtk.EventControllerMotion) {
246+ if v.events.pointerLeft != nil {
247+ v.events.pointerLeft()
248+ }
249+ }
250+ v.motion.ConnectEnter(&v.enterCb)
251+ v.motion.ConnectMotion(&v.motionCb)
252+ v.motion.ConnectLeave(&v.leaveCb)
253+ v.overlay.AddController(&v.motion.EventController)
254+
255+ v.keys = gtk.NewEventControllerKey()
256+ v.keys.SetPropagationPhase(gtk.PhaseCaptureValue)
257+ v.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
258+ if v.events.keyPressed != nil {
259+ v.events.keyPressed()
260+ }
261+ return false
262+ }
263+ v.keys.ConnectKeyPressed(&v.keyCb)
264+ v.window.AddController(&v.keys.EventController)
265+
266+ v.windowClick = gtk.NewGestureClick()
267+ v.windowClick.SetButton(0)
268+ v.windowClick.SetPropagationPhase(gtk.PhaseCaptureValue)
269+ v.windowClickC = func(_ gtk.GestureClick, _ int, x, y float64) {
270+ // A press on the pill is the user driving the toaster, not leaving it:
271+ // it must not end a lingering legend or count as a click elsewhere.
272+ if v.pillContains(x, y) {
273+ return
274+ }
275+ if v.events.pressedOutside != nil {
276+ v.events.pressedOutside()
277+ }
278+ }
279+ v.windowClick.ConnectPressed(&v.windowClickC)
280+ v.window.AddController(&v.windowClick.EventController)
281+}
282+
283+// pillContains reports whether window point (x, y) lies on the interactive pill.
284+func (v *gtkModeToasterView) pillContains(x, y float64) bool {
285+ if !v.pillShown || !v.interactive {
286+ return false
287+ }
288+ px, py, w, h, ok := v.pillBounds()
289+ if !ok {
290+ return false
291+ }
292+ // Window coordinates: the overlay sits below the window's own origin.
293+ origin, out := &graphene.Point{}, &graphene.Point{}
294+ if !v.overlay.ComputePoint(&v.window.Widget, origin, out) {
295+ return false
296+ }
297+ return pointInRect(x, y, px+int(out.X), py+int(out.Y), w, h)
298+}
299+
300+// pillBounds returns the pill's rectangle in overlay coordinates. The pill is
301+// pinned to the bottom-left by alignment and a fixed margin, so the rectangle
302+// follows from the overlay size. gtk_widget_compute_point is not used: for a
303+// pill with CSS padding it reports the origin shifted by the padding, which
304+// disagrees with where GTK draws and picks it.
305+func (v *gtkModeToasterView) pillBounds() (x, y, width, height int, ok bool) {
306+ if v.pill == nil || v.overlay == nil {
307+ return 0, 0, 0, 0, false
308+ }
309+ width, height = v.pill.GetAllocatedWidth(), v.pill.GetAllocatedHeight()
310+ if width <= 0 || height <= 0 {
311+ return 0, 0, 0, 0, false
312+ }
313+ overlayHeight := v.overlay.GetAllocatedHeight()
314+ return modeToasterMargin, overlayHeight - modeToasterMargin - height, width, height, true
315+}
316+
317+func (v *gtkModeToasterView) setHeader(text string) {
318+ if v.headerText == text {
319+ return
320+ }
321+ v.headerText = text
322+ v.pillLabel.SetText(text)
323+}
324+
325+func (v *gtkModeToasterView) setMode(mode input.Mode) {
326+ pillClass, panelClass := "", ""
327+ if mode != input.ModeNormal {
328+ pillClass = "mode-toaster-" + mode.String()
329+ panelClass = "mode-legend-" + mode.String()
330+ }
331+ if pillClass != v.modeClass {
332+ if v.modeClass != "" {
333+ v.pill.RemoveCssClass(v.modeClass)
334+ }
335+ if pillClass != "" {
336+ v.pill.AddCssClass(pillClass)
337+ }
338+ v.modeClass = pillClass
339+ }
340+ if panelClass != v.legendClass {
341+ if v.legendClass != "" {
342+ v.panel.RemoveCssClass(v.legendClass)
343+ }
344+ if panelClass != "" {
345+ v.panel.AddCssClass(panelClass)
346+ }
347+ v.legendClass = panelClass
348+ }
349+}
350+
351+func (v *gtkModeToasterView) setChevron(state chevronState) {
352+ if v.chevronState == state {
353+ return
354+ }
355+ v.chevronState = state
356+ switch state {
357+ case chevronExpanded:
358+ v.chevron.SetText("▾")
359+ v.chevron.SetVisible(true)
360+ v.setTooltip("Collapse the key legend")
361+ case chevronCollapsed:
362+ v.chevron.SetText("▴")
363+ v.chevron.SetVisible(true)
364+ v.setTooltip("Expand the key legend")
365+ default:
366+ v.chevron.SetVisible(false)
367+ v.setTooltip("")
368+ }
369+}
370+
371+func (v *gtkModeToasterView) setTooltip(text string) {
372+ if text == "" {
373+ v.pill.SetTooltipText(nil)
374+ return
375+ }
376+ v.pill.SetTooltipText(&text)
377+}
378+
379+func (v *gtkModeToasterView) setPillVisible(visible bool) {
380+ if v.pillShown == visible {
381+ return
382+ }
383+ v.pillShown = visible
384+ v.pill.SetVisible(visible)
385+}
386+
387+func (v *gtkModeToasterView) setPillInteractive(interactive bool) {
388+ if v.interactive == interactive {
389+ return
390+ }
391+ v.interactive = interactive
392+ v.pill.SetCanTarget(interactive)
393+ if interactive {
394+ v.pill.AddCssClass("mode-toaster-interactive")
395+ cursor := "pointer"
396+ v.pill.SetCursorFromName(&cursor)
397+ return
398+ }
399+ v.pill.RemoveCssClass("mode-toaster-interactive")
400+ v.pill.SetCursorFromName(nil)
401+}
402+
403+func (v *gtkModeToasterView) setStyle(style string) {
404+ if v.styleClass == "" && style == "" {
405+ return
406+ }
407+ class := ""
408+ if style != "" {
409+ class = "omnibox-style-" + style
410+ }
411+ if class == v.styleClass {
412+ return
413+ }
414+ if v.styleClass != "" {
415+ v.panel.RemoveCssClass(v.styleClass)
416+ }
417+ if class != "" {
418+ v.panel.AddCssClass(class)
419+ }
420+ v.styleClass = class
421+}
422+
423+func (v *gtkModeToasterView) setAnimations(enabled bool) {
424+ if v.animations == enabled {
425+ return
426+ }
427+ v.animations = enabled
428+ if enabled {
429+ v.panel.RemoveCssClass("mode-legend-no-motion")
430+ } else {
431+ v.panel.AddCssClass("mode-legend-no-motion")
432+ }
433+}
434+
435+func (v *gtkModeToasterView) setBodyLingering(lingering bool) {
436+ if v.lingering == lingering {
437+ return
438+ }
439+ v.lingering = lingering
440+ if lingering {
441+ v.panel.AddCssClass("mode-legend-lingering")
442+ v.stopRefresh()
443+ return
444+ }
445+ v.panel.RemoveCssClass("mode-legend-lingering")
446+ if v.bodyWanted {
447+ // The frozen placement is stale; track the window again.
448+ v.startRefresh()
449+ }
450+}
451+
452+// setLegend replaces the legend content; nil empties it.
453+func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
454+ v.clearColumns()
455+ v.releaseLegendWidgets()
456+ v.groupBoxes = nil
457+ v.rows = make(map[string][]*gtk.Widget)
458+ v.keycaps = make(map[string][]*gtk.Widget)
459+ v.groups = groups
460+ for _, group := range groups {
461+ v.groupBoxes = append(v.groupBoxes, v.buildGroup(group))
462+ }
463+ // Force the next geometry pass to place the groups.
464+ v.lastColumns, v.lastWidth = 0, 0
465+ if v.bodyWanted && !v.lingering {
466+ v.refreshGeometry()
467+ }
468+}
469+
470+func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
471+ box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
472+ v.legendWidgets = append(v.legendWidgets, &box.Widget)
473+ box.SetCanFocus(false)
474+ box.SetCanTarget(false)
475+ box.AddCssClass("mode-legend-group")
476+ name := group.Name
477+ title := gtk.NewLabel(&name)
478+ v.legendWidgets = append(v.legendWidgets, &title.Widget)
479+ title.SetCanFocus(false)
480+ title.SetCanTarget(false)
481+ title.SetHalign(gtk.AlignStartValue)
482+ title.AddCssClass("mode-legend-group-title")
483+ box.Append(&title.Widget)
484+ for _, row := range group.Rows {
485+ box.Append(&v.buildRow(row).Widget)
486+ }
487+ return box
488+}
489+
490+func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
491+ box := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
492+ v.legendWidgets = append(v.legendWidgets, &box.Widget)
493+ box.SetCanFocus(false)
494+ box.SetCanTarget(false)
495+ box.AddCssClass("mode-legend-row")
496+ v.rows[row.Action] = append(v.rows[row.Action], &box.Widget)
497+ for _, key := range row.Keys {
498+ display := displayLegendKey(key)
499+ keycap := gtk.NewLabel(&display)
500+ v.legendWidgets = append(v.legendWidgets, &keycap.Widget)
501+ keycap.SetCanFocus(false)
502+ keycap.SetCanTarget(false)
503+ keycap.AddCssClass("mode-legend-keycap")
504+ box.Append(&keycap.Widget)
505+ v.keycaps[row.Action] = append(v.keycaps[row.Action], &keycap.Widget)
506+ }
507+ desc := row.Desc
508+ text := gtk.NewLabel(&desc)
509+ v.legendWidgets = append(v.legendWidgets, &text.Widget)
510+ text.SetCanFocus(false)
511+ text.SetCanTarget(false)
512+ text.SetHalign(gtk.AlignStartValue)
513+ text.SetEllipsize(3) // PangoEllipsizeEnd
514+ text.AddCssClass("mode-legend-description")
515+ box.Append(&text.Widget)
516+ return box
517+}
518+
519+// clearColumns detaches the group boxes and drops the column boxes, keeping
520+// the group boxes for the next layout.
521+func (v *gtkModeToasterView) clearColumns() {
522+ for i, col := range v.columnBoxes {
523+ for _, group := range v.columns[i] {
524+ col.Remove(&group.Widget)
525+ }
526+ v.columnBox.Remove(&col.Widget)
527+ col.Unref() // drop the reference NewBox gave us
528+ }
529+ v.columnBoxes, v.columns = nil, nil
530+}
531+
532+// releaseLegendWidgets drops our reference to every widget of the current
533+// legend. The group boxes must already be detached from the columns.
534+func (v *gtkModeToasterView) releaseLegendWidgets() {
535+ for _, widget := range v.legendWidgets {
536+ widget.Unref()
537+ }
538+ v.legendWidgets = nil
539+ v.groupBoxes, v.groups = nil, nil
540+ v.rows, v.keycaps = make(map[string][]*gtk.Widget), make(map[string][]*gtk.Widget)
541+}
542+
543+// layoutColumns distributes the groups over count columns.
544+func (v *gtkModeToasterView) layoutColumns(count int) {
545+ v.clearColumns()
546+ for _, indices := range distributeLegendGroups(legendGroupHeights(v.groups), count) {
547+ col := gtk.NewBox(gtk.OrientationVerticalValue, 0)
548+ col.SetCanFocus(false)
549+ col.SetCanTarget(false)
550+ col.SetHexpand(true)
551+ col.SetValign(gtk.AlignStartValue)
552+ col.AddCssClass("mode-legend-column")
553+ placed := make([]*gtk.Box, 0, len(indices))
554+ for _, i := range indices {
555+ col.Append(&v.groupBoxes[i].Widget)
556+ placed = append(placed, v.groupBoxes[i])
557+ }
558+ v.columnBox.Append(&col.Widget)
559+ v.columnBoxes = append(v.columnBoxes, col)
560+ v.columns = append(v.columns, placed)
561+ }
562+ v.lastColumns = count
563+}
564+
565+func (v *gtkModeToasterView) setActionDimmed(action string, dimmed bool) {
566+ for _, row := range v.rows[action] {
567+ if dimmed {
568+ row.AddCssClass("mode-legend-dim")
569+ } else {
570+ row.RemoveCssClass("mode-legend-dim")
571+ }
572+ }
573+}
574+
575+func (v *gtkModeToasterView) flashAction(action string) bool {
576+ caps := v.keycaps[action]
577+ if len(caps) == 0 {
578+ return false
579+ }
580+ v.clearFlash()
581+ for _, keycap := range caps {
582+ keycap.AddCssClass("mode-legend-flash")
583+ }
584+ return true
585+}
586+
587+func (v *gtkModeToasterView) clearFlash() {
588+ for _, caps := range v.keycaps {
589+ for _, keycap := range caps {
590+ keycap.RemoveCssClass("mode-legend-flash")
591+ }
592+ }
593+}
594+
595+// setBodyVisible shows or hides the legend body. Showing waits for the first
596+// successful measurement.
597+func (v *gtkModeToasterView) setBodyVisible(visible bool) {
598+ if visible == v.bodyWanted {
599+ return
600+ }
601+ v.bodyWanted = visible
602+ if !visible {
603+ v.hideBody()
604+ return
605+ }
606+ if !v.lingering {
607+ v.startRefresh()
608+ }
609+}
610+
611+func (v *gtkModeToasterView) hideBody() {
612+ v.stopRefresh()
613+ v.placed = false
614+ v.root.SetVisible(false)
615+}
616+
617+func (v *gtkModeToasterView) lingerRect() (x, y, width, height int, ok bool) {
618+ if !v.placed {
619+ return 0, 0, 0, 0, false
620+ }
621+ r := v.placement
622+ return r.x, r.y, r.width, r.height, true
623+}
624+
625+func (v *gtkModeToasterView) scrollLegend(delta float64) bool {
626+ adj := v.scroller.GetVadjustment()
627+ if adj == nil || adj.GetUpper() <= adj.GetPageSize() {
628+ return false
629+ }
630+ adj.SetValue(adj.GetValue() + delta)
631+ return true
632+}
633+
634+func (v *gtkModeToasterView) stopRefresh() {
635+ if v.refreshSource != 0 {
636+ glib.SourceRemove(v.refreshSource)
637+ v.refreshSource = 0
638+ }
639+}
640+
641+// startRefresh measures outside GTK's allocation pass while the legend is
642+// shown, which also follows window resizes after the initial layout settles.
643+func (v *gtkModeToasterView) startRefresh() {
644+ v.stopRefresh()
645+ v.refreshGeometry()
646+ cb := glib.SourceFunc(func(_ uintptr) bool {
647+ if v.root == nil || !v.bodyWanted || v.lingering {
648+ v.refreshSource = 0
649+ return false
650+ }
651+ v.refreshGeometry()
652+ return true
653+ })
654+ v.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
655+}
656+
657+// refreshGeometry places the legend above the pill, from the pill's left edge,
658+// as wide as the window allows up to legendMaxWidth.
659+func (v *gtkModeToasterView) refreshGeometry() {
660+ if v.root == nil || v.overlay == nil || !v.bodyWanted || v.lingering {
661+ return
662+ }
663+ width, height := v.overlay.GetAllocatedWidth(), v.overlay.GetAllocatedHeight()
664+ if width <= 0 || height <= 0 || len(v.groups) == 0 {
665+ v.clearPlacement()
666+ return
667+ }
668+ left, bottom := modeToasterMargin, height-modeToasterMargin
669+ if v.pillShown {
670+ px, py, _, _, ok := v.pillBounds()
671+ if !ok {
672+ // The pill has not been allocated yet; the next tick retries.
673+ v.clearPlacement()
674+ return
675+ }
676+ left, bottom = px, py-legendGap
677+ }
678+ bandWidth, columns := legendBandWidth(width-left-modeToasterMargin, len(v.groups))
679+ if bandWidth <= 0 {
680+ v.clearPlacement()
681+ return
682+ }
683+ if columns != v.lastColumns {
684+ v.layoutColumns(columns)
685+ }
686+ if bandWidth != v.lastWidth {
687+ v.panel.SetSizeRequest(bandWidth, -1)
688+ v.lastWidth = bandWidth
689+ }
690+ if maxContent := legendBodyMaxHeight(height); maxContent != v.lastMaxContent {
691+ v.scroller.SetMaxContentHeight(maxContent)
692+ v.lastMaxContent = maxContent
693+ }
694+ // Measure the panel: GTK reports 0 for the hidden root.
695+ naturalHeight := 0
696+ v.panel.Measure(gtk.OrientationVerticalValue, bandWidth, nil, &naturalHeight, nil, nil)
697+ if naturalHeight <= 0 {
698+ v.clearPlacement()
699+ return
700+ }
701+ y := max(bottom-naturalHeight, 0)
702+ next := overlayRect{x: left, y: y, width: bandWidth, height: naturalHeight}
703+ if v.placed && v.placement == next {
704+ return
705+ }
706+ v.placement, v.placed = next, true
707+ v.root.SetVisible(true)
708+ v.root.QueueAllocate()
709+}
710+
711+func (v *gtkModeToasterView) clearPlacement() {
712+ if !v.placed {
713+ return
714+ }
715+ v.placed = false
716+ v.root.SetVisible(false)
717+ v.root.QueueAllocate()
718+}
719+
720+func (v *gtkModeToasterView) destroy() {
721+ if v == nil || v.overlay == nil {
722+ return
723+ }
724+ v.stopRefresh()
725+ v.overlay.RemoveController(&v.motion.EventController)
726+ v.window.RemoveController(&v.keys.EventController)
727+ v.window.RemoveController(&v.windowClick.EventController)
728+ if v.positionHandlerID != 0 {
729+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(v.overlay.GoPointer()), v.positionHandlerID)
730+ v.positionHandlerID = 0
731+ }
732+ v.placed = false
733+ v.clearColumns()
734+ v.releaseLegendWidgets()
735+ v.overlay.RemoveOverlay(&v.pill.Widget)
736+ v.overlay.RemoveOverlay(&v.root.Widget)
737+
738+ // Widgets are handed to us with a reference that GTK does not release on
739+ // removal: drop it. (Controllers are owned by the widget they were added to,
740+ // which released them on removal.) The callbacks go too, now that nothing
741+ // can invoke them.
742+ for _, widget := range []*gtk.Widget{
743+ &v.pillLabel.Widget, &v.chevron.Widget, &v.hint.Widget, &v.columnBox.Widget,
744+ &v.scroller.Widget, &v.panel.Widget, &v.pill.Widget, &v.root.Widget,
745+ } {
746+ widget.Unref()
747+ }
748+ v.pillClickCb, v.pillScrollCb, v.motionCb, v.enterCb, v.leaveCb, v.keyCb, v.windowClickC = nil, nil, nil, nil, nil, nil, nil
749+ v.overlay, v.window = nil, nil
750+ v.pill, v.pillLabel, v.chevron = nil, nil, nil
751+ v.root, v.panel, v.scroller, v.columnBox, v.hint = nil, nil, nil, nil, nil
752+ v.motion, v.keys, v.windowClick, v.pillClick, v.pillScroll = nil, nil, nil, nil, nil
753+}
754+
755+// newModeToaster builds the mode toaster of a window inside its content
756+// overlay. collapsed is the configured initial state of the legend; onToggle
757+// receives the state a click on the pill asks for.
758+func newModeToaster(overlay *gtk.Overlay, window *gtk.ApplicationWindow, collapsed bool, onToggle func(bool)) *modeToaster {
759+ t := newModeToasterWithView(nil, onToggle)
760+ t.collapsed = collapsed
761+ t.cfg.ModeLegendCollapsed = collapsed
762+ view := newGTKModeToasterView(overlay, window, modeToasterEvents{
763+ pillClicked: t.pillClicked,
764+ pointerMoved: t.pointerMoved,
765+ pointerLeft: t.pointerLeft,
766+ keyPressed: t.keyPressed,
767+ pressedOutside: t.pressStarted,
768+ })
769+ if view == nil {
770+ return nil
771+ }
772+ t.view = view
773+ return t
774+}
A internal/ui/mode_toaster_view_gtk_test.go +90 -0
@@ -0,0 +1,90 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/glib"
7+ "github.com/bnema/puregotk/v4/gobject"
8+ "github.com/bnema/puregotk/v4/gtk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// watchFinalized reports, through the returned flag, when widget is finalized.
13+func watchFinalized(t *testing.T, widget *gtk.Widget) *bool {
14+ t.Helper()
15+ finalized := new(bool)
16+ notify := gobject.WeakNotify(func(_, _ uintptr) { *finalized = true })
17+ t.Cleanup(func() { _ = notify })
18+ widget.WeakRef(&notify, 0)
19+ return finalized
20+}
21+
22+func TestGTKModeToasterViewReleasesWidgets(t *testing.T) {
23+ gtkApp := requireGTKDisplayApp(t)
24+ defer gtkApp.Unref()
25+ // Windows may only be created once the GApplication has started up;
26+ // registering emits the startup signal.
27+ registered, err := gtkApp.Register(nil)
28+ require.NoError(t, err)
29+ require.True(t, registered, "the test application registers")
30+
31+ var view *gtkModeToasterView
32+ done := make(chan struct{})
33+ run := glib.SourceFunc(func(uintptr) bool {
34+ defer close(done)
35+ window := gtk.NewApplicationWindow(gtkApp)
36+ require.NotNil(t, window)
37+ overlay := gtk.NewOverlay()
38+ window.SetChild(&overlay.Widget)
39+ overlayFinalized := watchFinalized(t, &overlay.Widget)
40+ view = newGTKModeToasterView(overlay, window, modeToasterEvents{})
41+ require.NotNil(t, view)
42+
43+ groups := []legendGroup{{Name: "SCROLL", Rows: []legendRow{{Action: "down", Keys: []string{"j"}, Desc: "Down"}}}}
44+ view.setLegend(groups)
45+ row := watchFinalized(t, view.rows["down"][0])
46+ keycap := watchFinalized(t, view.keycaps["down"][0])
47+ group := watchFinalized(t, &view.groupBoxes[0].Widget)
48+
49+ view.setLegend(nil)
50+ require.True(t, *row, "a replaced legend row is released")
51+ require.True(t, *keycap, "a replaced legend keycap is released")
52+ require.True(t, *group, "a replaced legend group is released")
53+
54+ view.setLegend(groups)
55+ view.layoutColumns(1)
56+ column := watchFinalized(t, &view.columnBoxes[0].Widget)
57+ view.layoutColumns(2)
58+ require.True(t, *column, "a column dropped by a relayout is released")
59+
60+ pill := watchFinalized(t, &view.pill.Widget)
61+ body := watchFinalized(t, &view.root.Widget)
62+ label := watchFinalized(t, &view.pillLabel.Widget)
63+ lastRow := watchFinalized(t, view.rows["down"][0])
64+ view.destroy()
65+ require.True(t, *pill, "destroy releases the pill")
66+ require.True(t, *body, "destroy releases the legend body")
67+ require.True(t, *label, "destroy releases the pill label")
68+ require.True(t, *lastRow, "destroy releases the legend rows")
69+
70+ frame := newModeFrame(overlay)
71+ require.NotNil(t, frame)
72+ border := watchFinalized(t, &frame.border.Widget)
73+ frame.destroy()
74+ require.True(t, *border, "destroy releases the mode frame border")
75+
76+ // The window is never presented: destroying an unrealized window
77+ // crashes on Wayland, so drop the references instead (the same as
78+ // the global shortcut handler test). The overlay's last reference
79+ // goes with the window's child; the window itself stays on GTK's
80+ // toplevel list until destroyed, so its finalization is not asserted.
81+ window.SetChild(nil)
82+ overlay.Unref()
83+ require.True(t, *overlayFinalized, "the test releases its overlay")
84+ gtkApp.RemoveWindow(&window.Window)
85+ window.Unref()
86+ return false
87+ })
88+ glib.MainContextDefault().Invoke(&run, 0)
89+ <-done
90+}
M internal/ui/runtime_config_test.go +3 -0
@@ -28,6 +28,9 @@ func TestDependenciesValidateAcceptsRuntimeConfigOnly(t *testing.T) {
28 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error {28 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error {
29 return nil29 return nil
30 },30 },
31+ SaveModeLegendCollapsed: func(context.Context, bool) error {
32+ return nil
33+ },
31 },34 },
32 }35 }
33 36
M internal/ui/theme/css.go +0 -36
@@ -1404,42 +1404,6 @@ func generateToasterCSS(p Palette) string {
1404 background-color: alpha(var(--destructive), 0.9);1404 background-color: alpha(var(--destructive), 0.9);
1405 color: var(--destructive-text);1405 color: var(--destructive-text);
1406 }1406 }
1407-
1408-/* Toast with custom styling (mode indicator toasters) */
1409-.toast-custom {
1410- color: #ffffff;
1411-}
1412-
1413-/* Mode-specific toast colors */
1414-.toast-pane-mode {
1415- background-color: var(--pane-mode-color);
1416- color: var(--pane-mode-text);
1417-}
1418-
1419-.toast-vim-mode {
1420- background-color: var(--vim-mode-color);
1421- color: var(--vim-mode-text);
1422-}
1423-
1424-.toast-tab-mode {
1425- background-color: var(--tab-mode-color);
1426- color: var(--tab-mode-text);
1427-}
1428-
1429-.toast-session-mode {
1430- background-color: var(--session-mode-color);
1431- color: var(--session-mode-text);
1432-}
1433-
1434-.toast-resize-mode {
1435- background-color: var(--resize-mode-color);
1436- color: var(--resize-mode-text);
1437-}
1438-
1439-.toast-prefix-mode {
1440- background-color: var(--pane-mode-color);
1441- color: var(--pane-mode-text);
1442-}
1443 `1407 `
1444 }1408 }
1445 1409
M internal/ui/theme/css_test.go +14 -0
@@ -427,3 +427,17 @@ func TestVimModeUsesOwnColor(t *testing.T) {
427 t.Fatal("palette missing Vim color")427 t.Fatal("palette missing Vim color")
428 }428 }
429 }429 }
430+
431+func TestModeLegendCSSStylesToasterPillAndBand(t *testing.T) {
432+ css := generateModeLegendCSS()
433+ for _, mode := range []string{"pane", "vim", "resize", "tab", "session", "prefix"} {
434+ assert.Contains(t, css, ".mode-toaster-pill.mode-toaster-"+mode+" {", mode)
435+ assert.Contains(t, css, ".omnibox-container.mode-legend-panel.mode-legend-"+mode+" {", mode)
436+ }
437+ for _, class := range []string{".mode-toaster-chevron", ".mode-toaster-interactive", ".mode-legend-columns", ".mode-legend-column", ".mode-legend-hint"} {
438+ assert.Contains(t, css, class)
439+ }
440+ assert.NotContains(t, css, "mode-legend-title", "the legend no longer has its own heading")
441+ assert.NotContains(t, css, "mode-legend-content", "the flow box is gone")
442+ assert.NotContains(t, GenerateCSS(DefaultDarkPalette()), ".toast-vim-mode", "modes no longer reuse the generic toast")
443+}
M internal/ui/theme/mode_legend_css.go +22 -10
@@ -17,26 +17,38 @@ var modeLegendColors = []struct{ mode, color, text string }{
17 17
18 func generateModeLegendCSS() string {18 func generateModeLegendCSS() string {
19 var sb strings.Builder19 var sb strings.Builder
20- sb.WriteString(`/* Mode frame legend: omnibox surface, tinted with the active mode color. */20+ sb.WriteString(`/* Mode toaster: a bottom-left pill whose legend expands above it. The legend
21+ is an omnibox surface tinted with the active mode color. */
21 .mode-legend-anchor { background-color: transparent; }22 .mode-legend-anchor { background-color: transparent; }
23+.mode-toaster-pill {
24+ border-radius: 0.25em;
25+ padding: 0.375em 0.625em;
26+ font-size: 0.875em;
27+ font-weight: 600;
28+ box-shadow: 0 2px 8px alpha(black, 0.3);
29+ min-width: 2.5em;
30+}
31+.mode-toaster-chevron { margin-left: 0.5em; font-size: 0.75em; opacity: 0.85; }
32+.mode-toaster-pill.mode-toaster-interactive:hover { box-shadow: 0 2px 10px alpha(black, 0.45); }
33+.mode-toaster-pill.mode-toaster-interactive:hover .mode-toaster-chevron { opacity: 1; }
22 .omnibox-container.mode-legend-panel {34 .omnibox-container.mode-legend-panel {
23 border-style: solid;35 border-style: solid;
24 border-width: 0.125em;36 border-width: 0.125em;
25- padding: 0 0 0.375em 0;37+ padding: 0.625em 0.5em 0.375em 0.5em;
26 min-width: 0;38 min-width: 0;
27 animation: mode-legend-appear 120ms ease-out;39 animation: mode-legend-appear 120ms ease-out;
28 }40 }
29 .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; }41 .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; }
30 .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; }42 .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; }
31-.mode-legend-title {43+.mode-legend-hint {
32 font-family: var(--font-mono);44 font-family: var(--font-mono);
33- font-size: 0.8125em;45+ font-size: 0.6875em;
34- font-weight: bold;46+ color: var(--surface-muted);
35- padding: 0.375em 0.75em;47+ margin: 0.25em 0.5em 0 0;
36- margin-bottom: 0.375em;
37 }48 }
38-.mode-legend-content { padding: 0 0.25em; }49+.mode-legend-columns { padding: 0 0.25em; }
39-.mode-legend-group { margin: 0 0.375em; min-width: 0; }50+.mode-legend-column { min-width: 0; }
51+.mode-legend-group { margin: 0 0.375em 0.5em 0.375em; min-width: 0; }
40 .mode-legend-group-title {52 .mode-legend-group-title {
41 font-family: var(--font-mono);53 font-family: var(--font-mono);
42 font-size: 0.6875em;54 font-size: 0.6875em;
@@ -66,7 +78,7 @@ func generateModeLegendCSS() string {
66 `)78 `)
67 for _, m := range modeLegendColors {79 for _, m := range modeLegendColors {
68 fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); }80 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); color: var(%[3]s); }81+.mode-toaster-pill.mode-toaster-%[1]s { background-color: var(%[2]s); color: var(%[3]s); }
70 .mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); }82 .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); }83 .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: var(%[3]s); }84 .mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: var(%[3]s); }
M internal/ui/vim_mode.go +6 -44
@@ -7,7 +7,6 @@ import (
7 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/internal/ui/component"
11 "github.com/bnema/dumber/internal/ui/input"10 "github.com/bnema/dumber/internal/ui/input"
12 )11 )
13 12
@@ -127,10 +126,8 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
127 // Entering Vim Mode accents the active pane; leaving removes the accent.126 // Entering Vim Mode accents the active pane; leaving removes the accent.
128 a.handleVimModeOwnership(ctx, bw, to, from)127 a.handleVimModeOwnership(ctx, bw, to, from)
129 128
129+ // Retarget the border and reconcile the mode toaster (indicator + legend).
130 a.updateModeFrame(bw, to)130 a.updateModeFrame(bw, to)
131-
132- // Show/hide this window's mode indicator toaster based on mode and config.
133- a.updateModeIndicatorToaster(ctx, bw, to)
134 }131 }
135 132
136 // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent133 // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
@@ -277,25 +274,6 @@ func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
277 a.vimMode.resetPulse()274 a.vimMode.resetPulse()
278 }275 }
279 276
280-// updateModeIndicatorToaster reconciles one window's mode toaster with its
281-// current mode and the live mode-indicator preference.
282-func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) {
283- if bw == nil || bw.modeToaster == nil {
284- return
285- }
286-
287- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
288- bw.modeToaster.Hide()
289- return
290- }
291-
292- bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
293- component.WithDuration(0),
294- component.WithPosition(component.ToastPositionBottomLeft),
295- component.WithModeClass(getModeToastClass(mode)),
296- )
297-}
298-
299 // bindVimModeSequenceToaster wires pending Vim-sequence text onto this277 // bindVimModeSequenceToaster wires pending Vim-sequence text onto this
300 // window's modeToaster only (never appToaster / pane toasters).278 // window's modeToaster only (never appToaster / pane toasters).
301 func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {279 func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
@@ -312,7 +290,7 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow)
312 290
313 // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster291 // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
314 // while a sequence is incomplete, and restores stable "VIM MODE" when pending292 // while a sequence is incomplete, and restores stable "VIM MODE" when pending
315-// clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;293+// clears. Mode exit is handled by handleModeChange/updateModeFrame;
316 // silent reset under ModalState avoids stale pending callbacks.294 // silent reset under ModalState avoids stale pending callbacks.
317 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {295 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
318 if a == nil || bw == nil {296 if a == nil || bw == nil {
@@ -323,15 +301,11 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
323 // sub-mode, and the legend is suspended. Nothing to repaint.301 // sub-mode, and the legend is suspended. Nothing to repaint.
324 return302 return
325 }303 }
326- if bw.modeFrame != nil {
327- cfg := a.runtimeConfigSnapshot().UI.Workspace
328- bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
329- }
330 if bw.modeToaster == nil {304 if bw.modeToaster == nil {
331 return305 return
332 }306 }
333 307
334- a.showVimModeToast(ctx, bw, pending)308+ bw.modeToaster.setPending(pending)
335 logging.FromContext(ctx).Debug().309 logging.FromContext(ctx).Debug().
336 Str("window_id", bw.id).310 Str("window_id", bw.id).
337 Str("pending", pending).311 Str("pending", pending).
@@ -339,22 +313,10 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
339 }313 }
340 314
341 // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the315 // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
342-// per-window mode toaster, honoring the indicator toggle.316+// per-window mode toaster.
343-func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) {317+func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) {
344 if bw == nil || bw.modeToaster == nil {318 if bw == nil || bw.modeToaster == nil {
345 return319 return
346 }320 }
347- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {321+ bw.modeToaster.setDetail(detail)
348- bw.modeToaster.Hide()
349- return
350- }
351- text := input.ModeVim.DisplayName()
352- if detail != "" {
353- text = text + " · " + detail
354- }
355- bw.modeToaster.Show(ctx, text, component.ToastInfo,
356- component.WithDuration(0),
357- component.WithPosition(component.ToastPositionBottomLeft),
358- component.WithModeClass(getModeToastClass(input.ModeVim)),
359- )
360 }322 }
M internal/ui/vim_page.go +4 -4
@@ -28,8 +28,8 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
28 }28 }
29 })29 })
30 a.showVimModeToast(ctx, bw, label)30 a.showVimModeToast(ctx, bw, label)
31- if bw.modeFrame != nil {31+ if bw.modeToaster != nil {
32- bw.modeFrame.setSuspended(true)32+ bw.modeToaster.setSuspended(true)
33 }33 }
34 logging.FromContext(ctx).Debug().34 logging.FromContext(ctx).Debug().
35 Str("window_id", bw.id).35 Str("window_id", bw.id).
@@ -54,8 +54,8 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason
54 }54 }
55 if stillVim {55 if stillVim {
56 a.showVimModeToast(ctx, bw, "")56 a.showVimModeToast(ctx, bw, "")
57- if bw.modeFrame != nil {57+ if bw.modeToaster != nil {
58- bw.modeFrame.setSuspended(false)58+ bw.modeToaster.setSuspended(false)
59 }59 }
60 }60 }
61 logging.FromContext(ctx).Debug().61 logging.FromContext(ctx).Debug().

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close