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