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:
7575 KeybindingsProvider: {}
7676 KeybindingsSaver: {}
7777 OmniboxPreferencesSaver: {}
78+ ModeLegendPreferencesSaver: {}
7879 DownloadResponse: {}
7980 PermissionDialogPresenter: {}
8081 PermissionRepository: {}
M CHANGELOG.md +3 -0
@@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.
1515
1616 ### Changed
1717
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"`.
1819 - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style.
1920 - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker.
2021 - **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.
2627
2728 ### Fixed
2829
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.
2932 - **Omnibox favorite selection**: Selected favorite rows retain their yellow left indicator in every visual style.
3033 - **Popup and floating-pane stability**: Hardened deferred popup cleanup, failed popup creation, opener-capable fallback behavior, and pane/window rollback paths.
3134 - **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
355355 new_pane_url = "dumb://history"
356356 ```
357357
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+
358362 ### Pane Mode
359363
360364 | Key | Type | Default | Description |
@@ -560,26 +564,26 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
560564
561565 | Action | Default Keys | Description |
562566 |--------|--------------|-------------|
563-| `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) |
565-| `next_tab` | `ctrl+tab` | Switch to next tab |
566-| `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 |
568-| `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 |
570-| `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked |
567+| `toggle-floating-pane` | `alt+f` | Toggle the workspace floating pane (persistent session) |
568+| `close-pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) |
569+| `next-tab` | `ctrl+tab` | Switch to next tab |
570+| `previous-tab` | `ctrl+shift+tab` | Switch to previous tab |
571+| `consume-or-expel-left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |
572+| `consume-or-expel-right` | `alt+]` | Consume into right sibling stack, or expel right if stacked |
573+| `consume-or-expel-up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked |
574+| `consume-or-expel-down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked |
571575
572576 **Example:**
573577 ```toml
574-[workspace.shortcuts.actions.close_pane]
578+[workspace.shortcuts.actions.close-pane]
575579 keys = ["ctrl+w"]
576580 desc = "Close active pane"
577581
578-[workspace.shortcuts.actions.next_tab]
582+[workspace.shortcuts.actions.next-tab]
579583 keys = ["ctrl+tab", "alt+l"]
580584 desc = "Switch to next tab"
581585
582-[workspace.shortcuts.actions.toggle_floating_pane]
586+[workspace.shortcuts.actions.toggle-floating-pane]
583587 keys = ["alt+f"]
584588 desc = "Toggle floating pane"
585589 ```
@@ -597,7 +601,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
597601
598602 The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.
599603
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`).
601605 - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.
602606 - `Alt+F` toggles visibility only and preserves floating state.
603607 - `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
612616 **Example:**
613617
614618 ```toml
615-[workspace.shortcuts.actions.toggle_floating_pane]
619+[workspace.shortcuts.actions.toggle-floating-pane]
616620 keys = ["alt+f"]
617621 desc = "Toggle floating pane"
618622
@@ -658,11 +662,12 @@ desc = "Open floating pane on GitHub"
658662 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
659663 | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color |
660664 | `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 |
662-| `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend sits on the active mode border in that mode's color |
663-| `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the legend when set to `delay` (>= 0) |
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 |
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 |
667+| `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the expanded legend when set to `delay` (>= 0) |
664668 | `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. |
665669 | `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 |
666671 | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms |
667672
668673 **Example:**
@@ -682,6 +687,7 @@ mode_legend = "delay"
682687 mode_legend_delay_ms = 500
683688 mode_legend_linger = true
684689 mode_legend_animations = true
690+mode_legend_collapsed = false
685691 transition_duration = 120
686692 ```
687693
M docs/reference/configuration.md +6 -5
@@ -101,7 +101,7 @@
101101 | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) |
102102 | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap |
103103 | `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 |
105105 | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | |
106106 | `workspace.pane_mode.timeout_ms` | int | `3000` | |
107107 | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings |
@@ -119,8 +119,8 @@
119119 | `workspace.resize_mode.step_percent` | float | `5.0` | |
120120 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
121121 | `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 |
123-| `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
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` | |
124124 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
125125 | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |
126126 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
@@ -143,11 +143,12 @@
143143 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |
144144 | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend |
145145 | `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 |
147-| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` |
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`; `off` keeps only the toaster header |
148148 | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` |
149149 | `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. |
150150 | `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 |
151152 | `workspace.styling.transition_duration` | int | `120` | |
152153 | `session.auto_restore` | bool | `false` | |
153154 | `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
3535 ## Example
3636
3737 ```toml
38-[workspace.shortcuts.actions.toggle_floating_pane]
38+[workspace.shortcuts.actions.toggle-floating-pane]
3939 keys = ["alt+f"]
4040 desc = "Toggle floating pane"
4141
M docs/reference/keybindings.md +3 -1
@@ -20,6 +20,8 @@ Press `Escape` or `Enter` to exit any mode.
2020
2121 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.
2222
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+
2325 ## Pane Mode (`Ctrl+P`)
2426
2527 | Action | Keys |
@@ -58,7 +60,7 @@ Keybinding tables use uppercase letters as visual labels for unshifted letter ke
5860
5961 ## Vim Mode (`Ctrl+Y`)
6062
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.
6264
6365 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.
6466
M internal/application/port/handler_deps.go +1 -0
@@ -46,6 +46,7 @@ type AutoCopyConfig interface {
4646 type HandlerDeps struct {
4747 SaveConfig func(context.Context, dto.WebUIConfig) error
4848 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
49+ SaveModeLegendCollapsed func(context.Context, bool) error
4950 KeybindingsGetter KeybindingsGetter
5051 KeybindingSetter KeybindingSetter
5152 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) {
2121 gateway := config.NewKeybindingsGateway(mgr)
2222 saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
2323 omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr))
24+ modeLegendSaveUC := usecase.NewSaveModeLegendCollapsedUseCase(config.NewModeLegendPreferencesGateway(mgr))
2425
2526 return &port.HandlerDeps{
2627 SaveConfig: saveUC.Execute,
2728 SaveOmniboxInitialBehavior: omniboxSaveUC.Execute,
29+ SaveModeLegendCollapsed: modeLegendSaveUC.Execute,
2830 KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway),
2931 KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway),
3032 KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway),
M internal/domain/entity/builtin_shortcuts.go +3 -2
@@ -2,8 +2,9 @@ package entity
22
33 // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every
44 // 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 an
6-// older config file. An action present with empty keys stays disabled.
5+// them, and config loading completes any that a config file omits (unless the
6+// user already bound one of their keys). An action present with empty keys
7+// stays disabled.
78 func BuiltinGlobalShortcuts() map[string]ActionBinding {
89 return map[string]ActionBinding{
910 "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 {
194194 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
195195 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
196196 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
197198
198199 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
199200 }
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) {
273273 m.viper.Set("workspace.popups.oauth_auto_close", false)
274274
275275 // Transform should map popups → browsing_contexts.
276- m.transformLegacyConfig()
276+ settings, err := m.transformedSettings()
277+ require.NoError(t, err)
277278
278279 // 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"),
280281 "workspace.popups.behavior should not exist after transform")
281282
282283 // browsing_contexts should now hold the values.
283- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))
284- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
285- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
284+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
285+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
286+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
286287
287288 // Unmarshal should succeed.
288289 cfg := &Config{}
289- err := m.viper.Unmarshal(cfg)
290+ err = settings.Unmarshal(cfg)
290291 require.NoError(t, err)
291292
292293 // BrowsingContexts should have the legacy values.
@@ -319,14 +320,15 @@ oauth_auto_close = false
319320 m.setDefaults()
320321 require.NoError(t, m.viper.ReadInConfig())
321322
322- m.transformLegacyConfig()
323+ settings, err := m.transformedSettings()
324+ require.NoError(t, err)
323325
324- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))
325- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
326- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
326+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
327+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
328+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
327329
328330 cfg := &Config{}
329- require.NoError(t, m.viper.Unmarshal(cfg))
331+ require.NoError(t, settings.Unmarshal(cfg))
330332 normalizeConfig(cfg)
331333
332334 assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)
@@ -354,10 +356,11 @@ placement = "left"
354356 m.setDefaults()
355357 require.NoError(t, m.viper.ReadInConfig())
356358
357- m.transformLegacyConfig()
359+ settings, err := m.transformedSettings()
360+ require.NoError(t, err)
358361
359- assert.Equal(t, "tabbed", m.viper.GetString("workspace.browsing_contexts.behavior"))
360- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
362+ assert.Equal(t, "tabbed", settings.GetString("workspace.browsing_contexts.behavior"))
363+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
361364 }
362365
363366 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 {
445445 ModeLegend: "delay",
446446 ModeLegendDelayMs: 500,
447447 ModeLegendAnimations: true,
448+ ModeLegendCollapsed: false,
448449 TransitionDuration: defaultTransitionDuration,
449450 },
450451 },
M internal/infrastructure/config/defaults_test.go +1 -0
@@ -19,6 +19,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1919 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
2020 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
2121 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
22+ assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed)
2223 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
2324
2425 // Engine defaults (replaces old Performance/Privacy sections)
M internal/infrastructure/config/loader.go +63 -12
@@ -24,6 +24,11 @@ type Manager struct {
2424 callbacks []func(*Config)
2525 watching bool
2626 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()
2732 }
2833
2934 type automaticEnvConfig struct {
@@ -145,15 +150,19 @@ func (m *Manager) readConfigFile() error {
145150 }
146151 }
147152
148- // Transform legacy action bindings and engine config before unmarshaling.
149- m.transformLegacyConfig()
150-
151153 return nil
152154 }
153155
154-// transformLegacyConfig converts old-format action bindings and engine config to the new format.
155-// This is called after reading config but before unmarshaling.
156-func (m *Manager) transformLegacyConfig() {
156+// transformedSettings returns a throwaway Viper holding the effective settings
157+// (config file, defaults and environment as resolved by the long-lived Viper)
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) {
157166 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
158167
159168 rawConfig := m.viper.AllSettings()
@@ -175,10 +184,11 @@ func (m *Manager) transformLegacyConfig() {
175184 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176185 }
177186
178- // Apply transformed config back to viper
179- for key, value := range rawConfig {
180- m.viper.Set(key, value)
187+ settings := viper.New()
188+ if err := settings.MergeConfigMap(rawConfig); err != nil {
189+ return nil, fmt.Errorf("failed to merge transformed config: %w", err)
181190 }
191+ return settings, nil
182192 }
183193
184194 func (m *Manager) configureAutomaticEnv() {
@@ -348,9 +358,16 @@ func (m *Manager) hasEngineSectionInConfig() bool {
348358 return m.viper.InConfig("engine.type")
349359 }
350360
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.
351364 func (m *Manager) unmarshalConfig() (*Config, error) {
365+ settings, err := m.transformedSettings()
366+ if err != nil {
367+ return nil, err
368+ }
352369 config := &Config{}
353- if err := m.viper.Unmarshal(config); err != nil {
370+ if err := settings.Unmarshal(config); err != nil {
354371 configFile := m.viper.ConfigFileUsed()
355372 return nil, fmt.Errorf(
356373 "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 {
374391 }
375392
376393 func normalizeConfig(config *Config) {
394+ completeDefaultActions(config)
377395 normalizeExternalLinks(config)
378396 normalizeAppearance(config)
379397 normalizeMedia(config)
@@ -504,10 +522,13 @@ func normalizeMedia(config *Config) {
504522 // Get returns the current configuration (thread-safe).
505523 func (m *Manager) Get() *Config {
506524 m.mu.RLock()
507- defer m.mu.RUnlock()
508-
509525 // Return a copy to prevent external modification
510526 configCopy := *m.config
527+ m.mu.RUnlock()
528+
529+ if m.afterSnapshot != nil {
530+ m.afterSnapshot()
531+ }
511532 return &configCopy
512533 }
513534
@@ -516,6 +537,35 @@ func (m *Manager) Save(cfg *Config) error {
516537 m.mu.Lock()
517538 defer m.mu.Unlock()
518539
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 {
519569 if cfg == nil {
520570 return fmt.Errorf("config is nil")
521571 }
@@ -753,6 +803,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
753803 m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend)
754804 m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs)
755805 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)
756807 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
757808 }
758809
M internal/infrastructure/config/loader_actions_test.go +6 -4
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
3636 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
3737 }
3838
39-func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {
39+func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
4040 home := t.TempDir()
4141 t.Setenv("HOME", home)
4242 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -65,7 +65,7 @@ desc = "Custom stack pane"
6565 require.NotNil(t, cfg)
6666
6767 _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- assert.False(t, ok)
68+ assert.True(t, ok)
6969
7070 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
7171 assert.Equal(t, "Custom stack pane", stack.Desc)
@@ -123,6 +123,8 @@ desc = "Toggle Favorites in right split"
123123 favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124124 assert.Empty(t, favorites.Keys)
125125 assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126- _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
127- assert.False(t, ok)
126+ // The renamed default action is a missing default, so it is active in memory.
127+ sidebar, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
128+ assert.True(t, ok)
129+ assert.NotEmpty(t, sidebar.Keys)
128130 }
M internal/infrastructure/config/loader_legacy_test.go +18 -14
@@ -149,11 +149,12 @@ func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *
149149 m.setDefaults()
150150 require.NoError(t, m.viper.ReadInConfig())
151151
152- m.transformLegacyConfig()
152+ settings, err := m.transformedSettings()
153+ require.NoError(t, err)
153154
154- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
155- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
156- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
155+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
156+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
157+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
157158 }
158159
159160 func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {
@@ -169,10 +170,11 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigrati
169170 m.setDefaults()
170171 require.NoError(t, m.viper.ReadInConfig())
171172
172- m.transformLegacyConfig()
173+ settings, err := m.transformedSettings()
174+ require.NoError(t, err)
173175
174- require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
175- require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate"))
176+ require.False(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
177+ require.Equal(t, 60, settings.GetInt("engine.cef.windowless_frame_rate"))
176178 }
177179
178180 func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {
@@ -187,10 +189,11 @@ func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *t
187189 m.setDefaults()
188190 require.NoError(t, m.viper.ReadInConfig())
189191
190- m.transformLegacyConfig()
192+ settings, err := m.transformedSettings()
193+ require.NoError(t, err)
191194
192- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
193- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
195+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
196+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
194197 }
195198
196199 func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {
@@ -206,9 +209,10 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t
206209 m.setDefaults()
207210 require.NoError(t, m.viper.ReadInConfig())
208211
209- m.transformLegacyConfig()
212+ settings, err := m.transformedSettings()
213+ require.NoError(t, err)
210214
211- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
212- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
213- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
215+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
216+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
217+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
214218 }
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) {
1717 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
1818 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
1919 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
20+ assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed)
2021 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
2122 }
2223
M internal/infrastructure/config/migrate.go +30 -26
@@ -296,19 +296,21 @@ const (
296296
297297 type defaultActionMap struct {
298298 key string
299+ spec actionMapSpec
299300 actions map[string]entity.ActionBinding
300301 }
301302
302303 func (m *Migrator) defaultActionMaps() []defaultActionMap {
303- return []defaultActionMap{
304- {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions},
305- {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions},
306- {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions},
307- {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions},
308- {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions},
309- {key: "workspace.resize_mode.actions", actions: m.defaultConfig.Workspace.ResizeMode.Actions},
310- {key: "session.session_mode.actions", actions: m.defaultConfig.Session.SessionMode.Actions},
304+ specs := actionMapSpecs()
305+ maps := make([]defaultActionMap, 0, len(specs))
306+ for _, spec := range specs {
307+ maps = append(maps, defaultActionMap{
308+ key: spec.key,
309+ spec: spec,
310+ actions: *spec.actions(m.defaultConfig),
311+ })
311312 }
313+ return maps
312314 }
313315
314316 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -987,7 +989,21 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
987989 return false
988990 }
989991
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+
9901004 // 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.
9911007 func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any) []string {
9921008 if userKeysWithValues == nil {
9931009 return nil
@@ -1005,15 +1021,9 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any
10051021 continue
10061022 }
10071023
1008- for actionName := range actionMap.actions {
1009- if _, exists := userActions[actionName]; exists {
1010- continue
1011- }
1012- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1013- if _, exists := userActions[legacyActionName]; exists {
1014- continue
1015- }
1016- missing = append(missing, actionMap.key+"."+actionName)
1024+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1025+ for _, name := range names {
1026+ missing = append(missing, actionMap.key+"."+name)
10171027 }
10181028 }
10191029
@@ -1034,15 +1044,9 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
10341044 continue
10351045 }
10361046
1037- for actionName, defaultAction := range actionMap.actions {
1038- if _, exists := userActions[actionName]; exists {
1039- continue
1040- }
1041- legacyActionName := strings.ReplaceAll(actionName, "-", "_")
1042- if _, exists := userActions[legacyActionName]; exists {
1043- continue
1044- }
1045- userActions[actionName] = defaultAction
1047+ names, _ := actionMap.spec.missingDefaultActions(actionMap.actions, m.userActionKeys(userActions))
1048+ for _, name := range names {
1049+ userActions[name] = actionMap.actions[name]
10461050 }
10471051
10481052 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
2020 return fmt.Errorf("config manager not initialized")
2121 }
2222
23- current := g.mgr.Get()
24- current.Omnibox.InitialBehavior = behavior
25-
26- return g.mgr.Save(current)
23+ return g.mgr.Update(func(cfg *Config) {
24+ cfg.Omnibox.InitialBehavior = behavior
25+ })
2726 }
M internal/infrastructure/config/schema_provider.go +8 -1
@@ -740,7 +740,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
740740 Key: "workspace.styling.mode_indicator_toaster_enabled",
741741 Type: "bool",
742742 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)",
744744 Section: SectionWorkspace,
745745 },
746746 {
@@ -771,6 +771,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
771771 Description: "Animate the mode legend and pressed keycaps",
772772 Section: SectionWorkspace,
773773 },
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+ },
774781 {
775782 Key: "workspace.styling.transition_duration",
776783 Type: "int",
M internal/infrastructure/config/schema_provider_test.go +1 -0
@@ -25,6 +25,7 @@ func TestModeLegendSchemaDefaults(t *testing.T) {
2525 {"workspace.styling.mode_legend_linger", "bool", "true", ""},
2626 {"workspace.styling.vim_mode_color", "string", "#F7768E", ""},
2727 {"workspace.styling.mode_legend_animations", "bool", "true", ""},
28+ {"workspace.styling.mode_legend_collapsed", "bool", "false", ""},
2829 } {
2930 entry, ok := keys[tc.key]
3031 require.True(t, ok, "missing schema key %s", tc.key)
M internal/infrastructure/config/transform_legacy.go +94 -35
@@ -1,6 +1,12 @@
11 package config
22
3-import "math"
3+import (
4+ "math"
5+ "slices"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
410
511 const (
612 sectionWorkspace = "workspace"
@@ -18,8 +24,29 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
1824 return &LegacyConfigTransformer{defaults: DefaultConfig()}
1925 }
2026
21-// TransformLegacyActions converts old-format action bindings to new format.
22-// This handles migration from:
27+// legacyActionSlicePaths are the action maps that older configs wrote as plain
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:
2350 //
2451 // [workspace.pane_mode.actions]
2552 // "focus-left" = ["h", "arrowleft"]
@@ -30,20 +57,72 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
3057 // keys = ["h", "arrowleft"]
3158 // desc = "Focus pane to the left"
3259 func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
33- actionPaths := [][]string{
34- {"workspace", "prefix_mode", "actions"},
35- {"workspace", "pane_mode", "actions"},
36- {"workspace", "tab_mode", "actions"},
37- {"workspace", "resize_mode", "actions"},
38- {"workspace", "shortcuts", "actions"},
39- {"session", "session_mode", "actions"},
60+ for _, path := range actionNamePaths {
61+ if actions, ok := rawActionSection(rawConfig, path); ok {
62+ canonicalizeActionNames(actions, strings.Join(path, "."))
63+ }
4064 }
65+ for _, path := range legacyActionSlicePaths {
66+ if actions, ok := rawActionSection(rawConfig, path); ok {
67+ t.transformActionSection(actions, path)
68+ }
69+ }
70+}
4171
42- for _, path := range actionPaths {
43- t.transformActionSection(rawConfig, path)
72+// canonicalActionName returns the canonical (hyphenated) spelling of an action name.
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")
44108 }
45109 }
46110
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+
47126 // TransformLegacyEngineConfig removes deprecated engine config keys.
48127 func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
49128 adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
@@ -217,29 +296,9 @@ func int64ConfigValue(value any) (int64, bool) {
217296 }
218297 }
219298
220-func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
221- // Navigate to the parent of the actions section
222- current := rawConfig
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
299+// transformActionSection converts old-format (key list) bindings of one action
300+// map to the ActionBinding format.
301+func (t *LegacyConfigTransformer) transformActionSection(actions map[string]any, path []string) {
243302 for actionName, actionValue := range actions {
244303 // If value is a slice (old format), convert to ActionBinding format
245304 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) {
8989
9090 // Check shortcuts
9191 shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any)
92- closePane, ok := shortcutActions["close_pane"].(map[string]any)
93- require.True(t, ok)
92+ closePane, ok := shortcutActions["close-pane"].(map[string]any)
93+ require.True(t, ok, "underscore action names are renamed to the hyphen form")
94+ assert.NotContains(t, shortcutActions, "close_pane")
9495 assert.Equal(t, []string{"ctrl+w"}, closePane["keys"])
9596
9697 // Check session mode
@@ -404,3 +405,72 @@ func TestTransformLegacyActions_UnknownAction(t *testing.T) {
404405 assert.Equal(t, []string{"x"}, binding["keys"])
405406 assert.Empty(t, binding["desc"], "unknown action should have empty description")
406407 }
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 (
99 "sort"
1010 "strings"
1111
12+ "github.com/bnema/dumber/internal/domain/keychord"
1213 domainurl "github.com/bnema/dumber/internal/domain/url"
1314 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
1415 "github.com/bnema/dumber/internal/domain/vimkeys"
@@ -299,8 +300,12 @@ func validateModalModeActionsWithKeyCanon(
299300 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
300301 }
301302
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
302307 if keyCanon == nil {
303- keyCanon = func(key string) (string, error) { return key, nil }
308+ keyCanon = canonicalizeChordKey
304309 }
305310
306311 actionNames := make([]string, 0, len(actions))
@@ -312,10 +317,7 @@ func validateModalModeActionsWithKeyCanon(
312317 seenSequence := make(map[string]string)
313318 for _, action := range actionNames {
314319 binding := actions[action]
315- if len(binding.Keys) == 0 {
316- validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
317- continue
318- }
320+ // An action with no keys is disabled; it binds nothing and is valid.
319321 keys := append([]string(nil), binding.Keys...)
320322 sort.Strings(keys)
321323 for _, key := range keys {
@@ -331,32 +333,10 @@ func validateModalModeActionsWithKeyCanon(
331333 continue
332334 }
333335 if modePath == "vim_mode" {
334- sequence, parseErr := vimkeys.ParseBinding(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- }
336+ validationErrors = append(validationErrors, validateVimBindingKey(modePath, action, canonical)...)
358337 }
359- if existingAction, exists := seenSequence[canonical]; exists {
338+ existingAction, exists := seenSequence[canonical]
339+ if exists && (!allowSameActionAliases || existingAction != action) {
360340 validationErrors = append(validationErrors, fmt.Sprintf(
361341 "duplicate key binding '%s' found in %s actions '%s' and '%s'",
362342 canonical,
@@ -371,6 +351,50 @@ func validateModalModeActionsWithKeyCanon(
371351 return validationErrors
372352 }
373353
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+
374398 func isVimCountStartKey(key vimkeys.Key) bool {
375399 return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
376400 }
M internal/infrastructure/config/vim_mode_config_test.go +2 -11
@@ -357,16 +357,14 @@ func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
357357 }
358358 }
359359
360-func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
360+func TestValidateVimMode_EmptyActionKeysDisableAction(t *testing.T) {
361361 cfg := DefaultConfig()
362362 // Override an action with no key bindings.
363363 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
364364 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
365365 }
366366
367- err := 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")
367+ require.NoError(t, validateConfig(cfg))
370368 }
371369
372370 func TestValidateModalModeActions_RegressionTable(t *testing.T) {
@@ -391,13 +389,6 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
391389 actions: nil,
392390 want: "workspace.pane_mode.actions cannot be empty",
393391 },
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- },
401392 {
402393 name: "page duplicate vim binding",
403394 modePath: "vim_mode",
M internal/infrastructure/config/watcher.go +4 -12
@@ -87,20 +87,12 @@ func (m *Manager) reload() error {
8787 return err
8888 }
8989
90- m.transformLegacyConfig()
91-
92- config := &Config{}
93- if err := m.viper.Unmarshal(config); err != nil {
90+ config, err := m.unmarshalConfig()
91+ if err != nil {
9492 return err
9593 }
96-
97- // Set database path if not specified
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
94+ if err := ensureDatabasePath(config); err != nil {
95+ return err
10496 }
10597
10698 normalizeConfig(config)
M internal/ui/app.go +2 -21
@@ -126,6 +126,7 @@ type App struct {
126126 vimModePolicyUC *usecase.VimModePolicyUseCase
127127 vimNavigationUC *usecase.VimNavigationUseCase
128128 vimMode vimModeState
129+ modeLegend modeLegendPreference
129130
130131 // Omnibox configuration (omnibox is created per workspace view)
131132 omniboxCfg component.OmniboxConfig
@@ -3929,26 +3930,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
39293930 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
39303931 }
39313932
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-
39523933 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
39533934 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
39543935 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
@@ -4345,6 +4326,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
43454326 a.runtimeConfig = newRuntimeConfigState(nil)
43464327 }
43474328 a.runtimeConfig.Update(snapshot)
4329+ a.modeLegend.configReloaded(snapshot.UI.Workspace.Styling.ModeLegendCollapsed)
43484330 if a.contentCoord != nil {
43494331 a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts)
43504332 }
@@ -4385,7 +4367,6 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
43854367 mode = bw.keyboardHandler.Mode()
43864368 }
43874369 a.updateModeFrame(bw, mode)
4388- a.updateModeIndicatorToaster(ctx, bw, mode)
43894370 }
43904371 }
43914372
M internal/ui/app_clipboard_test.go +3 -0
@@ -38,6 +38,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
3838 HandlerDeps: port.HandlerDeps{
3939 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
4040 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
41+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
4142 },
4243 Clipboard: nil,
4344 }
@@ -66,6 +67,7 @@ func TestNew_ClosesHistoryRecorderWhenRegisterHandlersFails(t *testing.T) {
6667 HandlerDeps: port.HandlerDeps{
6768 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
6869 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
70+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
6971 },
7072 }
7173
@@ -101,6 +103,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
101103 HandlerDeps: port.HandlerDeps{
102104 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
103105 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
106+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
104107 },
105108 Clipboard: clipboard,
106109 }
M internal/ui/app_mode_frame.go +126 -9
@@ -1,6 +1,10 @@
11 package ui
22
33 import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
48 "github.com/bnema/dumber/internal/ui/input"
59 "github.com/bnema/dumber/internal/ui/layout"
610 )
@@ -33,38 +37,151 @@ func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) {
3337 return
3438 }
3539 a.setModeFrameTab(bw)
36- newTarget := a.modeFrameTarget(bw, mode)
3740 // Rebuilds can temporarily lack a pane widget. Drop the old border while
3841 // the next focus/rebuild event retries the target.
39- bw.modeFrame.setTarget(newTarget, modeFrameClass(mode))
40- if bw.modeFrame.visible {
42+ bw.modeFrame.setTarget(a.modeFrameTarget(bw, mode), modeFrameClass(mode))
43+ if bw.modeFrame.target != nil {
4144 bw.modeFrame.refreshGeometry()
4245 }
4346 }
4447
48+// updateModeFrame reconciles the window's mode border and mode toaster with
49+// mode and the live configuration.
4550 func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {
46- if bw == nil || bw.modeFrame == nil {
51+ if bw == nil {
4752 return
4853 }
4954 a.setModeFrameTab(bw)
5055 cfg := a.runtimeConfigSnapshot().UI
51- bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling,
52- cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
56+ if bw.modeFrame != nil {
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+ }
5364 }
5465
5566 func (a *App) setModeFrameTab(bw *browserWindow) {
67+ if bw.modeFrame == nil {
68+ return
69+ }
5670 tabID := bw.modeFrame.tabID
5771 bw.modeFrame.tabID = ""
5872 if tab := a.activeTabForBrowserWindow(bw); tab != nil {
5973 bw.modeFrame.tabID = tab.ID
6074 }
6175 if tabID != bw.modeFrame.tabID {
62- bw.modeFrame.dismissLinger()
76+ bw.modeToaster.dismissLinger()
6377 }
6478 }
6579
6680 func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
67- if bw != nil && bw.modeFrame != nil {
68- bw.modeFrame.flash(action)
81+ if bw != nil && bw.modeToaster != nil {
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
69147 }
70148 }
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) {
10191019 // Mode toaster
10201020 // ============================================================================
10211021
1022-func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1023- t.Helper()
1024-
1025- container := mocks.NewMockBoxWidget(t)
1026- label := mocks.NewMockLabelWidget(t)
1027- factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
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()
1022+func enabledIndicatorSnapshot() entity.RuntimeConfigSnapshot {
1023+ return entity.RuntimeConfigSnapshot{
1024+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1025+ Styling: entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"},
1026+ }},
1027+ }
10521028 }
10531029
10541030 func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1055- factory := mocks.NewMockWidgetFactory(t)
1056- toaster, container, label := newModeToasterForVimModeTest(t, factory)
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}
1031+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1032+ bw, view := newFakeModeToasterWindow("win-1")
10681033 app.browserWindows = map[string]*browserWindow{bw.id: bw}
1069- app.lastFocusedWindowID = bw.id
10701034
1071- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1072- require.True(t, toaster.IsVisible())
1035+ app.updateModeFrame(bw, input.ModeVim)
1036+ require.True(t, view.pillShown)
1037+ require.Equal(t, "VIM MODE", view.header)
10731038
1039+ // Nothing but a mode change hides it: no timer dismisses the persistent pill.
10741040 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
10751041 mainContext := glib.MainContextDefault()
10761042 for time.Now().Before(deadline) {
@@ -1079,116 +1045,95 @@ func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
10791045 mainContext.Iteration(false)
10801046 }
10811047 }
1082-
1083- assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1048+ assert.True(t, view.pillShown, "Vim Mode pill must remain visible until mode exit")
10841049 }
10851050
10861051 func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1087- factory := mocks.NewMockWidgetFactory(t)
1088- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1089- expectVimModeToastShown(container, label)
1090- container.EXPECT().RemoveCssClass("toast-custom").Once()
1091- container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1092- container.EXPECT().SetVisible(false).Once()
1052+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1053+ bw, view := newFakeModeToasterWindow("win-1")
1054+
1055+ app.updateModeFrame(bw, input.ModeVim)
1056+ require.True(t, view.pillShown)
1057+ app.updateModeFrame(bw, input.ModeNormal)
10931058
1059+ assert.False(t, view.pillShown, "leaving Vim Mode must hide its persistent pill")
1060+}
1061+
1062+func TestVimMode_DisabledIndicatorWithLegendOffShowsNothing(t *testing.T) {
10941063 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1095- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1096- ModeIndicatorToasterEnabled: true,
1097- }}},
1064+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1065+ Styling: entity.WorkspaceStylingConfig{ModeLegend: "off"},
1066+ }},
10981067 })}
1099- bw := &browserWindow{id: "win-1", modeToaster: toaster}
1068+ bw, view := newFakeModeToasterWindow("win-1")
11001069
1101- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1102- require.True(t, toaster.IsVisible())
1103- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1070+ app.updateModeFrame(bw, input.ModeVim)
11041071
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)
11061074 }
11071075
1108-func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1109- factory := mocks.NewMockWidgetFactory(t)
1110- toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1111- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1112- bw := &browserWindow{id: "win-1", modeToaster: toaster}
1076+func TestVimMode_DisabledIndicatorKeepsPillAsLegendHeader(t *testing.T) {
1077+ snapshot := entity.RuntimeConfigSnapshot{
1078+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1079+ Styling: entity.WorkspaceStylingConfig{ModeLegend: "always"},
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")
11131085
1114- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1086+ app.updateModeFrame(bw, input.ModeVim)
11151087
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)
11171090 }
11181091
11191092 func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1120- factory := mocks.NewMockWidgetFactory(t)
1121- toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1122- toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
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}
1093+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1094+ bw1, view1 := newFakeModeToasterWindow("win-1")
1095+ bw2, view2 := newFakeModeToasterWindow("win-2")
11361096
1137- app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1138- app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1139- app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1097+ app.updateModeFrame(bw1, input.ModeVim)
1098+ app.updateModeFrame(bw2, input.ModeVim)
1099+ app.updateModeFrame(bw1, input.ModeNormal)
11401100
1141- assert.False(t, toaster1.IsVisible())
1142- assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1101+ assert.False(t, view1.pillShown)
1102+ assert.True(t, view2.pillShown, "exiting one window must not hide another window's pill")
11431103 }
11441104
11451105 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- }
11581106 keyboardHandler := newKeyboardHandlerInVimMode(t)
1159- bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1107+ bw, view := newFakeModeToasterWindow("win-1")
1108+ bw.keyboardHandler = keyboardHandler
11601109 app := &App{
1161- runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled),
1110+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot()),
11621111 browserWindows: map[string]*browserWindow{bw.id: bw},
11631112 }
11641113
1165- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1166- require.True(t, toaster.IsVisible())
1167- app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1114+ app.updateModeFrame(bw, input.ModeVim)
1115+ require.True(t, view.pillShown)
1116+ disabled := enabledIndicatorSnapshot()
1117+ disabled.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
1118+ app.applyRuntimeConfigChange(context.Background(), disabled)
11681119
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")
11701121 }
11711122
11721123 func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1173- factory := mocks.NewMockWidgetFactory(t)
1174- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1175- expectVimModeToastShown(container, label)
1176-
11771124 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
11791129 app := &App{
1180- runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1130+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(off),
11811131 browserWindows: map[string]*browserWindow{bw.id: bw},
11821132 }
1183- enabled := entity.RuntimeConfigSnapshot{
1184- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1185- ModeIndicatorToasterEnabled: true,
1186- }}},
1187- }
11881133
1189- app.applyRuntimeConfigChange(context.Background(), enabled)
1134+ app.applyRuntimeConfigChange(context.Background(), enabledIndicatorSnapshot())
11901135
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")
11921137 }
11931138
11941139 // ============================================================================
M internal/ui/app_vim_page_test.go +14 -25
@@ -5,41 +5,31 @@ import (
55 "testing"
66 "time"
77
8- "github.com/stretchr/testify/mock"
98 "github.com/stretchr/testify/require"
109
1110 "github.com/bnema/dumber/internal/application/dto"
1211 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1312 "github.com/bnema/dumber/internal/domain/entity"
1413 "github.com/bnema/dumber/internal/ui/input"
15- "github.com/bnema/dumber/internal/ui/layout/mocks"
1614 )
1715
18-// newVimPageTestWindow builds a window in Vim Mode with a real mode toaster
19-// bound to the pending-sequence callback, as initChrome does. It records every
20-// text shown on the toast so tests can assert what the user sees.
16+// newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound
17+// to the pending-sequence callback, as initChrome does. It records every header
18+// the pill shows so tests can assert what the user sees.
2119 func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
2220 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-
3321 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
3422 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
3523 ModeIndicatorToasterEnabled: true,
24+ ModeLegend: "off",
3625 }}},
3726 })}
38- bw := &browserWindow{id: "win-1", modeToaster: toaster}
27+ bw, view := newFakeModeToasterWindow("win-1")
3928 app.browserWindows = map[string]*browserWindow{bw.id: bw}
4029 bw.keyboardHandler = newKeyboardHandlerInVimMode(t)
4130 app.bindVimModeSequenceToaster(context.Background(), bw)
42- return app, bw, shown
31+ app.updateModeFrame(bw, input.ModeVim)
32+ return app, bw, &view.headers
4333 }
4434
4535 var visualOutcome = hintsOutcome(dto.VimPageVisual)
@@ -83,16 +73,15 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
8373 // restored when the interaction ends.
8474 func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
8575 app, bw, _ := newVimPageTestWindow(t)
86- frame := &modeFrame{mode: input.ModeVim, visible: true}
87- bw.modeFrame = frame
76+ toaster := bw.modeToaster
8877 wv := portmocks.NewMockWebView(t)
8978
9079 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")
9281
9382 wv.EXPECT().ID().Return(1).Maybe()
9483 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")
9685 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
9786 }
9887
@@ -101,8 +90,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
10190 // suspended legend stay in place.
10291 func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
10392 app, bw, shown := newVimPageTestWindow(t)
104- frame := &modeFrame{mode: input.ModeVim, visible: true}
105- bw.modeFrame = frame
93+ toaster := bw.modeToaster
10694 wv := portmocks.NewMockWebView(t)
10795 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
10896 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
@@ -117,16 +105,17 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
117105 require.Equal(t, want, (*shown)[len(*shown)-1])
118106 require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)
119107 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")
121109 }
122110 }
123111
124112 func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
125113 app, bw, shown := newVimPageTestWindow(t)
114+ before := len(*shown)
126115
127116 app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)
128117
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")
130119 }
131120
132121 // 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 {
3535 tabs *entity.TabList // per-window tab list (single source of truth for tab state)
3636 mainWindow *window.MainWindow
3737 appToaster *component.Toaster
38- modeToaster *component.Toaster
38+ modeToaster *modeToaster
3939 touchpadNavIndicator *component.TouchpadNavigationIndicator
4040 modeFrame *modeFrame
4141 sessionManager *component.SessionManager
@@ -104,8 +104,11 @@ func (bw *browserWindow) clearShellState() {
104104 bw.touchpadNavIndicator.Destroy()
105105 }
106106 bw.appToaster = nil
107- bw.modeToaster = nil
108107 bw.touchpadNavIndicator = nil
108+ if bw.modeToaster != nil {
109+ bw.modeToaster.destroy()
110+ bw.modeToaster = nil
111+ }
109112 if bw.modeFrame != nil {
110113 bw.modeFrame.destroy()
111114 bw.modeFrame = nil
@@ -141,7 +144,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
141144 // no hidden-panel queries or widget row rebuilds.
142145 bw.initToasterOverlay(a)
143146 bw.initTouchpadNavigationIndicator()
144- bw.initBorderOverlay(a)
147+ bw.initBorderOverlay(ctx, a)
145148 bw.initAccentPicker(ctx, a)
146149 bw.initSessionManager(ctx, a)
147150 bw.initTabPicker(ctx, a)
@@ -160,15 +163,6 @@ func (bw *browserWindow) initToasterOverlay(a *App) {
160163 }
161164 }
162165 }
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- }
172166 }
173167
174168 func (bw *browserWindow) initTouchpadNavigationIndicator() {
@@ -184,12 +178,19 @@ func (bw *browserWindow) initTouchpadNavigationIndicator() {
184178 }
185179 }
186180
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) {
188184 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
189185 return
190186 }
191187
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+ })
193194 }
194195
195196 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
@@ -359,13 +360,11 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
359360
360361 a.releaseFloatingSessionsForTab(ctx, tab.ID)
361362 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++
365363 bw.modeFrame.setTarget(nil, "")
366364 bw.modeFrame.tabID = ""
367- bw.modeFrame.hide()
368- a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode)
365+ // The legend belonged to the released workspace; the header stays for
366+ // the mode.
367+ bw.modeToaster.release()
369368 }
370369 if a.contentCoord != nil && tab.Workspace != nil {
371370 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) {
3434 }
3535
3636 setShellField(t, removed, "appToaster", &component.Toaster{})
37- setShellField(t, removed, "modeToaster", &component.Toaster{})
37+ setShellField(t, removed, "modeToaster", &modeToaster{})
3838 setShellField(t, removed, "modeFrame", &modeFrame{})
3939 setShellField(t, removed, "sessionManager", &component.SessionManager{})
4040 setShellField(t, removed, "tabPicker", &component.TabPicker{})
M internal/ui/component/toaster.go +0 -31
@@ -80,9 +80,6 @@ type ToastOptions struct {
8080 TextColor string
8181 // Position determines where the toast appears on screen.
8282 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
8683 }
8784
8885 // ToastOption is a functional option for configuring toast display.
@@ -117,14 +114,6 @@ func WithPosition(pos ToastPosition) ToastOption {
117114 }
118115 }
119116
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-
128117 // defaultToastOptions returns the default toast options.
129118 func defaultToastOptions() ToastOptions {
130119 return ToastOptions{
@@ -150,7 +139,6 @@ type Toaster struct {
150139 hasCustomStyle bool
151140 customBgColor string // Current custom background color
152141 customTextColor string // Current custom text color
153- customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode")
154142 customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
155143
156144 mu sync.Mutex
@@ -223,11 +211,6 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op
223211 // Apply custom colors if specified
224212 t.applyCustomStyle(options)
225213
226- // Apply mode class if specified (atomically to avoid flicker)
227- if options.ModeClass != "" {
228- t.applyModeClassInternal(options.ModeClass)
229- }
230-
231214 // Apply position
232215 t.applyPosition(options.Position)
233216
@@ -385,11 +368,6 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) {
385368 func (t *Toaster) clearCustomStyle() {
386369 if t.hasCustomStyle {
387370 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- }
393371 // Remove custom CSS provider if one was added
394372 if t.customCSSProvider != nil {
395373 styleCtx := t.container.GtkWidget().GetStyleContext()
@@ -407,15 +385,6 @@ func (t *Toaster) clearCustomStyle() {
407385 }
408386 }
409387
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-
419388 // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
420389 // Must be called with lock held.
421390 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 {
130130 if d.HandlerDeps.SaveOmniboxInitialBehavior == nil {
131131 return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior")
132132 }
133+ if d.HandlerDeps.SaveModeLegendCollapsed == nil {
134+ return ErrMissingDependency("HandlerDeps.SaveModeLegendCollapsed")
135+ }
133136 // Use cases are optional - can be nil if not needed
134137 return nil
135138 }
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 {
1616 return &entity.WorkspaceConfig{
1717 Shortcuts: entity.GlobalShortcutsConfig{
1818 Actions: map[string]entity.ActionBinding{
19- "toggle_floating_pane": {Keys: []string{"alt+f"}},
20- "consume_or_expel_left": {Keys: []string{"alt+["}},
21- "consume_or_expel_right": {Keys: []string{"alt+]"}},
19+ "toggle-floating-pane": {Keys: []string{"alt+f"}},
20+ "consume-or-expel-left": {Keys: []string{"alt+["}},
21+ "consume-or-expel-right": {Keys: []string{"alt+]"}},
22+ "open-omnibox": {Keys: []string{"ctrl+l"}},
2223 },
2324 },
2425 }
M internal/ui/input/global_shortcuts.go +0 -9
@@ -504,23 +504,14 @@ func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
504504
505505 func globalShortcutActionMap() map[string]Action {
506506 return map[string]Action{
507- "toggle_floating_pane": ActionToggleFloatingPane,
508507 "toggle-floating-pane": ActionToggleFloatingPane,
509- "consume_or_expel_left": ActionConsumeOrExpelLeft,
510508 "consume-or-expel-left": ActionConsumeOrExpelLeft,
511- "consume_or_expel_right": ActionConsumeOrExpelRight,
512509 "consume-or-expel-right": ActionConsumeOrExpelRight,
513- "consume_or_expel_up": ActionConsumeOrExpelUp,
514510 "consume-or-expel-up": ActionConsumeOrExpelUp,
515- "consume_or_expel_down": ActionConsumeOrExpelDown,
516511 "consume-or-expel-down": ActionConsumeOrExpelDown,
517- "toggle_history_systemview": ActionToggleHistorySystemView,
518512 "toggle-history-systemview": ActionToggleHistorySystemView,
519- "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
520513 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
521- "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522514 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523- "toggle_config_systemview": ActionToggleConfigSystemView,
524515 "toggle-config-systemview": ActionToggleConfigSystemView,
525516 }
526517 }
M internal/ui/input/keyboard_test.go +2 -1
@@ -16,6 +16,7 @@ import (
1616 // Used by tests that construct a KeyboardHandler.
1717 func newTestWorkspace() *entity.WorkspaceConfig {
1818 return &entity.WorkspaceConfig{
19+ Shortcuts: entity.GlobalShortcutsConfig{Actions: entity.BuiltinGlobalShortcuts()},
1920 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
2021 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
2122 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
@@ -331,7 +332,7 @@ func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing
331332 func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
332333 workspace := newTestWorkspace()
333334 workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
334- "toggle_floating_pane": {Keys: []string{"alt+f"}},
335+ "toggle-floating-pane": {Keys: []string{"alt+f"}},
335336 }
336337 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
337338 calls := 0
M internal/ui/input/prefix_mode_test.go +4 -6
@@ -108,12 +108,10 @@ func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
108108 assert.Equal(t, ModeNormal, h.Mode())
109109 }
110110
111-func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
111+func TestShortcutSet_BuiltinShortcutsAndOverrides(t *testing.T) {
112112 ws := newTestWorkspace()
113- ws.Shortcuts.Actions = map[string]entity.ActionBinding{
114- "reload": {Keys: []string{}}, // disabled by user
115- "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user
116- }
113+ ws.Shortcuts.Actions["reload"] = entity.ActionBinding{Keys: []string{}} // disabled by user
114+ ws.Shortcuts.Actions["quit"] = entity.ActionBinding{Keys: []string{"ctrl+shift+q"}} // rebound by user
117115 set := NewShortcutSet(context.Background(), ws, newTestSession())
118116
119117 lookup := func(key string) (Action, bool) {
@@ -124,7 +122,7 @@ func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
124122 }
125123
126124 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")
128126 assert.Equal(t, ActionOpenOmnibox, action)
129127
130128 _, ok = lookup("ctrl+r")
M internal/ui/input/shortcuts.go +58 -173
@@ -9,6 +9,7 @@ import (
99 "strings"
1010
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/keychord"
1213 "github.com/bnema/dumber/internal/domain/vimkeys"
1314 "github.com/bnema/dumber/internal/logging"
1415 "github.com/bnema/puregotk/v4/gdk"
@@ -31,67 +32,42 @@ const (
3132 // modifierMask filters out non-standard modifiers from GDK state.
3233 const modifierMask = ModCtrl | ModShift | ModAlt
3334
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.
3437 var keyvalByName = map[string]uint{
35- "escape": uint(gdk.KEY_Escape),
36- "esc": uint(gdk.KEY_Escape),
37- "return": uint(gdk.KEY_Return),
38- "enter": uint(gdk.KEY_Return),
39- "tab": uint(gdk.KEY_Tab),
40- "space": uint(gdk.KEY_space),
41- "backspace": uint(gdk.KEY_BackSpace),
42- "delete": uint(gdk.KEY_Delete),
43- "del": uint(gdk.KEY_Delete),
44- "home": uint(gdk.KEY_Home),
45- "end": uint(gdk.KEY_End),
46- "pageup": uint(gdk.KEY_Page_Up),
47- "page_up": uint(gdk.KEY_Page_Up),
48- "pagedown": uint(gdk.KEY_Page_Down),
49- "page_down": uint(gdk.KEY_Page_Down),
50- "left": uint(gdk.KEY_Left),
51- "arrowleft": uint(gdk.KEY_Left),
52- "right": uint(gdk.KEY_Right),
53- "arrowright": uint(gdk.KEY_Right),
54- "up": uint(gdk.KEY_Up),
55- "arrowup": uint(gdk.KEY_Up),
56- "down": uint(gdk.KEY_Down),
57- "arrowdown": uint(gdk.KEY_Down),
58- "f1": uint(gdk.KEY_F1),
59- "f2": uint(gdk.KEY_F2),
60- "f3": uint(gdk.KEY_F3),
61- "f4": uint(gdk.KEY_F4),
62- "f5": uint(gdk.KEY_F5),
63- "f6": uint(gdk.KEY_F6),
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-
38+ "escape": uint(gdk.KEY_Escape),
39+ "return": uint(gdk.KEY_Return),
40+ "tab": uint(gdk.KEY_Tab),
41+ "space": uint(gdk.KEY_space),
42+ "backspace": uint(gdk.KEY_BackSpace),
43+ "delete": uint(gdk.KEY_Delete),
44+ "home": uint(gdk.KEY_Home),
45+ "end": uint(gdk.KEY_End),
46+ "pageup": uint(gdk.KEY_Page_Up),
47+ "pagedown": uint(gdk.KEY_Page_Down),
48+ "left": uint(gdk.KEY_Left),
49+ "right": uint(gdk.KEY_Right),
50+ "up": uint(gdk.KEY_Up),
51+ "down": uint(gdk.KEY_Down),
52+ "f1": uint(gdk.KEY_F1),
53+ "f2": uint(gdk.KEY_F2),
54+ "f3": uint(gdk.KEY_F3),
55+ "f4": uint(gdk.KEY_F4),
56+ "f5": uint(gdk.KEY_F5),
57+ "f6": uint(gdk.KEY_F6),
58+ "f7": uint(gdk.KEY_F7),
59+ "f8": uint(gdk.KEY_F8),
60+ "f9": uint(gdk.KEY_F9),
61+ "f10": uint(gdk.KEY_F10),
62+ "f11": uint(gdk.KEY_F11),
63+ "f12": uint(gdk.KEY_F12),
64+ "plus": uint(gdk.KEY_plus),
65+ "minus": uint(gdk.KEY_minus),
66+ "equal": uint(gdk.KEY_equal),
8767 "bracketleft": uint(gdk.KEY_bracketleft),
8868 "bracketright": uint(gdk.KEY_bracketright),
89- "[": uint(gdk.KEY_bracketleft),
90- "]": uint(gdk.KEY_bracketright),
9169 "braceleft": uint(gdk.KEY_braceleft),
9270 "braceright": uint(gdk.KEY_braceright),
93- "{": uint(gdk.KEY_braceleft),
94- "}": uint(gdk.KEY_braceright),
9571 }
9672
9773 // KeyBinding represents a single key combination.
@@ -285,7 +261,6 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
285261
286262 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
287263 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288- s.registerBuiltinShortcutFallbacks(workspace)
289264 s.registerActivationShortcutsFromParts(ctx, workspace, session)
290265 s.registerConfiguredShortcuts(workspace)
291266 // 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
376351 Msg("mode activation registered")
377352 }
378353
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-
401354 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
402355 if cfg == nil {
403356 return
@@ -421,10 +374,13 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
421374 // key conflict the same across restarts.
422375 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
423376 builtins := entity.BuiltinGlobalShortcuts()
377+ isBuiltin := func(name string) bool {
378+ _, ok := builtins[name]
379+ return ok
380+ }
424381 names := slices.Sorted(maps.Keys(actions))
425382 slices.SortStableFunc(names, func(a, b string) int {
426- _, aBuiltin := builtins[a]
427- _, bBuiltin := builtins[b]
383+ aBuiltin, bBuiltin := isBuiltin(a), isBuiltin(b)
428384 switch {
429385 case aBuiltin == bBuiltin:
430386 return 0
@@ -573,57 +529,37 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
573529 Msg(mode + " mode shortcuts built")
574530 }
575531
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.
576535 var configActionToAction = map[string]Action{
577- "toggle_floating_pane": ActionToggleFloatingPane,
578536 "toggle-floating-pane": ActionToggleFloatingPane,
579- "toggle_history_systemview": ActionToggleHistorySystemView,
580537 "toggle-history-systemview": ActionToggleHistorySystemView,
581- "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
582538 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
583- "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
584539 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
585- "toggle_config_systemview": ActionToggleConfigSystemView,
586540 "toggle-config-systemview": ActionToggleConfigSystemView,
587541
588542 // Tab actions
589- "new_tab": ActionNewTab,
590543 "new-tab": ActionNewTab,
591- "close_tab": ActionCloseTab,
592544 "close-tab": ActionCloseTab,
593- "next_tab": ActionNextTab,
594545 "next-tab": ActionNextTab,
595- "previous_tab": ActionPreviousTab,
596546 "previous-tab": ActionPreviousTab,
597- "rename_tab": ActionRenameTab,
598547 "rename-tab": ActionRenameTab,
599548
600549 // Pane actions
601- "split_right": ActionSplitRight,
602550 "split-right": ActionSplitRight,
603- "split_left": ActionSplitLeft,
604551 "split-left": ActionSplitLeft,
605- "split_up": ActionSplitUp,
606552 "split-up": ActionSplitUp,
607- "split_down": ActionSplitDown,
608553 "split-down": ActionSplitDown,
609- "close_pane": ActionClosePane,
610554 "close-pane": ActionClosePane,
611- "stack_pane": ActionStackPane,
612555 "stack-pane": ActionStackPane,
613- "move_pane_to_tab": ActionMovePaneToTab,
614556 "move-pane-to-tab": ActionMovePaneToTab,
615- "move_pane_to_next_tab": ActionMovePaneToNextTab,
616557 "move-pane-to-next-tab": ActionMovePaneToNextTab,
617- "eject_pane_to_window": ActionEjectPaneToWindow,
618558 "eject-pane-to-window": ActionEjectPaneToWindow,
619559
620- "consume_or_expel_left": ActionConsumeOrExpelLeft,
621560 "consume-or-expel-left": ActionConsumeOrExpelLeft,
622- "consume_or_expel_right": ActionConsumeOrExpelRight,
623561 "consume-or-expel-right": ActionConsumeOrExpelRight,
624- "consume_or_expel_up": ActionConsumeOrExpelUp,
625562 "consume-or-expel-up": ActionConsumeOrExpelUp,
626- "consume_or_expel_down": ActionConsumeOrExpelDown,
627563 "consume-or-expel-down": ActionConsumeOrExpelDown,
628564
629565 // Focus navigation
@@ -679,17 +615,11 @@ var configActionToAction = map[string]Action{
679615 "enter-vim-mode": ActionEnterVimMode,
680616
681617 // Vim mode scroll actions
682- "vim_scroll_left": ActionVimScrollLeft,
683618 "vim-scroll-left": ActionVimScrollLeft,
684- "vim_scroll_down": ActionVimScrollDown,
685619 "vim-scroll-down": ActionVimScrollDown,
686- "vim_scroll_up": ActionVimScrollUp,
687620 "vim-scroll-up": ActionVimScrollUp,
688- "vim_scroll_right": ActionVimScrollRight,
689621 "vim-scroll-right": ActionVimScrollRight,
690- "vim_scroll_down_fast": ActionVimScrollDownFast,
691622 "vim-scroll-down-fast": ActionVimScrollDownFast,
692- "vim_scroll_up_fast": ActionVimScrollUpFast,
693623 "vim-scroll-up-fast": ActionVimScrollUpFast,
694624 }
695625
@@ -760,87 +690,42 @@ func mapConfigAction(configAction string) Action {
760690 }
761691
762692 // 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.
763695 // Returns false if the string cannot be parsed.
764696 func ParseKeyString(s string) (KeyBinding, bool) {
765- if s == "" {
697+ chord, ok := keychord.Parse(s)
698+ if !ok {
766699 return KeyBinding{}, false
767700 }
768-
769- s = strings.TrimSpace(s)
770- if s == "" {
701+ keyval, ok := keychordKeyval(chord.Key)
702+ if !ok {
771703 return KeyBinding{}, false
772704 }
773- if s == "+" {
774- return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
775- }
776-
777- parts := strings.Split(s, "+")
778705
779706 var modifiers Modifier
780- var keyPart string
781-
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 = "+"
707+ if chord.Mods&keychord.Ctrl != 0 {
708+ modifiers |= ModCtrl
807709 }
808-
809- if keyPart == "" {
810- return KeyBinding{}, false
710+ if chord.Mods&keychord.Alt != 0 {
711+ modifiers |= ModAlt
811712 }
812-
813- // Treat uppercase single-letter keys as Shift+<letter>.
814- if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
713+ if chord.Mods&keychord.Shift != 0 {
815714 modifiers |= ModShift
816- keyPart = strings.ToLower(keyPart)
817715 }
818-
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
716+ return KeyBinding{Keyval: keyval, Modifiers: modifiers}, true
828717 }
829718
830-// stringToKeyval converts a key name to its GDK keyval.
831-func stringToKeyval(s string) (uint, bool) {
832- if s == "" {
833- return 0, false
834- }
835-
836- if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
719+// keychordKeyval converts a canonical keychord key name to its GDK keyval.
720+func keychordKeyval(name string) (uint, bool) {
721+ if keyval, ok := keyvalByName[name]; ok {
837722 return keyval, true
838723 }
839724
840- // Single printable ASCII keys (a-z, symbols such as %, ", &).
725+ // Single printable ASCII keys (a-z, 0-9, symbols such as %, ", &).
841726 // GDK keyvals for printable ASCII match their character codes.
842- if len(s) == 1 && s[0] > ' ' && s[0] <= '~' {
843- return uint(s[0]), true
727+ if len(name) == 1 && name[0] > ' ' && name[0] <= '~' {
728+ return uint(name[0]), true
844729 }
845730
846731 return 0, false
M internal/ui/input/shortcuts_test.go +3 -29
@@ -380,7 +380,7 @@ func TestShouldAutoExitMode(t *testing.T) {
380380 }
381381
382382 func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
383- tests := []string{"eject_pane_to_window", "eject-pane-to-window"}
383+ tests := []string{"eject-pane-to-window"}
384384 for _, name := range tests {
385385 t.Run(name, func(t *testing.T) {
386386 if got := mapConfigAction(name); got != ActionEjectPaneToWindow {
@@ -391,29 +391,9 @@ func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
391391 }
392392
393393 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
394- action := mapConfigAction("toggle_floating_pane")
394+ action := mapConfigAction("toggle-floating-pane")
395395 if 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- })
396+ t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
417397 }
418398 }
419399
@@ -580,17 +560,11 @@ func TestMapConfigAction_VimScrollActions(t *testing.T) {
580560 config string
581561 expected Action
582562 }{
583- {name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft},
584563 {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},
585- {name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown},
586564 {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},
587- {name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp},
588565 {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},
589- {name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight},
590566 {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},
591- {name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast},
592567 {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},
594568 {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},
595569 }
596570
M internal/ui/mode_frame.go +35 -481
@@ -1,106 +1,41 @@
11 package ui
22
33 import (
4- "strings"
5-
64 "github.com/bnema/dumber/internal/domain/entity"
75 "github.com/bnema/dumber/internal/ui/input"
86 "github.com/bnema/dumber/internal/ui/layout"
9- "github.com/bnema/puregotk/v4/gdk"
107 "github.com/bnema/puregotk/v4/glib"
118 "github.com/bnema/puregotk/v4/gobject"
129 "github.com/bnema/puregotk/v4/graphene"
1310 "github.com/bnema/puregotk/v4/gtk"
1411 )
1512
16-const (
17- legendColumnSpacing = 8
18- legendMaxWidth = 700
19- legendCompactWidth = 460
20- legendTwoColumnWidth = 650
21- legendInset = 16
22- legendHeaderSpace = 80
23- legendRefreshMs = 100
24-)
13+const borderRefreshMs = 100
2514
26-// modeFrame owns the per-window mode border and its non-interactive legend.
27-// GTK access is restricted to the main thread, as with the rest of the UI.
15+// modeFrame owns the per-window mode border drawn around its target pane or
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.
2819 type modeFrame struct {
2920 mode input.Mode
3021 tabID entity.TabID
3122 target layout.Widget
3223 frameClass string
33- root *gtk.Box
3424 border *gtk.Box
3525 borderStyle cssClassTarget // border's CSS classes; a seam for tests
3626 borderRect func() (x, y, width, height int, ok bool)
37- panel *gtk.Box
38- content *gtk.FlowBox
39- scroller *gtk.ScrolledWindow
40- heading *gtk.Label
41- rows map[string][]*gtk.Widget
42- keycaps map[string][]*gtk.Widget
43- pending string
44- visible bool
45- // 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)
27+ overlay *gtk.Overlay
28+
29+ refreshSource uint
30+ positionHandlerID uint
31+ pulseCycle bool
7632 }
7733
78-func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
79- if overlay == nil || window == nil {
34+func newModeFrame(overlay *gtk.Overlay) *modeFrame {
35+ if overlay == nil {
8036 return nil
8137 }
82- f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
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()
38+ f := &modeFrame{mode: input.ModeNormal, overlay: overlay}
10439 // The border is its own overlay: inset shadows on pane containers are
10540 // painted below web content and would be invisible.
10641 f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
@@ -112,129 +47,38 @@ func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFram
11247 overlay.AddOverlay(&f.border.Widget)
11348 overlay.SetClipOverlay(&f.border.Widget, false)
11449 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)
11850 // A single positioned overlay tracks the active pane without reparenting it.
11951 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
120- placement := f.placement
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 {
52+ if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil {
12753 return false
12854 }
129- x, y, w, h, ok := placement()
55+ x, y, w, h, ok := f.borderRect()
13056 if !ok {
13157 return false
13258 }
13359 return writeOverlayAllocation(allocation, x, y, w, h)
13460 }
13561 f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
136- f.installDismissControllers()
13762 return f
13863 }
13964
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-
20465 func (f *modeFrame) destroy() {
20566 if f == nil {
20667 return
20768 }
208- f.cancelTimers()
209- f.dismissLinger()
21069 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- }
22270 f.setTarget(nil, "")
22371 if f.overlay != nil && f.positionHandlerID != 0 {
22472 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
22573 f.positionHandlerID = 0
22674 }
227- f.placement = nil
22875 f.borderRect = nil
229- if f.overlay != nil && f.root != nil {
230- f.overlay.RemoveOverlay(&f.root.Widget)
231- }
23276 if f.overlay != nil && f.border != nil {
23377 f.overlay.RemoveOverlay(&f.border.Widget)
78+ // NewBox's reference is not released by the removal.
79+ f.border.Unref()
23480 }
23581 f.overlay = nil
236- f.window = nil
237- f.root = nil
23882 f.border = nil
23983 f.borderStyle = nil
24084 }
@@ -244,6 +88,17 @@ type cssClassTarget interface {
24488 RemoveCssClass(string)
24589 }
24690
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+
247102 func (f *modeFrame) setTarget(target layout.Widget, class string) {
248103 if f == nil {
249104 return
@@ -314,167 +169,6 @@ func modeFrameClass(mode input.Mode) string {
314169 }
315170 }
316171
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-
478172 func (f *modeFrame) stopGeometryRefresh() {
479173 if f == nil || f.refreshSource == 0 {
480174 return
@@ -483,92 +177,40 @@ func (f *modeFrame) stopGeometryRefresh() {
483177 f.refreshSource = 0
484178 }
485179
486-func (f *modeFrame) show() {
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
180+// Measure outside GTK allocation while the border is active, including window
495181 // resizes and splitter drags after the initial layout has settled.
496182 func (f *modeFrame) startGeometryRefresh() {
497183 f.stopGeometryRefresh()
498184 f.refreshGeometry()
499185 cb := glib.SourceFunc(func(_ uintptr) bool {
500- if f.target == nil || f.root == nil || f.lingering {
186+ if f.target == nil || f.border == nil {
501187 f.refreshSource = 0
502188 return false
503189 }
504190 f.refreshGeometry()
505191 return true
506192 })
507- f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &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- }
193+ f.refreshSource = glib.TimeoutAdd(borderRefreshMs, &cb, 0)
516194 }
517195
518-// clearPlacement hides both the border and the legend: the target is gone.
196+// clearPlacement hides the border: the target is gone.
519197 func (f *modeFrame) clearPlacement() {
520198 if f.borderRect != nil {
521199 f.borderRect = nil
522200 f.border.SetVisible(false)
523201 }
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()
535202 }
536203
537-func (f *modeFrame) measureLegendHeight(panelWidth int) int {
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.
204+// refreshGeometry measures the target outside GTK's positioning callback.
558205 func (f *modeFrame) refreshGeometry() {
559- if f.lingering {
560- return
561- }
562- previous := f.placement
563206 if f.target == nil || f.overlay == nil {
564207 f.clearPlacement()
565208 return
566209 }
567- overlayWidget := &f.overlay.Widget
568210 point := &graphene.Point{X: 0, Y: 0}
569211 out := &graphene.Point{}
570212 target := f.target.GtkWidget()
571- if target == nil || !target.ComputePoint(overlayWidget, point, out) {
213+ if target == nil || !target.ComputePoint(&f.overlay.Widget, point, out) {
572214 f.clearPlacement()
573215 return
574216 }
@@ -577,48 +219,7 @@ func (f *modeFrame) refreshGeometry() {
577219 f.clearPlacement()
578220 return
579221 }
580- x, bottom := int(out.X), int(out.Y)+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()
222+ f.placeBorder(int(out.X), int(out.Y), width, height)
622223 }
623224
624225 func (f *modeFrame) placeBorder(x, y, width, height int) {
@@ -631,50 +232,3 @@ func (f *modeFrame) placeBorder(x, y, width, height int) {
631232 f.border.SetVisible(true)
632233 f.border.QueueAllocate()
633234 }
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) {
2828 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error {
2929 return nil
3030 },
31+ SaveModeLegendCollapsed: func(context.Context, bool) error {
32+ return nil
33+ },
3134 },
3235 }
3336
M internal/ui/theme/css.go +0 -36
@@ -1404,42 +1404,6 @@ func generateToasterCSS(p Palette) string {
14041404 background-color: alpha(var(--destructive), 0.9);
14051405 color: var(--destructive-text);
14061406 }
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-}
14431407 `
14441408 }
14451409
M internal/ui/theme/css_test.go +14 -0
@@ -427,3 +427,17 @@ func TestVimModeUsesOwnColor(t *testing.T) {
427427 t.Fatal("palette missing Vim color")
428428 }
429429 }
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 }{
1717
1818 func generateModeLegendCSS() string {
1919 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. */
2122 .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; }
2234 .omnibox-container.mode-legend-panel {
2335 border-style: solid;
2436 border-width: 0.125em;
25- padding: 0 0 0.375em 0;
37+ padding: 0.625em 0.5em 0.375em 0.5em;
2638 min-width: 0;
2739 animation: mode-legend-appear 120ms ease-out;
2840 }
2941 .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; }
3042 .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; }
31-.mode-legend-title {
43+.mode-legend-hint {
3244 font-family: var(--font-mono);
33- font-size: 0.8125em;
34- font-weight: bold;
35- padding: 0.375em 0.75em;
36- margin-bottom: 0.375em;
45+ font-size: 0.6875em;
46+ color: var(--surface-muted);
47+ margin: 0.25em 0.5em 0 0;
3748 }
38-.mode-legend-content { padding: 0 0.25em; }
39-.mode-legend-group { margin: 0 0.375em; min-width: 0; }
49+.mode-legend-columns { padding: 0 0.25em; }
50+.mode-legend-column { min-width: 0; }
51+.mode-legend-group { margin: 0 0.375em 0.5em 0.375em; min-width: 0; }
4052 .mode-legend-group-title {
4153 font-family: var(--font-mono);
4254 font-size: 0.6875em;
@@ -66,7 +78,7 @@ func generateModeLegendCSS() string {
6678 `)
6779 for _, m := range modeLegendColors {
6880 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); }
7082 .mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); }
7183 .mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); }
7284 .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 (
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/internal/ui/component"
1110 "github.com/bnema/dumber/internal/ui/input"
1211 )
1312
@@ -127,10 +126,8 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
127126 // Entering Vim Mode accents the active pane; leaving removes the accent.
128127 a.handleVimModeOwnership(ctx, bw, to, from)
129128
129+ // Retarget the border and reconcile the mode toaster (indicator + legend).
130130 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)
134131 }
135132
136133 // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
@@ -277,25 +274,6 @@ func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
277274 a.vimMode.resetPulse()
278275 }
279276
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-
299277 // bindVimModeSequenceToaster wires pending Vim-sequence text onto this
300278 // window's modeToaster only (never appToaster / pane toasters).
301279 func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
@@ -312,7 +290,7 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow)
312290
313291 // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
314292 // 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;
316294 // silent reset under ModalState avoids stale pending callbacks.
317295 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
318296 if a == nil || bw == nil {
@@ -323,15 +301,11 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
323301 // sub-mode, and the legend is suspended. Nothing to repaint.
324302 return
325303 }
326- if bw.modeFrame != nil {
327- cfg := a.runtimeConfigSnapshot().UI.Workspace
328- bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
329- }
330304 if bw.modeToaster == nil {
331305 return
332306 }
333307
334- a.showVimModeToast(ctx, bw, pending)
308+ bw.modeToaster.setPending(pending)
335309 logging.FromContext(ctx).Debug().
336310 Str("window_id", bw.id).
337311 Str("pending", pending).
@@ -339,22 +313,10 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
339313 }
340314
341315 // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
342-// per-window mode toaster, honoring the indicator toggle.
343-func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) {
316+// per-window mode toaster.
317+func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) {
344318 if bw == nil || bw.modeToaster == nil {
345319 return
346320 }
347- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
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- )
321+ bw.modeToaster.setDetail(detail)
360322 }
M internal/ui/vim_page.go +4 -4
@@ -28,8 +28,8 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
2828 }
2929 })
3030 a.showVimModeToast(ctx, bw, label)
31- if bw.modeFrame != nil {
32- bw.modeFrame.setSuspended(true)
31+ if bw.modeToaster != nil {
32+ bw.modeToaster.setSuspended(true)
3333 }
3434 logging.FromContext(ctx).Debug().
3535 Str("window_id", bw.id).
@@ -54,8 +54,8 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason
5454 }
5555 if stillVim {
5656 a.showVimModeToast(ctx, bw, "")
57- if bw.modeFrame != nil {
58- bw.modeFrame.setSuspended(false)
57+ if bw.modeToaster != nil {
58+ bw.modeToaster.setSuspended(false)
5959 }
6060 }
6161 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