feat(ui): merge mode indicator and legend into an expandable mode toaster
The bottom-left mode pill now owns the key legend. The legend expands above it as a responsive band (1-4 balanced columns, capped at 1100px), clicking the pill toggles it without stealing focus, and the collapsed state persists in workspace.styling.mode_legend_collapsed. modeFrame now only draws the mode border.
- commit
- 6e023867279a441bb3f034377e05e7ad5f14830f
- author
- brice · · 2026-10-14 07:23 UTC
- parent
- 78e62bd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
44 files · +2893 -1002
M .mockery.yaml +1 -0
| @@ -75,6 +75,7 @@ packages: | |||||
| 75 | KeybindingsProvider: {} | 75 | KeybindingsProvider: {} | ||
| 76 | KeybindingsSaver: {} | 76 | KeybindingsSaver: {} | ||
| 77 | OmniboxPreferencesSaver: {} | 77 | OmniboxPreferencesSaver: {} | ||
| 78 | + | ModeLegendPreferencesSaver: {} | |||
| 78 | DownloadResponse: {} | 79 | DownloadResponse: {} | ||
| 79 | PermissionDialogPresenter: {} | 80 | PermissionDialogPresenter: {} | ||
| 80 | PermissionRepository: {} | 81 | PermissionRepository: {} | ||
M CHANGELOG.md +1 -0
| @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. | |||||
| 15 | 15 | ||||
| 16 | ### Changed | 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 | - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style. | 19 | - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style. | ||
| 19 | - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker. | 20 | - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker. | ||
| 20 | - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration. | 21 | - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration. | ||
M docs/config/index.md +5 -3
| @@ -662,11 +662,12 @@ desc = "Open floating pane on GitHub" | |||||
| 662 | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | 662 | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | ||
| 663 | | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color | | 663 | | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color | | ||
| 664 | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | | 664 | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | | ||
| 665 | - | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active | | 665 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active. When `false` and `mode_legend` is not `off`, the header is still shown because the legend expands from it | |
| 666 | - | | `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend sits on the active mode border in that mode's color | | 666 | + | | `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend expands from the bottom-left mode toaster in that mode's color. `off` keeps only the toaster header, without the expand/collapse chevron | |
| 667 | - | | `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the legend when set to `delay` (>= 0) | | 667 | + | | `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the expanded legend when set to `delay` (>= 0) | |
| 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. | | 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. | | ||
| 669 | | `workspace.styling.mode_legend_animations` | bool | `true` | Animate the legend and its keycap feedback | | 669 | | `workspace.styling.mode_legend_animations` | bool | `true` | Animate the legend and its keycap feedback | | ||
| 670 | + | | `workspace.styling.mode_legend_collapsed` | bool | `false` | Collapse the legend to the mode toaster header. Clicking the toaster toggles it, applies immediately in every window, and is saved here for all modes | | |||
| 670 | | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms | | 671 | | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms | | ||
| 671 | 672 | ||||
| 672 | **Example:** | 673 | **Example:** | ||
| @@ -686,6 +687,7 @@ mode_legend = "delay" | |||||
| 686 | mode_legend_delay_ms = 500 | 687 | mode_legend_delay_ms = 500 | ||
| 687 | mode_legend_linger = true | 688 | mode_legend_linger = true | ||
| 688 | mode_legend_animations = true | 689 | mode_legend_animations = true | ||
| 690 | + | mode_legend_collapsed = false | |||
| 689 | transition_duration = 120 | 691 | transition_duration = 120 | ||
| 690 | ``` | 692 | ``` | ||
| 691 | 693 | ||||
M docs/reference/configuration.md +3 -2
| @@ -143,11 +143,12 @@ | |||||
| 143 | | `workspace.styling.session_mode_color` | string | `#9B59B6` | | | 143 | | `workspace.styling.session_mode_color` | string | `#9B59B6` | | | ||
| 144 | | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend | | 144 | | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend | | ||
| 145 | | `workspace.styling.resize_mode_color` | string | `#00D4AA` | | | 145 | | `workspace.styling.resize_mode_color` | string | `#00D4AA` | | | ||
| 146 | - | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active | | 146 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active (still shown when the legend is not `off`) | |
| 147 | - | | `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` | | 147 | + | | `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off`; `off` keeps only the toaster header | |
| 148 | | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` | | 148 | | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` | | ||
| 149 | | `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. | | 149 | | `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. | | ||
| 150 | | `workspace.styling.mode_legend_animations` | bool | `true` | | | 150 | | `workspace.styling.mode_legend_animations` | bool | `true` | | | ||
| 151 | + | | `workspace.styling.mode_legend_collapsed` | bool | `false` | Collapse the legend to the mode toaster header; toggled by clicking the toaster and saved for all modes | | |||
| 151 | | `workspace.styling.transition_duration` | int | `120` | | | 152 | | `workspace.styling.transition_duration` | int | `120` | | | ||
| 152 | | `session.auto_restore` | bool | `false` | | | 153 | | `session.auto_restore` | bool | `false` | | | ||
| 153 | | `session.snapshot_interval_ms` | int | `5000` | | | 154 | | `session.snapshot_interval_ms` | int | `5000` | | | ||
M docs/reference/keybindings.md +1 -1
| @@ -60,7 +60,7 @@ Default actions missing from your config are always active. Your own bindings ov | |||||
| 60 | 60 | ||||
| 61 | ## Vim Mode (`Ctrl+Y`) | 61 | ## Vim Mode (`Ctrl+Y`) | ||
| 62 | 62 | ||||
| 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` 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. |
| 64 | 64 | ||||
| 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. | 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. | ||
| 66 | 66 | ||||
M internal/application/port/handler_deps.go +1 -0
| @@ -46,6 +46,7 @@ type AutoCopyConfig interface { | |||||
| 46 | type HandlerDeps struct { | 46 | type HandlerDeps struct { | ||
| 47 | SaveConfig func(context.Context, dto.WebUIConfig) error | 47 | SaveConfig func(context.Context, dto.WebUIConfig) error | ||
| 48 | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error | 48 | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error | ||
| 49 | + | SaveModeLegendCollapsed func(context.Context, bool) error | |||
| 49 | KeybindingsGetter KeybindingsGetter | 50 | KeybindingsGetter KeybindingsGetter | ||
| 50 | KeybindingSetter KeybindingSetter | 51 | KeybindingSetter KeybindingSetter | ||
| 51 | KeybindingResetter KeybindingResetter | 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 | gateway := config.NewKeybindingsGateway(mgr) | 21 | gateway := config.NewKeybindingsGateway(mgr) | ||
| 22 | saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | 22 | saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | ||
| 23 | omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr)) | 23 | omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr)) | ||
| 24 | + | modeLegendSaveUC := usecase.NewSaveModeLegendCollapsedUseCase(config.NewModeLegendPreferencesGateway(mgr)) | |||
| 24 | 25 | ||||
| 25 | return &port.HandlerDeps{ | 26 | return &port.HandlerDeps{ | ||
| 26 | SaveConfig: saveUC.Execute, | 27 | SaveConfig: saveUC.Execute, | ||
| 27 | SaveOmniboxInitialBehavior: omniboxSaveUC.Execute, | 28 | SaveOmniboxInitialBehavior: omniboxSaveUC.Execute, | ||
| 29 | + | SaveModeLegendCollapsed: modeLegendSaveUC.Execute, | |||
| 28 | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), | 30 | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), | ||
| 29 | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), | 31 | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), | ||
| 30 | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), | 32 | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), | ||
M internal/domain/entity/config_types.go +1 -0
| @@ -194,6 +194,7 @@ type WorkspaceStylingConfig struct { | |||||
| 194 | ModeLegendDelayMs int `mapstructure:"mode_legend_delay_ms" yaml:"mode_legend_delay_ms" toml:"mode_legend_delay_ms" json:"mode_legend_delay_ms"` //nolint:lll // struct tags must stay on one line | 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 | 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 | 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 | 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 | 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 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | 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 | } | ||
M internal/infrastructure/config/defaults.go +1 -0
| @@ -445,6 +445,7 @@ func DefaultConfig() *Config { | |||||
| 445 | ModeLegend: "delay", | 445 | ModeLegend: "delay", | ||
| 446 | ModeLegendDelayMs: 500, | 446 | ModeLegendDelayMs: 500, | ||
| 447 | ModeLegendAnimations: true, | 447 | ModeLegendAnimations: true, | ||
| 448 | + | ModeLegendCollapsed: false, | |||
| 448 | TransitionDuration: defaultTransitionDuration, | 449 | TransitionDuration: defaultTransitionDuration, | ||
| 449 | }, | 450 | }, | ||
| 450 | }, | 451 | }, | ||
M internal/infrastructure/config/defaults_test.go +1 -0
| @@ -19,6 +19,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 19 | assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs) | 19 | assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs) | ||
| 20 | assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations) | 20 | assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations) | ||
| 21 | assert.True(t, cfg.Workspace.Styling.ModeLegendLinger) | 21 | assert.True(t, cfg.Workspace.Styling.ModeLegendLinger) | ||
| 22 | + | assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed) | |||
| 22 | assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor) | 23 | assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor) | ||
| 23 | 24 | ||||
| 24 | // Engine defaults (replaces old Performance/Privacy sections) | 25 | // Engine defaults (replaces old Performance/Privacy sections) | ||
M internal/infrastructure/config/loader.go +1 -0
| @@ -754,6 +754,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 754 | m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend) | 754 | m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend) | ||
| 755 | m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs) | 755 | m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs) | ||
| 756 | m.viper.SetDefault("workspace.styling.mode_legend_animations", defaults.Workspace.Styling.ModeLegendAnimations) | 756 | m.viper.SetDefault("workspace.styling.mode_legend_animations", defaults.Workspace.Styling.ModeLegendAnimations) | ||
| 757 | + | m.viper.SetDefault("workspace.styling.mode_legend_collapsed", defaults.Workspace.Styling.ModeLegendCollapsed) | |||
| 757 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | 758 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | ||
| 758 | } | 759 | } | ||
| 759 | 760 | ||||
M internal/infrastructure/config/loader_test.go +1 -0
| @@ -17,6 +17,7 @@ func TestModeLegendDefaultsThroughLoader(t *testing.T) { | |||||
| 17 | assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs) | 17 | assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs) | ||
| 18 | assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations) | 18 | assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations) | ||
| 19 | assert.True(t, cfg.Workspace.Styling.ModeLegendLinger) | 19 | assert.True(t, cfg.Workspace.Styling.ModeLegendLinger) | ||
| 20 | + | assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed) | |||
| 20 | assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor) | 21 | assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor) | ||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
A internal/infrastructure/config/mode_legend_preferences_gateway.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 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 | + | current := g.mgr.Get() | |||
| 23 | + | current.Workspace.Styling.ModeLegendCollapsed = collapsed | |||
| 24 | + | ||||
| 25 | + | return g.mgr.Save(current) | |||
| 26 | + | } | |||
A internal/infrastructure/config/mode_legend_preferences_gateway_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestModeLegendPreferencesGateway_SaveModeLegendCollapsed_Persists(t *testing.T) { | |||
| 11 | + | configHome := t.TempDir() | |||
| 12 | + | mgr := newTestOmniboxPreferencesGatewayManager(t, configHome) | |||
| 13 | + | gateway := NewModeLegendPreferencesGateway(mgr) | |||
| 14 | + | require.False(t, mgr.Get().Workspace.Styling.ModeLegendCollapsed) | |||
| 15 | + | ||||
| 16 | + | require.NoError(t, gateway.SaveModeLegendCollapsed(context.Background(), true)) | |||
| 17 | + | require.True(t, newTestOmniboxPreferencesGatewayManager(t, configHome).Get().Workspace.Styling.ModeLegendCollapsed) | |||
| 18 | + | ||||
| 19 | + | require.NoError(t, gateway.SaveModeLegendCollapsed(context.Background(), false)) | |||
| 20 | + | require.False(t, newTestOmniboxPreferencesGatewayManager(t, configHome).Get().Workspace.Styling.ModeLegendCollapsed) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestModeLegendPreferencesGateway_NilManagerReturnsError(t *testing.T) { | |||
| 24 | + | require.Error(t, NewModeLegendPreferencesGateway(nil).SaveModeLegendCollapsed(context.Background(), true)) | |||
| 25 | + | } | |||
M internal/infrastructure/config/schema_provider.go +8 -1
| @@ -740,7 +740,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 740 | Key: "workspace.styling.mode_indicator_toaster_enabled", | 740 | Key: "workspace.styling.mode_indicator_toaster_enabled", | ||
| 741 | Type: "bool", | 741 | Type: "bool", | ||
| 742 | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled), | 742 | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled), | ||
| 743 | - | Description: "Show the mode toast while a mode is active", | 743 | + | Description: "Show the bottom-left mode toaster while a mode is active (kept as the legend header while the legend is not off)", |
| 744 | Section: SectionWorkspace, | 744 | Section: SectionWorkspace, | ||
| 745 | }, | 745 | }, | ||
| 746 | { | 746 | { | ||
| @@ -771,6 +771,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 771 | Description: "Animate the mode legend and pressed keycaps", | 771 | Description: "Animate the mode legend and pressed keycaps", | ||
| 772 | Section: SectionWorkspace, | 772 | Section: SectionWorkspace, | ||
| 773 | }, | 773 | }, | ||
| 774 | + | { | |||
| 775 | + | Key: "workspace.styling.mode_legend_collapsed", | |||
| 776 | + | Type: "bool", | |||
| 777 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendCollapsed), | |||
| 778 | + | Description: "Collapse the mode legend to its header; toggled by clicking the mode toaster and shared by all modes and windows", | |||
| 779 | + | Section: SectionWorkspace, | |||
| 780 | + | }, | |||
| 774 | { | 781 | { | ||
| 775 | Key: "workspace.styling.transition_duration", | 782 | Key: "workspace.styling.transition_duration", | ||
| 776 | Type: "int", | 783 | Type: "int", | ||
M internal/infrastructure/config/schema_provider_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestModeLegendSchemaDefaults(t *testing.T) { | |||||
| 25 | {"workspace.styling.mode_legend_linger", "bool", "true", ""}, | 25 | {"workspace.styling.mode_legend_linger", "bool", "true", ""}, | ||
| 26 | {"workspace.styling.vim_mode_color", "string", "#F7768E", ""}, | 26 | {"workspace.styling.vim_mode_color", "string", "#F7768E", ""}, | ||
| 27 | {"workspace.styling.mode_legend_animations", "bool", "true", ""}, | 27 | {"workspace.styling.mode_legend_animations", "bool", "true", ""}, | ||
| 28 | + | {"workspace.styling.mode_legend_collapsed", "bool", "false", ""}, | |||
| 28 | } { | 29 | } { | ||
| 29 | entry, ok := keys[tc.key] | 30 | entry, ok := keys[tc.key] | ||
| 30 | require.True(t, ok, "missing schema key %s", tc.key) | 31 | require.True(t, ok, "missing schema key %s", tc.key) | ||
M internal/ui/app.go +2 -21
| @@ -126,6 +126,7 @@ type App struct { | |||||
| 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | ||
| 127 | vimNavigationUC *usecase.VimNavigationUseCase | 127 | vimNavigationUC *usecase.VimNavigationUseCase | ||
| 128 | vimMode vimModeState | 128 | vimMode vimModeState | ||
| 129 | + | modeLegend modeLegendPreference | |||
| 129 | 130 | ||||
| 130 | // Omnibox configuration (omnibox is created per workspace view) | 131 | // Omnibox configuration (omnibox is created per workspace view) | ||
| 131 | omniboxCfg component.OmniboxConfig | 132 | omniboxCfg component.OmniboxConfig | ||
| @@ -3929,26 +3930,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||||
| 3929 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | 3930 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | ||
| 3930 | } | 3931 | } | ||
| 3931 | 3932 | ||||
| 3932 | - | // getModeToastClass returns the CSS class for the given mode's toast styling. | |||
| 3933 | - | func getModeToastClass(mode input.Mode) string { | |||
| 3934 | - | switch mode { | |||
| 3935 | - | case input.ModePane: | |||
| 3936 | - | return "toast-pane-mode" | |||
| 3937 | - | case input.ModeVim: | |||
| 3938 | - | return "toast-vim-mode" | |||
| 3939 | - | case input.ModeTab: | |||
| 3940 | - | return "toast-tab-mode" | |||
| 3941 | - | case input.ModeSession: | |||
| 3942 | - | return "toast-session-mode" | |||
| 3943 | - | case input.ModeResize: | |||
| 3944 | - | return "toast-resize-mode" | |||
| 3945 | - | case input.ModePrefix: | |||
| 3946 | - | return "toast-prefix-mode" | |||
| 3947 | - | default: | |||
| 3948 | - | return "" | |||
| 3949 | - | } | |||
| 3950 | - | } | |||
| 3951 | - | ||||
| 3952 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | 3933 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | ||
| 3953 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | 3934 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | ||
| 3954 | if !a.createWorkspaceViewWithoutAttach(ctx, tab) { | 3935 | if !a.createWorkspaceViewWithoutAttach(ctx, tab) { | ||
| @@ -4345,6 +4326,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||||
| 4345 | a.runtimeConfig = newRuntimeConfigState(nil) | 4326 | a.runtimeConfig = newRuntimeConfigState(nil) | ||
| 4346 | } | 4327 | } | ||
| 4347 | a.runtimeConfig.Update(snapshot) | 4328 | a.runtimeConfig.Update(snapshot) | ||
| 4329 | + | a.modeLegend.configReloaded(snapshot.UI.Workspace.Styling.ModeLegendCollapsed) | |||
| 4348 | if a.contentCoord != nil { | 4330 | if a.contentCoord != nil { | ||
| 4349 | a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts) | 4331 | a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts) | ||
| 4350 | } | 4332 | } | ||
| @@ -4385,7 +4367,6 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||||
| 4385 | mode = bw.keyboardHandler.Mode() | 4367 | mode = bw.keyboardHandler.Mode() | ||
| 4386 | } | 4368 | } | ||
| 4387 | a.updateModeFrame(bw, mode) | 4369 | a.updateModeFrame(bw, mode) | ||
| 4388 | - | a.updateModeIndicatorToaster(ctx, bw, mode) | |||
| 4389 | } | 4370 | } | ||
| 4390 | } | 4371 | } | ||
| 4391 | 4372 | ||||
M internal/ui/app_clipboard_test.go +3 -0
| @@ -38,6 +38,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||||
| 38 | HandlerDeps: port.HandlerDeps{ | 38 | HandlerDeps: port.HandlerDeps{ | ||
| 39 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | 39 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | ||
| 40 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | 40 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | ||
| 41 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { return nil }, | |||
| 41 | }, | 42 | }, | ||
| 42 | Clipboard: nil, | 43 | Clipboard: nil, | ||
| 43 | } | 44 | } | ||
| @@ -66,6 +67,7 @@ func TestNew_ClosesHistoryRecorderWhenRegisterHandlersFails(t *testing.T) { | |||||
| 66 | HandlerDeps: port.HandlerDeps{ | 67 | HandlerDeps: port.HandlerDeps{ | ||
| 67 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | 68 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | ||
| 68 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | 69 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | ||
| 70 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { return nil }, | |||
| 69 | }, | 71 | }, | ||
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| @@ -101,6 +103,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |||||
| 101 | HandlerDeps: port.HandlerDeps{ | 103 | HandlerDeps: port.HandlerDeps{ | ||
| 102 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | 104 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | ||
| 103 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | 105 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | ||
| 106 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { return nil }, | |||
| 104 | }, | 107 | }, | ||
| 105 | Clipboard: clipboard, | 108 | Clipboard: clipboard, | ||
| 106 | } | 109 | } | ||
M internal/ui/app_mode_frame.go +127 -9
| @@ -1,6 +1,10 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |||
| 4 | "github.com/bnema/dumber/internal/ui/input" | 8 | "github.com/bnema/dumber/internal/ui/input" | ||
| 5 | "github.com/bnema/dumber/internal/ui/layout" | 9 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 6 | ) | 10 | ) | ||
| @@ -33,38 +37,152 @@ func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) { | |||||
| 33 | return | 37 | return | ||
| 34 | } | 38 | } | ||
| 35 | a.setModeFrameTab(bw) | 39 | a.setModeFrameTab(bw) | ||
| 36 | - | newTarget := a.modeFrameTarget(bw, mode) | |||
| 37 | // Rebuilds can temporarily lack a pane widget. Drop the old border while | 40 | // Rebuilds can temporarily lack a pane widget. Drop the old border while | ||
| 38 | // the next focus/rebuild event retries the target. | 41 | // the next focus/rebuild event retries the target. | ||
| 39 | - | bw.modeFrame.setTarget(newTarget, modeFrameClass(mode)) | 42 | + | bw.modeFrame.setTarget(a.modeFrameTarget(bw, mode), modeFrameClass(mode)) |
| 40 | - | if bw.modeFrame.visible { | 43 | + | if bw.modeFrame.target != nil { |
| 41 | bw.modeFrame.refreshGeometry() | 44 | bw.modeFrame.refreshGeometry() | ||
| 42 | } | 45 | } | ||
| 43 | } | 46 | } | ||
| 44 | 47 | ||||
| 48 | + | // updateModeFrame reconciles the window's mode border and mode toaster with | |||
| 49 | + | // mode and the live configuration. | |||
| 45 | func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) { | 50 | func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) { | ||
| 46 | - | if bw == nil || bw.modeFrame == nil { | 51 | + | if bw == nil { |
| 47 | return | 52 | return | ||
| 48 | } | 53 | } | ||
| 49 | a.setModeFrameTab(bw) | 54 | a.setModeFrameTab(bw) | ||
| 50 | cfg := a.runtimeConfigSnapshot().UI | 55 | cfg := a.runtimeConfigSnapshot().UI | ||
| 51 | - | bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling, | 56 | + | if bw.modeFrame != nil { |
| 52 | - | cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session)) | 57 | + | bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode)) |
| 58 | + | } | |||
| 59 | + | if bw.modeToaster != nil { | |||
| 60 | + | styling := cfg.Workspace.Styling | |||
| 61 | + | styling.ModeLegendCollapsed = a.modeLegendCollapsed() | |||
| 62 | + | bw.modeToaster.apply(mode, styling, cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session)) | |||
| 63 | + | } | |||
| 53 | } | 64 | } | ||
| 54 | 65 | ||||
| 55 | func (a *App) setModeFrameTab(bw *browserWindow) { | 66 | func (a *App) setModeFrameTab(bw *browserWindow) { | ||
| 67 | + | if bw.modeFrame == nil { | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 56 | tabID := bw.modeFrame.tabID | 70 | tabID := bw.modeFrame.tabID | ||
| 57 | bw.modeFrame.tabID = "" | 71 | bw.modeFrame.tabID = "" | ||
| 58 | if tab := a.activeTabForBrowserWindow(bw); tab != nil { | 72 | if tab := a.activeTabForBrowserWindow(bw); tab != nil { | ||
| 59 | bw.modeFrame.tabID = tab.ID | 73 | bw.modeFrame.tabID = tab.ID | ||
| 60 | } | 74 | } | ||
| 61 | if tabID != bw.modeFrame.tabID { | 75 | if tabID != bw.modeFrame.tabID { | ||
| 62 | - | bw.modeFrame.dismissLinger() | 76 | + | bw.modeToaster.dismissLinger() |
| 63 | } | 77 | } | ||
| 64 | } | 78 | } | ||
| 65 | 79 | ||||
| 66 | func (a *App) modeFrameAction(bw *browserWindow, action input.Action) { | 80 | func (a *App) modeFrameAction(bw *browserWindow, action input.Action) { | ||
| 67 | - | if bw != nil && bw.modeFrame != nil { | 81 | + | if bw != nil && bw.modeToaster != nil { |
| 68 | - | bw.modeFrame.flash(action) | 82 | + | bw.modeToaster.flash(action) |
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // modeLegendPreference is the app-wide "legend collapsed" state, shared by | |||
| 87 | + | // every mode and window. A click is applied at once and saved in the | |||
| 88 | + | // background. It keeps priority over the configured value, which may still be | |||
| 89 | + | // the previous one while the save is in flight, until a configuration reload | |||
| 90 | + | // 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 | + | idle sync.WaitGroup | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // current returns the state to show, given the configured one. | |||
| 104 | + | func (p *modeLegendPreference) current(configured bool) bool { | |||
| 105 | + | p.mu.Lock() | |||
| 106 | + | defer p.mu.Unlock() | |||
| 107 | + | if p.clicked { | |||
| 108 | + | return p.collapsed | |||
| 109 | + | } | |||
| 110 | + | return configured | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // click records a click and returns whether a writer must be started. | |||
| 114 | + | func (p *modeLegendPreference) click(collapsed bool) (startWriter bool) { | |||
| 115 | + | p.mu.Lock() | |||
| 116 | + | defer p.mu.Unlock() | |||
| 117 | + | p.clicked, p.collapsed = true, collapsed | |||
| 118 | + | p.pending = &collapsed | |||
| 119 | + | if p.saving { | |||
| 120 | + | return false | |||
| 121 | + | } | |||
| 122 | + | p.saving = true | |||
| 123 | + | p.idle.Add(1) | |||
| 124 | + | return true | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // next hands the writer its next value, or ends it when none is left. | |||
| 128 | + | func (p *modeLegendPreference) next() (collapsed, ok bool) { | |||
| 129 | + | p.mu.Lock() | |||
| 130 | + | defer p.mu.Unlock() | |||
| 131 | + | if p.pending == nil { | |||
| 132 | + | p.saving = false | |||
| 133 | + | p.idle.Done() | |||
| 134 | + | return false, false | |||
| 135 | + | } | |||
| 136 | + | collapsed, p.pending = *p.pending, nil | |||
| 137 | + | return collapsed, true | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // configReloaded hands the state back to the configuration when a reload | |||
| 141 | + | // agrees with the last click (it is that click's own save), or when no save is | |||
| 142 | + | // in flight (an external edit, or a save that failed). | |||
| 143 | + | func (p *modeLegendPreference) configReloaded(configured bool) { | |||
| 144 | + | p.mu.Lock() | |||
| 145 | + | defer p.mu.Unlock() | |||
| 146 | + | if !p.saving || configured == p.collapsed { | |||
| 147 | + | p.clicked = false | |||
| 69 | } | 148 | } | ||
| 70 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | // modeLegendCollapsed returns the state new windows and mode changes start from. | |||
| 152 | + | func (a *App) modeLegendCollapsed() bool { | |||
| 153 | + | return a.modeLegend.current(a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeLegendCollapsed) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // setModeLegendCollapsed applies a click on a mode toaster: it updates every | |||
| 157 | + | // open window at once, then saves the choice off the GTK thread. A failed save | |||
| 158 | + | // is logged; the state stays applied until the next configuration reload. | |||
| 159 | + | func (a *App) setModeLegendCollapsed(ctx context.Context, collapsed bool) { | |||
| 160 | + | for _, bw := range a.browserWindows { | |||
| 161 | + | if bw != nil && bw.modeToaster != nil { | |||
| 162 | + | bw.modeToaster.setCollapsed(collapsed) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | start := a.modeLegend.click(collapsed) | |||
| 166 | + | if !start { | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | var save func(context.Context, bool) error | |||
| 170 | + | if a.deps != nil { | |||
| 171 | + | save = a.deps.HandlerDeps.SaveModeLegendCollapsed | |||
| 172 | + | } | |||
| 173 | + | go func() { | |||
| 174 | + | for { | |||
| 175 | + | next, ok := a.modeLegend.next() | |||
| 176 | + | if !ok { | |||
| 177 | + | return | |||
| 178 | + | } | |||
| 179 | + | if save == nil { | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | if err := save(ctx, next); err != nil { | |||
| 183 | + | logging.FromContext(ctx).Warn().Err(err).Bool("collapsed", next). | |||
| 184 | + | Msg("failed to save mode legend collapsed preference") | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | }() | |||
| 188 | + | } | |||
M internal/ui/app_vim_mode_test.go +66 -121
| @@ -1019,58 +1019,24 @@ func TestVimMode_CSSClassConstants(t *testing.T) { | |||||
| 1019 | // Mode toaster | 1019 | // Mode toaster | ||
| 1020 | // ============================================================================ | 1020 | // ============================================================================ | ||
| 1021 | 1021 | ||||
| 1022 | - | func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) { | 1022 | + | func enabledIndicatorSnapshot() entity.RuntimeConfigSnapshot { |
| 1023 | - | t.Helper() | 1023 | + | return entity.RuntimeConfigSnapshot{ |
| 1024 | - | 1024 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |
| 1025 | - | container := mocks.NewMockBoxWidget(t) | 1025 | + | Styling: entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"}, |
| 1026 | - | label := mocks.NewMockLabelWidget(t) | 1026 | + | }}, |
| 1027 | - | factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once() | 1027 | + | } |
| 1028 | - | container.EXPECT().AddCssClass("toast").Once() | |||
| 1029 | - | container.EXPECT().AddCssClass("toast-info").Once() | |||
| 1030 | - | container.EXPECT().SetHalign(mock.Anything).Once() | |||
| 1031 | - | container.EXPECT().SetValign(mock.Anything).Once() | |||
| 1032 | - | container.EXPECT().SetHexpand(false).Once() | |||
| 1033 | - | container.EXPECT().SetVexpand(false).Once() | |||
| 1034 | - | container.EXPECT().SetCanTarget(false).Once() | |||
| 1035 | - | container.EXPECT().SetCanFocus(false).Once() | |||
| 1036 | - | container.EXPECT().SetVisible(false).Once() | |||
| 1037 | - | factory.EXPECT().NewLabel("").Return(label).Once() | |||
| 1038 | - | label.EXPECT().SetCanTarget(false).Once() | |||
| 1039 | - | label.EXPECT().SetCanFocus(false).Once() | |||
| 1040 | - | container.EXPECT().Append(label).Once() | |||
| 1041 | - | ||||
| 1042 | - | return component.NewToaster(factory), container, label | |||
| 1043 | - | } | |||
| 1044 | - | ||||
| 1045 | - | func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) { | |||
| 1046 | - | container.EXPECT().RemoveCssClass("toast-info").Once() | |||
| 1047 | - | container.EXPECT().AddCssClass("toast-vim-mode").Once() | |||
| 1048 | - | container.EXPECT().SetHalign(mock.Anything).Once() | |||
| 1049 | - | container.EXPECT().SetValign(mock.Anything).Once() | |||
| 1050 | - | label.EXPECT().SetText("VIM MODE").Once() | |||
| 1051 | - | container.EXPECT().SetVisible(true).Once() | |||
| 1052 | } | 1028 | } | ||
| 1053 | 1029 | ||||
| 1054 | func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) { | 1030 | func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) { | ||
| 1055 | - | factory := mocks.NewMockWidgetFactory(t) | 1031 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())} |
| 1056 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | 1032 | + | bw, view := newFakeModeToasterWindow("win-1") |
| 1057 | - | expectVimModeToastShown(container, label) | |||
| 1058 | - | // These optional calls are made only by the old brief auto-dismiss path. | |||
| 1059 | - | container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe() | |||
| 1060 | - | container.EXPECT().SetVisible(false).Maybe() | |||
| 1061 | - | ||||
| 1062 | - | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 1063 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 1064 | - | ModeIndicatorToasterEnabled: true, | |||
| 1065 | - | }}}, | |||
| 1066 | - | })} | |||
| 1067 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |||
| 1068 | app.browserWindows = map[string]*browserWindow{bw.id: bw} | 1033 | app.browserWindows = map[string]*browserWindow{bw.id: bw} | ||
| 1069 | - | app.lastFocusedWindowID = bw.id | |||
| 1070 | 1034 | ||||
| 1071 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | 1035 | + | app.updateModeFrame(bw, input.ModeVim) |
| 1072 | - | require.True(t, toaster.IsVisible()) | 1036 | + | require.True(t, view.pillShown) |
| 1037 | + | require.Equal(t, "VIM MODE", view.header) | |||
| 1073 | 1038 | ||||
| 1039 | + | // Nothing but a mode change hides it: no timer dismisses the persistent pill. | |||
| 1074 | deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond) | 1040 | deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond) | ||
| 1075 | mainContext := glib.MainContextDefault() | 1041 | mainContext := glib.MainContextDefault() | ||
| 1076 | for time.Now().Before(deadline) { | 1042 | for time.Now().Before(deadline) { | ||
| @@ -1079,116 +1045,95 @@ func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) { | |||||
| 1079 | mainContext.Iteration(false) | 1045 | mainContext.Iteration(false) | ||
| 1080 | } | 1046 | } | ||
| 1081 | } | 1047 | } | ||
| 1082 | - | 1048 | + | assert.True(t, view.pillShown, "Vim Mode pill must remain visible until mode exit") | |
| 1083 | - | assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit") | |||
| 1084 | } | 1049 | } | ||
| 1085 | 1050 | ||||
| 1086 | func TestVimMode_ModeExitHidesToaster(t *testing.T) { | 1051 | func TestVimMode_ModeExitHidesToaster(t *testing.T) { | ||
| 1087 | - | factory := mocks.NewMockWidgetFactory(t) | 1052 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())} |
| 1088 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | 1053 | + | bw, view := newFakeModeToasterWindow("win-1") |
| 1089 | - | expectVimModeToastShown(container, label) | 1054 | + | |
| 1090 | - | container.EXPECT().RemoveCssClass("toast-custom").Once() | 1055 | + | app.updateModeFrame(bw, input.ModeVim) |
| 1091 | - | container.EXPECT().RemoveCssClass("toast-vim-mode").Once() | 1056 | + | require.True(t, view.pillShown) |
| 1092 | - | container.EXPECT().SetVisible(false).Once() | 1057 | + | app.updateModeFrame(bw, input.ModeNormal) |
| 1093 | 1058 | ||||
| 1059 | + | assert.False(t, view.pillShown, "leaving Vim Mode must hide its persistent pill") | |||
| 1060 | + | } | |||
| 1061 | + | ||||
| 1062 | + | func TestVimMode_DisabledIndicatorWithLegendOffShowsNothing(t *testing.T) { | |||
| 1094 | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | 1063 | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | ||
| 1095 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | 1064 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ |
| 1096 | - | ModeIndicatorToasterEnabled: true, | 1065 | + | Styling: entity.WorkspaceStylingConfig{ModeLegend: "off"}, |
| 1097 | - | }}}, | 1066 | + | }}, |
| 1098 | })} | 1067 | })} | ||
| 1099 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | 1068 | + | bw, view := newFakeModeToasterWindow("win-1") |
| 1100 | 1069 | ||||
| 1101 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | 1070 | + | app.updateModeFrame(bw, input.ModeVim) |
| 1102 | - | require.True(t, toaster.IsVisible()) | |||
| 1103 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal) | |||
| 1104 | 1071 | ||||
| 1105 | - | assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast") | 1072 | + | assert.False(t, view.pillShown) |
| 1073 | + | assert.False(t, view.bodyShown) | |||
| 1106 | } | 1074 | } | ||
| 1107 | 1075 | ||||
| 1108 | - | func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) { | 1076 | + | func TestVimMode_DisabledIndicatorKeepsPillAsLegendHeader(t *testing.T) { |
| 1109 | - | factory := mocks.NewMockWidgetFactory(t) | 1077 | + | snapshot := entity.RuntimeConfigSnapshot{ |
| 1110 | - | toaster, _, _ := newModeToasterForVimModeTest(t, factory) | 1078 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ |
| 1111 | - | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})} | 1079 | + | Styling: entity.WorkspaceStylingConfig{ModeLegend: "always"}, |
| 1112 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | 1080 | + | VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}}, |
| 1081 | + | }}, | |||
| 1082 | + | } | |||
| 1083 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)} | |||
| 1084 | + | bw, view := newFakeModeToasterWindow("win-1") | |||
| 1113 | 1085 | ||||
| 1114 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | 1086 | + | app.updateModeFrame(bw, input.ModeVim) |
| 1115 | 1087 | ||||
| 1116 | - | assert.False(t, toaster.IsVisible()) | 1088 | + | assert.True(t, view.pillShown, "the pill is the legend header even when the indicator is disabled") |
| 1089 | + | assert.True(t, view.bodyShown) | |||
| 1117 | } | 1090 | } | ||
| 1118 | 1091 | ||||
| 1119 | func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) { | 1092 | func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) { | ||
| 1120 | - | factory := mocks.NewMockWidgetFactory(t) | 1093 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())} |
| 1121 | - | toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory) | 1094 | + | bw1, view1 := newFakeModeToasterWindow("win-1") |
| 1122 | - | toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory) | 1095 | + | bw2, view2 := newFakeModeToasterWindow("win-2") |
| 1123 | - | expectVimModeToastShown(container1, label1) | |||
| 1124 | - | expectVimModeToastShown(container2, label2) | |||
| 1125 | - | container1.EXPECT().RemoveCssClass("toast-custom").Once() | |||
| 1126 | - | container1.EXPECT().RemoveCssClass("toast-vim-mode").Once() | |||
| 1127 | - | container1.EXPECT().SetVisible(false).Once() | |||
| 1128 | - | ||||
| 1129 | - | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |||
| 1130 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 1131 | - | ModeIndicatorToasterEnabled: true, | |||
| 1132 | - | }}}, | |||
| 1133 | - | })} | |||
| 1134 | - | bw1 := &browserWindow{id: "win-1", modeToaster: toaster1} | |||
| 1135 | - | bw2 := &browserWindow{id: "win-2", modeToaster: toaster2} | |||
| 1136 | 1096 | ||||
| 1137 | - | app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim) | 1097 | + | app.updateModeFrame(bw1, input.ModeVim) |
| 1138 | - | app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim) | 1098 | + | app.updateModeFrame(bw2, input.ModeVim) |
| 1139 | - | app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal) | 1099 | + | app.updateModeFrame(bw1, input.ModeNormal) |
| 1140 | 1100 | ||||
| 1141 | - | assert.False(t, toaster1.IsVisible()) | 1101 | + | assert.False(t, view1.pillShown) |
| 1142 | - | assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast") | 1102 | + | assert.True(t, view2.pillShown, "exiting one window must not hide another window's pill") |
| 1143 | } | 1103 | } | ||
| 1144 | 1104 | ||||
| 1145 | func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) { | 1105 | func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) { | ||
| 1146 | - | factory := mocks.NewMockWidgetFactory(t) | |||
| 1147 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |||
| 1148 | - | expectVimModeToastShown(container, label) | |||
| 1149 | - | container.EXPECT().RemoveCssClass("toast-custom").Once() | |||
| 1150 | - | container.EXPECT().RemoveCssClass("toast-vim-mode").Once() | |||
| 1151 | - | container.EXPECT().SetVisible(false).Once() | |||
| 1152 | - | ||||
| 1153 | - | enabled := entity.RuntimeConfigSnapshot{ | |||
| 1154 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 1155 | - | ModeIndicatorToasterEnabled: true, | |||
| 1156 | - | }}}, | |||
| 1157 | - | } | |||
| 1158 | keyboardHandler := newKeyboardHandlerInVimMode(t) | 1106 | keyboardHandler := newKeyboardHandlerInVimMode(t) | ||
| 1159 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler} | 1107 | + | bw, view := newFakeModeToasterWindow("win-1") |
| 1108 | + | bw.keyboardHandler = keyboardHandler | |||
| 1160 | app := &App{ | 1109 | app := &App{ | ||
| 1161 | - | runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled), | 1110 | + | runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot()), |
| 1162 | browserWindows: map[string]*browserWindow{bw.id: bw}, | 1111 | browserWindows: map[string]*browserWindow{bw.id: bw}, | ||
| 1163 | } | 1112 | } | ||
| 1164 | 1113 | ||||
| 1165 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | 1114 | + | app.updateModeFrame(bw, input.ModeVim) |
| 1166 | - | require.True(t, toaster.IsVisible()) | 1115 | + | require.True(t, view.pillShown) |
| 1167 | - | app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{}) | 1116 | + | disabled := enabledIndicatorSnapshot() |
| 1117 | + | disabled.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false | |||
| 1118 | + | app.applyRuntimeConfigChange(context.Background(), disabled) | |||
| 1168 | 1119 | ||||
| 1169 | - | assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast") | 1120 | + | assert.False(t, view.pillShown, "runtime preference disable must reconcile an existing pill") |
| 1170 | } | 1121 | } | ||
| 1171 | 1122 | ||||
| 1172 | func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) { | 1123 | func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) { | ||
| 1173 | - | factory := mocks.NewMockWidgetFactory(t) | |||
| 1174 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |||
| 1175 | - | expectVimModeToastShown(container, label) | |||
| 1176 | - | ||||
| 1177 | keyboardHandler := newKeyboardHandlerInVimMode(t) | 1124 | keyboardHandler := newKeyboardHandlerInVimMode(t) | ||
| 1178 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler} | 1125 | + | bw, view := newFakeModeToasterWindow("win-1") |
| 1126 | + | bw.keyboardHandler = keyboardHandler | |||
| 1127 | + | off := enabledIndicatorSnapshot() | |||
| 1128 | + | off.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false | |||
| 1179 | app := &App{ | 1129 | app := &App{ | ||
| 1180 | - | runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}), | 1130 | + | runtimeConfig: runtimeConfigStateFromSnapshotForTest(off), |
| 1181 | browserWindows: map[string]*browserWindow{bw.id: bw}, | 1131 | browserWindows: map[string]*browserWindow{bw.id: bw}, | ||
| 1182 | } | 1132 | } | ||
| 1183 | - | enabled := entity.RuntimeConfigSnapshot{ | |||
| 1184 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |||
| 1185 | - | ModeIndicatorToasterEnabled: true, | |||
| 1186 | - | }}}, | |||
| 1187 | - | } | |||
| 1188 | 1133 | ||||
| 1189 | - | app.applyRuntimeConfigChange(context.Background(), enabled) | 1134 | + | app.applyRuntimeConfigChange(context.Background(), enabledIndicatorSnapshot()) |
| 1190 | 1135 | ||||
| 1191 | - | assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode") | 1136 | + | assert.True(t, view.pillShown, "runtime preference enable must reconcile active Vim Mode") |
| 1192 | } | 1137 | } | ||
| 1193 | 1138 | ||||
| 1194 | // ============================================================================ | 1139 | // ============================================================================ | ||
M internal/ui/app_vim_page_test.go +14 -25
| @@ -5,41 +5,31 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | - | "github.com/stretchr/testify/mock" | |||
| 9 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 10 | 9 | ||||
| 11 | "github.com/bnema/dumber/internal/application/dto" | 10 | "github.com/bnema/dumber/internal/application/dto" | ||
| 12 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | "github.com/bnema/dumber/internal/ui/input" | 13 | "github.com/bnema/dumber/internal/ui/input" | ||
| 15 | - | "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 16 | ) | 14 | ) | ||
| 17 | 15 | ||||
| 18 | - | // newVimPageTestWindow builds a window in Vim Mode with a real mode toaster | 16 | + | // newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound |
| 19 | - | // bound to the pending-sequence callback, as initChrome does. It records every | 17 | + | // to the pending-sequence callback, as initChrome does. It records every header |
| 20 | - | // text shown on the toast so tests can assert what the user sees. | 18 | + | // the pill shows so tests can assert what the user sees. |
| 21 | func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) { | 19 | func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) { | ||
| 22 | t.Helper() | 20 | t.Helper() | ||
| 23 | - | factory := mocks.NewMockWidgetFactory(t) | |||
| 24 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |||
| 25 | - | shown := &[]string{} | |||
| 26 | - | container.EXPECT().RemoveCssClass(mock.Anything).Maybe() | |||
| 27 | - | container.EXPECT().AddCssClass(mock.Anything).Maybe() | |||
| 28 | - | container.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 29 | - | container.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 30 | - | container.EXPECT().SetVisible(mock.Anything).Maybe() | |||
| 31 | - | label.EXPECT().SetText(mock.Anything).Run(func(text string) { *shown = append(*shown, text) }).Maybe() | |||
| 32 | - | ||||
| 33 | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | 21 | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | ||
| 34 | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | 22 | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | ||
| 35 | ModeIndicatorToasterEnabled: true, | 23 | ModeIndicatorToasterEnabled: true, | ||
| 24 | + | ModeLegend: "off", | |||
| 36 | }}}, | 25 | }}}, | ||
| 37 | })} | 26 | })} | ||
| 38 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | 27 | + | bw, view := newFakeModeToasterWindow("win-1") |
| 39 | app.browserWindows = map[string]*browserWindow{bw.id: bw} | 28 | app.browserWindows = map[string]*browserWindow{bw.id: bw} | ||
| 40 | bw.keyboardHandler = newKeyboardHandlerInVimMode(t) | 29 | bw.keyboardHandler = newKeyboardHandlerInVimMode(t) | ||
| 41 | app.bindVimModeSequenceToaster(context.Background(), bw) | 30 | app.bindVimModeSequenceToaster(context.Background(), bw) | ||
| 42 | - | return app, bw, shown | 31 | + | app.updateModeFrame(bw, input.ModeVim) |
| 32 | + | return app, bw, &view.headers | |||
| 43 | } | 33 | } | ||
| 44 | 34 | ||||
| 45 | var visualOutcome = hintsOutcome(dto.VimPageVisual) | 35 | var visualOutcome = hintsOutcome(dto.VimPageVisual) | ||
| @@ -83,16 +73,15 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) { | |||||
| 83 | // restored when the interaction ends. | 73 | // restored when the interaction ends. | ||
| 84 | func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | 74 | func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | ||
| 85 | app, bw, _ := newVimPageTestWindow(t) | 75 | app, bw, _ := newVimPageTestWindow(t) | ||
| 86 | - | frame := &modeFrame{mode: input.ModeVim, visible: true} | 76 | + | toaster := bw.modeToaster |
| 87 | - | bw.modeFrame = frame | |||
| 88 | wv := portmocks.NewMockWebView(t) | 77 | wv := portmocks.NewMockWebView(t) | ||
| 89 | 78 | ||||
| 90 | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | 79 | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | ||
| 91 | - | require.True(t, frame.suspended, "legend must be suspended while the page captures keys") | 80 | + | require.True(t, toaster.suspended, "legend must be suspended while the page captures keys") |
| 92 | 81 | ||||
| 93 | wv.EXPECT().ID().Return(1).Maybe() | 82 | wv.EXPECT().ID().Return(1).Maybe() | ||
| 94 | app.endVimPageInteraction(context.Background(), bw, true) | 83 | app.endVimPageInteraction(context.Background(), bw, true) | ||
| 95 | - | require.False(t, frame.suspended, "legend must be restored when the interaction ends") | 84 | + | require.False(t, toaster.suspended, "legend must be restored when the interaction ends") |
| 96 | require.False(t, bw.keyboardHandler.PageKeyCaptureActive()) | 85 | require.False(t, bw.keyboardHandler.PageKeyCaptureActive()) | ||
| 97 | } | 86 | } | ||
| 98 | 87 | ||||
| @@ -101,8 +90,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |||||
| 101 | // suspended legend stay in place. | 90 | // suspended legend stay in place. | ||
| 102 | func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | 91 | func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | ||
| 103 | app, bw, shown := newVimPageTestWindow(t) | 92 | app, bw, shown := newVimPageTestWindow(t) | ||
| 104 | - | frame := &modeFrame{mode: input.ModeVim, visible: true} | 93 | + | toaster := bw.modeToaster |
| 105 | - | bw.modeFrame = frame | |||
| 106 | wv := portmocks.NewMockWebView(t) | 94 | wv := portmocks.NewMockWebView(t) | ||
| 107 | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | 95 | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | ||
| 108 | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | 96 | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | ||
| @@ -117,16 +105,17 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |||||
| 117 | require.Equal(t, want, (*shown)[len(*shown)-1]) | 105 | require.Equal(t, want, (*shown)[len(*shown)-1]) | ||
| 118 | require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode) | 106 | require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode) | ||
| 119 | require.NotNil(t, bw.vimPageInteractionWebView) | 107 | require.NotNil(t, bw.vimPageInteractionWebView) | ||
| 120 | - | require.True(t, frame.suspended, "legend stays hidden while the page owns the keys") | 108 | + | require.True(t, toaster.suspended, "legend stays hidden while the page owns the keys") |
| 121 | } | 109 | } | ||
| 122 | } | 110 | } | ||
| 123 | 111 | ||||
| 124 | func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) { | 112 | func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) { | ||
| 125 | app, bw, shown := newVimPageTestWindow(t) | 113 | app, bw, shown := newVimPageTestWindow(t) | ||
| 114 | + | before := len(*shown) | |||
| 126 | 115 | ||||
| 127 | app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret) | 116 | app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret) | ||
| 128 | 117 | ||||
| 129 | - | require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended") | 118 | + | require.Len(t, *shown, before, "a late mode report must not repaint the indicator once the interaction ended") |
| 130 | } | 119 | } | ||
| 131 | 120 | ||||
| 132 | // exitModeWithin runs fn and fails the test if it does not return in time, | 121 | // exitModeWithin runs fn and fails the test if it does not return in time, | ||
M internal/ui/browser_window.go +18 -19
| @@ -35,7 +35,7 @@ type browserWindow struct { | |||||
| 35 | tabs *entity.TabList // per-window tab list (single source of truth for tab state) | 35 | tabs *entity.TabList // per-window tab list (single source of truth for tab state) | ||
| 36 | mainWindow *window.MainWindow | 36 | mainWindow *window.MainWindow | ||
| 37 | appToaster *component.Toaster | 37 | appToaster *component.Toaster | ||
| 38 | - | modeToaster *component.Toaster | 38 | + | modeToaster *modeToaster |
| 39 | touchpadNavIndicator *component.TouchpadNavigationIndicator | 39 | touchpadNavIndicator *component.TouchpadNavigationIndicator | ||
| 40 | modeFrame *modeFrame | 40 | modeFrame *modeFrame | ||
| 41 | sessionManager *component.SessionManager | 41 | sessionManager *component.SessionManager | ||
| @@ -104,8 +104,11 @@ func (bw *browserWindow) clearShellState() { | |||||
| 104 | bw.touchpadNavIndicator.Destroy() | 104 | bw.touchpadNavIndicator.Destroy() | ||
| 105 | } | 105 | } | ||
| 106 | bw.appToaster = nil | 106 | bw.appToaster = nil | ||
| 107 | - | bw.modeToaster = nil | |||
| 108 | bw.touchpadNavIndicator = nil | 107 | bw.touchpadNavIndicator = nil | ||
| 108 | + | if bw.modeToaster != nil { | |||
| 109 | + | bw.modeToaster.destroy() | |||
| 110 | + | bw.modeToaster = nil | |||
| 111 | + | } | |||
| 109 | if bw.modeFrame != nil { | 112 | if bw.modeFrame != nil { | ||
| 110 | bw.modeFrame.destroy() | 113 | bw.modeFrame.destroy() | ||
| 111 | bw.modeFrame = nil | 114 | bw.modeFrame = nil | ||
| @@ -141,7 +144,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |||||
| 141 | // no hidden-panel queries or widget row rebuilds. | 144 | // no hidden-panel queries or widget row rebuilds. | ||
| 142 | bw.initToasterOverlay(a) | 145 | bw.initToasterOverlay(a) | ||
| 143 | bw.initTouchpadNavigationIndicator() | 146 | bw.initTouchpadNavigationIndicator() | ||
| 144 | - | bw.initBorderOverlay(a) | 147 | + | bw.initBorderOverlay(ctx, a) |
| 145 | bw.initAccentPicker(ctx, a) | 148 | bw.initAccentPicker(ctx, a) | ||
| 146 | bw.initSessionManager(ctx, a) | 149 | bw.initSessionManager(ctx, a) | ||
| 147 | bw.initTabPicker(ctx, a) | 150 | bw.initTabPicker(ctx, a) | ||
| @@ -160,15 +163,6 @@ func (bw *browserWindow) initToasterOverlay(a *App) { | |||||
| 160 | } | 163 | } | ||
| 161 | } | 164 | } | ||
| 162 | } | 165 | } | ||
| 163 | - | ||||
| 164 | - | bw.modeToaster = component.NewToaster(a.widgetFactory) | |||
| 165 | - | if bw.modeToaster != nil { | |||
| 166 | - | if widget := bw.modeToaster.Widget(); widget != nil { | |||
| 167 | - | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |||
| 168 | - | bw.mainWindow.AddOverlay(gtkWidget) | |||
| 169 | - | } | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | } | 166 | } | ||
| 173 | 167 | ||||
| 174 | func (bw *browserWindow) initTouchpadNavigationIndicator() { | 168 | func (bw *browserWindow) initTouchpadNavigationIndicator() { | ||
| @@ -184,12 +178,19 @@ func (bw *browserWindow) initTouchpadNavigationIndicator() { | |||||
| 184 | } | 178 | } | ||
| 185 | } | 179 | } | ||
| 186 | 180 | ||||
| 187 | - | func (bw *browserWindow) initBorderOverlay(a *App) { | 181 | + | // initBorderOverlay creates the mode border and the mode toaster, which share |
| 182 | + | // the window's content overlay. | |||
| 183 | + | func (bw *browserWindow) initBorderOverlay(ctx context.Context, a *App) { | |||
| 188 | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | 184 | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | ||
| 189 | return | 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 | func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) { | 196 | func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) { | ||
| @@ -359,13 +360,11 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) { | |||||
| 359 | 360 | ||||
| 360 | a.releaseFloatingSessionsForTab(ctx, tab.ID) | 361 | a.releaseFloatingSessionsForTab(ctx, tab.ID) | ||
| 361 | if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID { | 362 | if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID { | ||
| 362 | - | bw.modeFrame.cancelTimers() | |||
| 363 | - | bw.modeFrame.dismissLinger() | |||
| 364 | - | bw.modeFrame.generation++ | |||
| 365 | bw.modeFrame.setTarget(nil, "") | 363 | bw.modeFrame.setTarget(nil, "") | ||
| 366 | bw.modeFrame.tabID = "" | 364 | bw.modeFrame.tabID = "" | ||
| 367 | - | bw.modeFrame.hide() | 365 | + | // The legend belonged to the released workspace; the header stays for |
| 368 | - | a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode) | 366 | + | // the mode. |
| 367 | + | bw.modeToaster.release() | |||
| 369 | } | 368 | } | ||
| 370 | if a.contentCoord != nil && tab.Workspace != nil { | 369 | if a.contentCoord != nil && tab.Workspace != nil { | ||
| 371 | for _, pane := range tab.Workspace.AllPanes() { | 370 | for _, pane := range tab.Workspace.AllPanes() { | ||
M internal/ui/browser_window_test.go +1 -1
| @@ -34,7 +34,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |||||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | setShellField(t, removed, "appToaster", &component.Toaster{}) | 36 | setShellField(t, removed, "appToaster", &component.Toaster{}) | ||
| 37 | - | setShellField(t, removed, "modeToaster", &component.Toaster{}) | 37 | + | setShellField(t, removed, "modeToaster", &modeToaster{}) |
| 38 | setShellField(t, removed, "modeFrame", &modeFrame{}) | 38 | setShellField(t, removed, "modeFrame", &modeFrame{}) | ||
| 39 | setShellField(t, removed, "sessionManager", &component.SessionManager{}) | 39 | setShellField(t, removed, "sessionManager", &component.SessionManager{}) | ||
| 40 | setShellField(t, removed, "tabPicker", &component.TabPicker{}) | 40 | setShellField(t, removed, "tabPicker", &component.TabPicker{}) | ||
M internal/ui/component/toaster.go +0 -31
| @@ -80,9 +80,6 @@ type ToastOptions struct { | |||||
| 80 | TextColor string | 80 | TextColor string | ||
| 81 | // Position determines where the toast appears on screen. | 81 | // Position determines where the toast appears on screen. | ||
| 82 | Position ToastPosition | 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 | // ToastOption is a functional option for configuring toast display. | 85 | // ToastOption is a functional option for configuring toast display. | ||
| @@ -117,14 +114,6 @@ func WithPosition(pos ToastPosition) ToastOption { | |||||
| 117 | } | 114 | } | ||
| 118 | } | 115 | } | ||
| 119 | 116 | ||||
| 120 | - | // WithModeClass sets a mode-specific CSS class for styling. | |||
| 121 | - | // This is applied atomically within Show() to avoid visual flicker. | |||
| 122 | - | func WithModeClass(class string) ToastOption { | |||
| 123 | - | return func(o *ToastOptions) { | |||
| 124 | - | o.ModeClass = class | |||
| 125 | - | } | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | // defaultToastOptions returns the default toast options. | 117 | // defaultToastOptions returns the default toast options. | ||
| 129 | func defaultToastOptions() ToastOptions { | 118 | func defaultToastOptions() ToastOptions { | ||
| 130 | return ToastOptions{ | 119 | return ToastOptions{ | ||
| @@ -150,7 +139,6 @@ type Toaster struct { | |||||
| 150 | hasCustomStyle bool | 139 | hasCustomStyle bool | ||
| 151 | customBgColor string // Current custom background color | 140 | customBgColor string // Current custom background color | ||
| 152 | customTextColor string // Current custom text color | 141 | customTextColor string // Current custom text color | ||
| 153 | - | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | |||
| 154 | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | 142 | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | ||
| 155 | 143 | ||||
| 156 | mu sync.Mutex | 144 | mu sync.Mutex | ||
| @@ -223,11 +211,6 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op | |||||
| 223 | // Apply custom colors if specified | 211 | // Apply custom colors if specified | ||
| 224 | t.applyCustomStyle(options) | 212 | t.applyCustomStyle(options) | ||
| 225 | 213 | ||||
| 226 | - | // Apply mode class if specified (atomically to avoid flicker) | |||
| 227 | - | if options.ModeClass != "" { | |||
| 228 | - | t.applyModeClassInternal(options.ModeClass) | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | // Apply position | 214 | // Apply position | ||
| 232 | t.applyPosition(options.Position) | 215 | t.applyPosition(options.Position) | ||
| 233 | 216 | ||||
| @@ -385,11 +368,6 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||||
| 385 | func (t *Toaster) clearCustomStyle() { | 368 | func (t *Toaster) clearCustomStyle() { | ||
| 386 | if t.hasCustomStyle { | 369 | if t.hasCustomStyle { | ||
| 387 | t.container.RemoveCssClass("toast-custom") | 370 | t.container.RemoveCssClass("toast-custom") | ||
| 388 | - | // Remove mode-specific class if one was applied | |||
| 389 | - | if t.customModeClass != "" { | |||
| 390 | - | t.container.RemoveCssClass(t.customModeClass) | |||
| 391 | - | t.customModeClass = "" | |||
| 392 | - | } | |||
| 393 | // Remove custom CSS provider if one was added | 371 | // Remove custom CSS provider if one was added | ||
| 394 | if t.customCSSProvider != nil { | 372 | if t.customCSSProvider != nil { | ||
| 395 | styleCtx := t.container.GtkWidget().GetStyleContext() | 373 | styleCtx := t.container.GtkWidget().GetStyleContext() | ||
| @@ -407,15 +385,6 @@ func (t *Toaster) clearCustomStyle() { | |||||
| 407 | } | 385 | } | ||
| 408 | } | 386 | } | ||
| 409 | 387 | ||||
| 410 | - | // applyModeClassInternal applies mode class without locking (must be called with lock held). | |||
| 411 | - | func (t *Toaster) applyModeClassInternal(modeClass string) { | |||
| 412 | - | // Remove level class and add mode class | |||
| 413 | - | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |||
| 414 | - | t.container.AddCssClass(modeClass) | |||
| 415 | - | t.hasCustomStyle = true | |||
| 416 | - | t.customModeClass = modeClass // Store class name for cleanup tracking | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | 388 | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | ||
| 420 | // Must be called with lock held. | 389 | // Must be called with lock held. | ||
| 421 | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { | 390 | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { | ||
M internal/ui/deps.go +3 -0
| @@ -130,6 +130,9 @@ func (d *Dependencies) Validate() error { | |||||
| 130 | if d.HandlerDeps.SaveOmniboxInitialBehavior == nil { | 130 | if d.HandlerDeps.SaveOmniboxInitialBehavior == nil { | ||
| 131 | return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior") | 131 | return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior") | ||
| 132 | } | 132 | } | ||
| 133 | + | if d.HandlerDeps.SaveModeLegendCollapsed == nil { | |||
| 134 | + | return ErrMissingDependency("HandlerDeps.SaveModeLegendCollapsed") | |||
| 135 | + | } | |||
| 133 | // Use cases are optional - can be nil if not needed | 136 | // Use cases are optional - can be nil if not needed | ||
| 134 | return nil | 137 | return nil | ||
| 135 | } | 138 | } | ||
M internal/ui/mode_frame.go +33 -481
| @@ -1,106 +1,41 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "strings" | |||
| 5 | - | ||||
| 6 | "github.com/bnema/dumber/internal/domain/entity" | 4 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 7 | "github.com/bnema/dumber/internal/ui/input" | 5 | "github.com/bnema/dumber/internal/ui/input" | ||
| 8 | "github.com/bnema/dumber/internal/ui/layout" | 6 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 9 | - | "github.com/bnema/puregotk/v4/gdk" | |||
| 10 | "github.com/bnema/puregotk/v4/glib" | 7 | "github.com/bnema/puregotk/v4/glib" | ||
| 11 | "github.com/bnema/puregotk/v4/gobject" | 8 | "github.com/bnema/puregotk/v4/gobject" | ||
| 12 | "github.com/bnema/puregotk/v4/graphene" | 9 | "github.com/bnema/puregotk/v4/graphene" | ||
| 13 | "github.com/bnema/puregotk/v4/gtk" | 10 | "github.com/bnema/puregotk/v4/gtk" | ||
| 14 | ) | 11 | ) | ||
| 15 | 12 | ||||
| 16 | - | const ( | 13 | + | const borderRefreshMs = 100 |
| 17 | - | legendColumnSpacing = 8 | |||
| 18 | - | legendMaxWidth = 700 | |||
| 19 | - | legendCompactWidth = 460 | |||
| 20 | - | legendTwoColumnWidth = 650 | |||
| 21 | - | legendInset = 16 | |||
| 22 | - | legendHeaderSpace = 80 | |||
| 23 | - | legendRefreshMs = 100 | |||
| 24 | - | ) | |||
| 25 | 14 | ||||
| 26 | - | // modeFrame owns the per-window mode border and its non-interactive legend. | 15 | + | // modeFrame owns the per-window mode border drawn around its target pane or |
| 27 | - | // GTK access is restricted to the main thread, as with the rest of the UI. | 16 | + | // workspace, and the Vim pulse on it. The mode indicator and legend belong to |
| 17 | + | // modeToaster. GTK access is restricted to the main thread, as with the rest of | |||
| 18 | + | // the UI. | |||
| 28 | type modeFrame struct { | 19 | type modeFrame struct { | ||
| 29 | mode input.Mode | 20 | mode input.Mode | ||
| 30 | tabID entity.TabID | 21 | tabID entity.TabID | ||
| 31 | target layout.Widget | 22 | target layout.Widget | ||
| 32 | frameClass string | 23 | frameClass string | ||
| 33 | - | root *gtk.Box | |||
| 34 | border *gtk.Box | 24 | border *gtk.Box | ||
| 35 | borderStyle cssClassTarget // border's CSS classes; a seam for tests | 25 | borderStyle cssClassTarget // border's CSS classes; a seam for tests | ||
| 36 | borderRect func() (x, y, width, height int, ok bool) | 26 | borderRect func() (x, y, width, height int, ok bool) | ||
| 37 | - | panel *gtk.Box | 27 | + | overlay *gtk.Overlay |
| 38 | - | content *gtk.FlowBox | 28 | + | |
| 39 | - | scroller *gtk.ScrolledWindow | 29 | + | refreshSource uint |
| 40 | - | heading *gtk.Label | 30 | + | positionHandlerID uint |
| 41 | - | rows map[string][]*gtk.Widget | 31 | + | pulseCycle bool |
| 42 | - | keycaps map[string][]*gtk.Widget | |||
| 43 | - | pending string | |||
| 44 | - | visible bool | |||
| 45 | - | // suspended hides the legend while an in-page interaction (link hints, | |||
| 46 | - | // visual selection) owns the keys; the border keeps tracking its target. | |||
| 47 | - | suspended bool | |||
| 48 | - | style string | |||
| 49 | - | modeClass string | |||
| 50 | - | config entity.WorkspaceStylingConfig | |||
| 51 | - | showTimer uint | |||
| 52 | - | flashTimer uint | |||
| 53 | - | generation uint64 | |||
| 54 | - | placement func() (x, y, width, height int, ok bool) | |||
| 55 | - | refreshSource uint | |||
| 56 | - | lastColumns uint | |||
| 57 | - | lastPanelWidth int | |||
| 58 | - | lastMaxHeight int | |||
| 59 | - | overlay *gtk.Overlay | |||
| 60 | - | window *gtk.ApplicationWindow | |||
| 61 | - | pressInFlight bool | |||
| 62 | - | pressIdle uint | |||
| 63 | - | positionHandlerID uint | |||
| 64 | - | pulseCycle bool | |||
| 65 | - | lingering bool | |||
| 66 | - | pointerX, pointerY float64 | |||
| 67 | - | pointerInside bool | |||
| 68 | - | motionController *gtk.EventControllerMotion | |||
| 69 | - | keyController *gtk.EventControllerKey | |||
| 70 | - | clickController *gtk.GestureClick | |||
| 71 | - | motionCb func(gtk.EventControllerMotion, float64, float64) | |||
| 72 | - | enterCb func(gtk.EventControllerMotion, float64, float64) | |||
| 73 | - | leaveCb func(gtk.EventControllerMotion) | |||
| 74 | - | keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | |||
| 75 | - | clickCb func(gtk.GestureClick, int, float64, float64) | |||
| 76 | } | 32 | } | ||
| 77 | 33 | ||||
| 78 | - | func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame { | 34 | + | func newModeFrame(overlay *gtk.Overlay) *modeFrame { |
| 79 | - | if overlay == nil || window == nil { | 35 | + | if overlay == nil { |
| 80 | return nil | 36 | return nil | ||
| 81 | } | 37 | } | ||
| 82 | - | f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window} | 38 | + | f := &modeFrame{mode: input.ModeNormal, overlay: overlay} |
| 83 | - | f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 84 | - | f.root.SetHalign(gtk.AlignStartValue) | |||
| 85 | - | f.root.SetValign(gtk.AlignStartValue) | |||
| 86 | - | f.root.SetCanFocus(false) | |||
| 87 | - | f.root.SetCanTarget(false) | |||
| 88 | - | f.root.SetVisible(false) | |||
| 89 | - | f.root.AddCssClass("mode-legend-anchor") | |||
| 90 | - | f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 91 | - | f.panel.SetCanFocus(false) | |||
| 92 | - | f.panel.SetCanTarget(false) | |||
| 93 | - | f.panel.AddCssClass("omnibox-container") | |||
| 94 | - | f.panel.AddCssClass("mode-legend-panel") | |||
| 95 | - | f.root.Append(&f.panel.Widget) | |||
| 96 | - | title := "" | |||
| 97 | - | f.heading = gtk.NewLabel(&title) | |||
| 98 | - | f.heading.SetCanTarget(false) | |||
| 99 | - | f.heading.SetCanFocus(false) | |||
| 100 | - | f.heading.SetHalign(gtk.AlignStartValue) | |||
| 101 | - | f.heading.AddCssClass("mode-legend-title") | |||
| 102 | - | f.panel.Append(&f.heading.Widget) | |||
| 103 | - | f.initLegendContent() | |||
| 104 | // The border is its own overlay: inset shadows on pane containers are | 39 | // The border is its own overlay: inset shadows on pane containers are | ||
| 105 | // painted below web content and would be invisible. | 40 | // painted below web content and would be invisible. | ||
| 106 | f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0) | 41 | f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0) | ||
| @@ -112,129 +47,36 @@ func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFram | |||||
| 112 | overlay.AddOverlay(&f.border.Widget) | 47 | overlay.AddOverlay(&f.border.Widget) | ||
| 113 | overlay.SetClipOverlay(&f.border.Widget, false) | 48 | overlay.SetClipOverlay(&f.border.Widget, false) | ||
| 114 | overlay.SetMeasureOverlay(&f.border.Widget, false) | 49 | overlay.SetMeasureOverlay(&f.border.Widget, false) | ||
| 115 | - | overlay.AddOverlay(&f.root.Widget) | |||
| 116 | - | overlay.SetClipOverlay(&f.root.Widget, false) | |||
| 117 | - | overlay.SetMeasureOverlay(&f.root.Widget, false) | |||
| 118 | // A single positioned overlay tracks the active pane without reparenting it. | 50 | // A single positioned overlay tracks the active pane without reparenting it. | ||
| 119 | cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool { | 51 | cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool { | ||
| 120 | - | placement := f.placement | 52 | + | if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil { |
| 121 | - | if f.border != nil && widgetPtr == f.border.GoPointer() { | |||
| 122 | - | placement = f.borderRect | |||
| 123 | - | } else if widgetPtr != f.root.GoPointer() { | |||
| 124 | - | return false | |||
| 125 | - | } | |||
| 126 | - | if placement == nil { | |||
| 127 | return false | 53 | return false | ||
| 128 | } | 54 | } | ||
| 129 | - | x, y, w, h, ok := placement() | 55 | + | x, y, w, h, ok := f.borderRect() |
| 130 | if !ok { | 56 | if !ok { | ||
| 131 | return false | 57 | return false | ||
| 132 | } | 58 | } | ||
| 133 | return writeOverlayAllocation(allocation, x, y, w, h) | 59 | return writeOverlayAllocation(allocation, x, y, w, h) | ||
| 134 | } | 60 | } | ||
| 135 | f.positionHandlerID = overlay.ConnectGetChildPosition(&cb) | 61 | f.positionHandlerID = overlay.ConnectGetChildPosition(&cb) | ||
| 136 | - | f.installDismissControllers() | |||
| 137 | return f | 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 | func (f *modeFrame) destroy() { | 65 | func (f *modeFrame) destroy() { | ||
| 205 | if f == nil { | 66 | if f == nil { | ||
| 206 | return | 67 | return | ||
| 207 | } | 68 | } | ||
| 208 | - | f.cancelTimers() | |||
| 209 | - | f.dismissLinger() | |||
| 210 | f.stopGeometryRefresh() | 69 | f.stopGeometryRefresh() | ||
| 211 | - | if f.pressIdle != 0 { | |||
| 212 | - | glib.SourceRemove(f.pressIdle) | |||
| 213 | - | f.pressIdle = 0 | |||
| 214 | - | } | |||
| 215 | - | if f.overlay != nil { | |||
| 216 | - | f.overlay.RemoveController(&f.motionController.EventController) | |||
| 217 | - | } | |||
| 218 | - | if f.window != nil { | |||
| 219 | - | f.window.RemoveController(&f.keyController.EventController) | |||
| 220 | - | f.window.RemoveController(&f.clickController.EventController) | |||
| 221 | - | } | |||
| 222 | f.setTarget(nil, "") | 70 | f.setTarget(nil, "") | ||
| 223 | if f.overlay != nil && f.positionHandlerID != 0 { | 71 | if f.overlay != nil && f.positionHandlerID != 0 { | ||
| 224 | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID) | 72 | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID) | ||
| 225 | f.positionHandlerID = 0 | 73 | f.positionHandlerID = 0 | ||
| 226 | } | 74 | } | ||
| 227 | - | f.placement = nil | |||
| 228 | f.borderRect = nil | 75 | f.borderRect = nil | ||
| 229 | - | if f.overlay != nil && f.root != nil { | |||
| 230 | - | f.overlay.RemoveOverlay(&f.root.Widget) | |||
| 231 | - | } | |||
| 232 | if f.overlay != nil && f.border != nil { | 76 | if f.overlay != nil && f.border != nil { | ||
| 233 | f.overlay.RemoveOverlay(&f.border.Widget) | 77 | f.overlay.RemoveOverlay(&f.border.Widget) | ||
| 234 | } | 78 | } | ||
| 235 | f.overlay = nil | 79 | f.overlay = nil | ||
| 236 | - | f.window = nil | |||
| 237 | - | f.root = nil | |||
| 238 | f.border = nil | 80 | f.border = nil | ||
| 239 | f.borderStyle = nil | 81 | f.borderStyle = nil | ||
| 240 | } | 82 | } | ||
| @@ -244,6 +86,17 @@ type cssClassTarget interface { | |||||
| 244 | RemoveCssClass(string) | 86 | RemoveCssClass(string) | ||
| 245 | } | 87 | } | ||
| 246 | 88 | ||||
| 89 | + | // setMode retargets the border for mode; it clears the border for Normal mode, | |||
| 90 | + | // which has no target. | |||
| 91 | + | func (f *modeFrame) setMode(mode input.Mode, target layout.Widget) { | |||
| 92 | + | if f == nil { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | f.mode = mode | |||
| 96 | + | f.pulseCycle = false | |||
| 97 | + | f.setTarget(target, modeFrameClass(mode)) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 247 | func (f *modeFrame) setTarget(target layout.Widget, class string) { | 100 | func (f *modeFrame) setTarget(target layout.Widget, class string) { | ||
| 248 | if f == nil { | 101 | if f == nil { | ||
| 249 | return | 102 | return | ||
| @@ -314,167 +167,6 @@ func modeFrameClass(mode input.Mode) string { | |||||
| 314 | } | 167 | } | ||
| 315 | } | 168 | } | ||
| 316 | 169 | ||||
| 317 | - | func (f *modeFrame) setMode( | |||
| 318 | - | mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig, | |||
| 319 | - | style string, actions map[string]entity.ActionBinding, | |||
| 320 | - | ) { | |||
| 321 | - | if f == nil { | |||
| 322 | - | return | |||
| 323 | - | } | |||
| 324 | - | f.cancelTimers() | |||
| 325 | - | // Normal-mode refreshes must preserve an already lingering legend. | |||
| 326 | - | keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering | |||
| 327 | - | linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight, | |||
| 328 | - | cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement) | |||
| 329 | - | if !keepLinger { | |||
| 330 | - | f.dismissLinger() | |||
| 331 | - | } | |||
| 332 | - | f.generation++ | |||
| 333 | - | f.mode, f.config = mode, cfg | |||
| 334 | - | f.pending = "" | |||
| 335 | - | f.pulseCycle = false | |||
| 336 | - | f.suspended = false | |||
| 337 | - | if linger { | |||
| 338 | - | f.lingering = true | |||
| 339 | - | f.visible = false | |||
| 340 | - | f.clearLegendFeedback() | |||
| 341 | - | f.panel.AddCssClass("mode-legend-lingering") | |||
| 342 | - | } else if !keepLinger { | |||
| 343 | - | f.hide() | |||
| 344 | - | } | |||
| 345 | - | f.setTarget(target, modeFrameClass(mode)) | |||
| 346 | - | f.setLegendStyle(style, mode, linger || keepLinger) | |||
| 347 | - | if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil { | |||
| 348 | - | if !linger && !keepLinger { | |||
| 349 | - | f.content.RemoveAll() | |||
| 350 | - | } | |||
| 351 | - | f.rows = nil | |||
| 352 | - | f.keycaps = nil | |||
| 353 | - | return | |||
| 354 | - | } | |||
| 355 | - | f.build(actions) | |||
| 356 | - | if cfg.ModeLegendAnimations { | |||
| 357 | - | f.panel.RemoveCssClass("mode-legend-no-motion") | |||
| 358 | - | } else { | |||
| 359 | - | f.panel.AddCssClass("mode-legend-no-motion") | |||
| 360 | - | } | |||
| 361 | - | if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 { | |||
| 362 | - | generation := f.generation | |||
| 363 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 364 | - | f.showTimer = 0 | |||
| 365 | - | if generation == f.generation { | |||
| 366 | - | f.show() | |||
| 367 | - | } | |||
| 368 | - | return false | |||
| 369 | - | }) | |||
| 370 | - | f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0) | |||
| 371 | - | return | |||
| 372 | - | } | |||
| 373 | - | f.show() | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | - | func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) { | |||
| 377 | - | if f.style != style { | |||
| 378 | - | if f.style != "" { | |||
| 379 | - | f.panel.RemoveCssClass("omnibox-style-" + f.style) | |||
| 380 | - | } | |||
| 381 | - | f.style = style | |||
| 382 | - | if style != "" { | |||
| 383 | - | f.panel.AddCssClass("omnibox-style-" + style) | |||
| 384 | - | } | |||
| 385 | - | } | |||
| 386 | - | if f.modeClass != "" && !preserveModeClass { | |||
| 387 | - | f.panel.RemoveCssClass(f.modeClass) | |||
| 388 | - | } | |||
| 389 | - | if !preserveModeClass { | |||
| 390 | - | f.modeClass = "mode-legend-" + mode.String() | |||
| 391 | - | f.panel.AddCssClass(f.modeClass) | |||
| 392 | - | } | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | func (f *modeFrame) clearLegendFeedback() { | |||
| 396 | - | for _, rows := range f.rows { | |||
| 397 | - | for _, row := range rows { | |||
| 398 | - | row.RemoveCssClass("mode-legend-dim") | |||
| 399 | - | } | |||
| 400 | - | } | |||
| 401 | - | for _, caps := range f.keycaps { | |||
| 402 | - | for _, cap := range caps { | |||
| 403 | - | cap.RemoveCssClass("mode-legend-flash") | |||
| 404 | - | } | |||
| 405 | - | } | |||
| 406 | - | } | |||
| 407 | - | ||||
| 408 | - | func (f *modeFrame) cancelTimers() { | |||
| 409 | - | if f == nil { | |||
| 410 | - | return | |||
| 411 | - | } | |||
| 412 | - | if f.showTimer != 0 { | |||
| 413 | - | glib.SourceRemove(f.showTimer) | |||
| 414 | - | f.showTimer = 0 | |||
| 415 | - | } | |||
| 416 | - | if f.flashTimer != 0 { | |||
| 417 | - | glib.SourceRemove(f.flashTimer) | |||
| 418 | - | f.flashTimer = 0 | |||
| 419 | - | } | |||
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | func pointInRect(x, y float64, rx, ry, width, height int) bool { | |||
| 423 | - | return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height) | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | - | func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool { | |||
| 427 | - | if !enabled || !inside || placement == nil { | |||
| 428 | - | return false | |||
| 429 | - | } | |||
| 430 | - | rx, ry, w, h, ok := placement() | |||
| 431 | - | return ok && pointInRect(x, y, rx, ry, w, h) | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool { | |||
| 435 | - | return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement) | |||
| 436 | - | } | |||
| 437 | - | ||||
| 438 | - | // setSuspended hides the legend while suspended is true and brings it back | |||
| 439 | - | // otherwise, without touching the mode, its border, or a pending show timer. | |||
| 440 | - | // Used while an in-page interaction owns the keys, when the legend would list | |||
| 441 | - | // bindings that do not apply. | |||
| 442 | - | func (f *modeFrame) setSuspended(suspended bool) { | |||
| 443 | - | if f == nil || f.suspended == suspended { | |||
| 444 | - | return | |||
| 445 | - | } | |||
| 446 | - | f.suspended = suspended | |||
| 447 | - | if f.root == nil || f.lingering { | |||
| 448 | - | return | |||
| 449 | - | } | |||
| 450 | - | if suspended { | |||
| 451 | - | f.clearLegend() | |||
| 452 | - | return | |||
| 453 | - | } | |||
| 454 | - | if f.visible { | |||
| 455 | - | f.refreshGeometry() | |||
| 456 | - | } | |||
| 457 | - | } | |||
| 458 | - | ||||
| 459 | - | func (f *modeFrame) dismissLinger() { | |||
| 460 | - | if f == nil || !f.lingering { | |||
| 461 | - | return | |||
| 462 | - | } | |||
| 463 | - | f.lingering = false | |||
| 464 | - | f.panel.RemoveCssClass("mode-legend-lingering") | |||
| 465 | - | f.hide() | |||
| 466 | - | } | |||
| 467 | - | ||||
| 468 | - | func (f *modeFrame) hide() { | |||
| 469 | - | if f == nil || f.root == nil { | |||
| 470 | - | return | |||
| 471 | - | } | |||
| 472 | - | f.visible = false | |||
| 473 | - | f.placement = nil | |||
| 474 | - | f.resetLegendHeight() | |||
| 475 | - | f.root.SetVisible(false) | |||
| 476 | - | } | |||
| 477 | - | ||||
| 478 | func (f *modeFrame) stopGeometryRefresh() { | 170 | func (f *modeFrame) stopGeometryRefresh() { | ||
| 479 | if f == nil || f.refreshSource == 0 { | 171 | if f == nil || f.refreshSource == 0 { | ||
| 480 | return | 172 | return | ||
| @@ -483,92 +175,40 @@ func (f *modeFrame) stopGeometryRefresh() { | |||||
| 483 | f.refreshSource = 0 | 175 | f.refreshSource = 0 | ||
| 484 | } | 176 | } | ||
| 485 | 177 | ||||
| 486 | - | func (f *modeFrame) show() { | 178 | + | // Measure outside GTK allocation while the border is active, including window |
| 487 | - | if f == nil || f.mode == input.ModeNormal || f.target == nil { | |||
| 488 | - | return | |||
| 489 | - | } | |||
| 490 | - | f.visible = true | |||
| 491 | - | f.startGeometryRefresh() | |||
| 492 | - | } | |||
| 493 | - | ||||
| 494 | - | // Measure outside GTK allocation while the legend is active, including window | |||
| 495 | // resizes and splitter drags after the initial layout has settled. | 179 | // resizes and splitter drags after the initial layout has settled. | ||
| 496 | func (f *modeFrame) startGeometryRefresh() { | 180 | func (f *modeFrame) startGeometryRefresh() { | ||
| 497 | f.stopGeometryRefresh() | 181 | f.stopGeometryRefresh() | ||
| 498 | f.refreshGeometry() | 182 | f.refreshGeometry() | ||
| 499 | cb := glib.SourceFunc(func(_ uintptr) bool { | 183 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 500 | - | if f.target == nil || f.root == nil || f.lingering { | 184 | + | if f.target == nil || f.border == nil { |
| 501 | f.refreshSource = 0 | 185 | f.refreshSource = 0 | ||
| 502 | return false | 186 | return false | ||
| 503 | } | 187 | } | ||
| 504 | f.refreshGeometry() | 188 | f.refreshGeometry() | ||
| 505 | return true | 189 | return true | ||
| 506 | }) | 190 | }) | ||
| 507 | - | f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0) | 191 | + | f.refreshSource = glib.TimeoutAdd(borderRefreshMs, &cb, 0) |
| 508 | - | } | |||
| 509 | - | ||||
| 510 | - | func (f *modeFrame) resetLegendHeight() { | |||
| 511 | - | if f.lastMaxHeight != 0 { | |||
| 512 | - | f.scroller.SetSizeRequest(-1, -1) | |||
| 513 | - | f.scroller.SetPropagateNaturalHeight(true) | |||
| 514 | - | f.lastMaxHeight = 0 | |||
| 515 | - | } | |||
| 516 | } | 192 | } | ||
| 517 | 193 | ||||
| 518 | - | // clearPlacement hides both the border and the legend: the target is gone. | 194 | + | // clearPlacement hides the border: the target is gone. |
| 519 | func (f *modeFrame) clearPlacement() { | 195 | func (f *modeFrame) clearPlacement() { | ||
| 520 | if f.borderRect != nil { | 196 | if f.borderRect != nil { | ||
| 521 | f.borderRect = nil | 197 | f.borderRect = nil | ||
| 522 | f.border.SetVisible(false) | 198 | f.border.SetVisible(false) | ||
| 523 | } | 199 | } | ||
| 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 | } | 200 | } | ||
| 536 | 201 | ||||
| 537 | - | func (f *modeFrame) measureLegendHeight(panelWidth int) int { | 202 | + | // refreshGeometry measures the target outside GTK's positioning callback. |
| 538 | - | maxHeight := f.overlay.GetAllocatedHeight() - legendInset | |||
| 539 | - | if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight { | |||
| 540 | - | f.resetLegendHeight() | |||
| 541 | - | } | |||
| 542 | - | // Measure the panel: GTK reports 0 for the root while it is still hidden. | |||
| 543 | - | _, naturalHeight := 0, 0 | |||
| 544 | - | f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil) | |||
| 545 | - | if naturalHeight > maxHeight && maxHeight > 0 { | |||
| 546 | - | if f.lastMaxHeight != maxHeight { | |||
| 547 | - | f.scroller.SetPropagateNaturalHeight(false) | |||
| 548 | - | f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1)) | |||
| 549 | - | f.lastMaxHeight = maxHeight | |||
| 550 | - | } | |||
| 551 | - | return maxHeight | |||
| 552 | - | } | |||
| 553 | - | return naturalHeight | |||
| 554 | - | } | |||
| 555 | - | ||||
| 556 | - | // refreshGeometry measures the legend outside GTK's positioning callback. | |||
| 557 | - | // A narrow target falls back to the window width, keeping the legend reachable. | |||
| 558 | func (f *modeFrame) refreshGeometry() { | 203 | func (f *modeFrame) refreshGeometry() { | ||
| 559 | - | if f.lingering { | |||
| 560 | - | return | |||
| 561 | - | } | |||
| 562 | - | previous := f.placement | |||
| 563 | if f.target == nil || f.overlay == nil { | 204 | if f.target == nil || f.overlay == nil { | ||
| 564 | f.clearPlacement() | 205 | f.clearPlacement() | ||
| 565 | return | 206 | return | ||
| 566 | } | 207 | } | ||
| 567 | - | overlayWidget := &f.overlay.Widget | |||
| 568 | point := &graphene.Point{X: 0, Y: 0} | 208 | point := &graphene.Point{X: 0, Y: 0} | ||
| 569 | out := &graphene.Point{} | 209 | out := &graphene.Point{} | ||
| 570 | target := f.target.GtkWidget() | 210 | target := f.target.GtkWidget() | ||
| 571 | - | if target == nil || !target.ComputePoint(overlayWidget, point, out) { | 211 | + | if target == nil || !target.ComputePoint(&f.overlay.Widget, point, out) { |
| 572 | f.clearPlacement() | 212 | f.clearPlacement() | ||
| 573 | return | 213 | return | ||
| 574 | } | 214 | } | ||
| @@ -577,48 +217,7 @@ func (f *modeFrame) refreshGeometry() { | |||||
| 577 | f.clearPlacement() | 217 | f.clearPlacement() | ||
| 578 | return | 218 | return | ||
| 579 | } | 219 | } | ||
| 580 | - | x, bottom := int(out.X), int(out.Y)+height | 220 | + | f.placeBorder(int(out.X), int(out.Y), width, height) |
| 581 | - | f.placeBorder(x, int(out.Y), width, height) | |||
| 582 | - | if !f.visible || f.suspended { | |||
| 583 | - | f.clearLegend() | |||
| 584 | - | return | |||
| 585 | - | } | |||
| 586 | - | // Keep every keycap visible even in a narrow split. No webview resize. | |||
| 587 | - | panelWidth := min(width-legendInset, legendMaxWidth) | |||
| 588 | - | columns := uint(3) | |||
| 589 | - | if panelWidth < legendCompactWidth { | |||
| 590 | - | panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth) | |||
| 591 | - | x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2 | |||
| 592 | - | columns = 1 | |||
| 593 | - | } else { | |||
| 594 | - | if panelWidth < legendTwoColumnWidth { | |||
| 595 | - | columns = 2 | |||
| 596 | - | } | |||
| 597 | - | x += (width - panelWidth) / 2 | |||
| 598 | - | } | |||
| 599 | - | if panelWidth <= 0 { | |||
| 600 | - | f.clearLegend() | |||
| 601 | - | return | |||
| 602 | - | } | |||
| 603 | - | f.sizeLegendColumns(columns, panelWidth) | |||
| 604 | - | naturalHeight := f.measureLegendHeight(panelWidth) | |||
| 605 | - | if naturalHeight <= 0 { | |||
| 606 | - | f.clearLegend() | |||
| 607 | - | return | |||
| 608 | - | } | |||
| 609 | - | y := bottom - naturalHeight - 4 | |||
| 610 | - | if y < 0 { | |||
| 611 | - | y = 0 | |||
| 612 | - | } | |||
| 613 | - | f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true } | |||
| 614 | - | f.root.SetVisible(true) | |||
| 615 | - | if previous != nil { | |||
| 616 | - | oldX, oldY, oldWidth, oldHeight, _ := previous() | |||
| 617 | - | if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight { | |||
| 618 | - | return | |||
| 619 | - | } | |||
| 620 | - | } | |||
| 621 | - | f.root.QueueAllocate() | |||
| 622 | } | 221 | } | ||
| 623 | 222 | ||||
| 624 | func (f *modeFrame) placeBorder(x, y, width, height int) { | 223 | func (f *modeFrame) placeBorder(x, y, width, height int) { | ||
| @@ -631,50 +230,3 @@ func (f *modeFrame) placeBorder(x, y, width, height int) { | |||||
| 631 | f.border.SetVisible(true) | 230 | f.border.SetVisible(true) | ||
| 632 | f.border.QueueAllocate() | 231 | f.border.QueueAllocate() | ||
| 633 | } | 232 | } | ||
| 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 +419 -0
| @@ -0,0 +1,419 @@ | |||||
| 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 | + | t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions | |||
| 185 | + | t.detail, t.pending = "", "" | |||
| 186 | + | t.suspended = false | |||
| 187 | + | t.ready = false | |||
| 188 | + | t.view.setStyle(style) | |||
| 189 | + | if linger { | |||
| 190 | + | t.lingering = true | |||
| 191 | + | t.clearFeedback() | |||
| 192 | + | t.view.setBodyLingering(true) | |||
| 193 | + | } | |||
| 194 | + | if mode == input.ModeNormal || !modeLegendEnabled(cfg) { | |||
| 195 | + | if !linger && !keepLinger { | |||
| 196 | + | t.groups = nil | |||
| 197 | + | t.view.setLegend(nil) | |||
| 198 | + | } | |||
| 199 | + | t.reconcile() | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | t.groups = buildLegendGroups(mode, actions) | |||
| 203 | + | t.view.setLegend(t.groups) | |||
| 204 | + | t.view.setAnimations(cfg.ModeLegendAnimations) | |||
| 205 | + | if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 { | |||
| 206 | + | generation := t.generation | |||
| 207 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 208 | + | t.showTimer = 0 | |||
| 209 | + | if generation == t.generation && t.live() { | |||
| 210 | + | t.ready = true | |||
| 211 | + | t.reconcile() | |||
| 212 | + | } | |||
| 213 | + | return false | |||
| 214 | + | }) | |||
| 215 | + | t.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0) | |||
| 216 | + | } else { | |||
| 217 | + | t.ready = true | |||
| 218 | + | } | |||
| 219 | + | t.reconcile() | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // setDetail shows detail after the Vim Mode name in the header: the sub-mode of | |||
| 223 | + | // an in-page interaction, or "" for the plain name. Other modes have none. | |||
| 224 | + | func (t *modeToaster) setDetail(detail string) { | |||
| 225 | + | if !t.live() || t.mode != input.ModeVim || t.detail == detail { | |||
| 226 | + | return | |||
| 227 | + | } | |||
| 228 | + | t.detail = detail | |||
| 229 | + | t.reconcile() | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // setPending shows the pending key sequence in the header and dims the legend | |||
| 233 | + | // rows it rules out. | |||
| 234 | + | func (t *modeToaster) setPending(pending string) { | |||
| 235 | + | if !t.live() || t.mode != input.ModeVim { | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | t.detail = pending | |||
| 239 | + | t.pending = pending | |||
| 240 | + | for _, group := range t.groups { | |||
| 241 | + | for _, row := range group.Rows { | |||
| 242 | + | t.view.setActionDimmed(row.Action, !legendSequenceMatches(pending, row.Keys)) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | t.reconcile() | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // setSuspended hides the legend while suspended is true and brings it back | |||
| 249 | + | // otherwise, without touching the mode or a pending show delay. Used while an | |||
| 250 | + | // in-page interaction owns the keys, when the legend would list bindings that | |||
| 251 | + | // do not apply. The header keeps showing the sub-mode. | |||
| 252 | + | func (t *modeToaster) setSuspended(suspended bool) { | |||
| 253 | + | if !t.live() || t.suspended == suspended { | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | t.suspended = suspended | |||
| 257 | + | t.reconcile() | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // setCollapsed collapses or expands the legend. | |||
| 261 | + | func (t *modeToaster) setCollapsed(collapsed bool) { | |||
| 262 | + | if !t.live() || t.collapsed == collapsed { | |||
| 263 | + | return | |||
| 264 | + | } | |||
| 265 | + | t.collapsed = collapsed | |||
| 266 | + | if !collapsed && t.showTimer != 0 { | |||
| 267 | + | // Expanding is a request to see the legend now, not after its delay. | |||
| 268 | + | glib.SourceRemove(t.showTimer) | |||
| 269 | + | t.showTimer = 0 | |||
| 270 | + | t.ready = true | |||
| 271 | + | } | |||
| 272 | + | t.reconcile() | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // pillClicked handles a click on the header: it toggles the legend. | |||
| 276 | + | func (t *modeToaster) pillClicked() { | |||
| 277 | + | if !t.live() || t.lingering || !t.hasLegend() { | |||
| 278 | + | return | |||
| 279 | + | } | |||
| 280 | + | next := nextModeLegendCollapsed(t.collapsed) | |||
| 281 | + | if t.onToggle != nil { | |||
| 282 | + | t.onToggle(next) | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | t.setCollapsed(next) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // release drops the legend of a tab whose workspace is going away. The header | |||
| 289 | + | // stays for the active mode. | |||
| 290 | + | func (t *modeToaster) release() { | |||
| 291 | + | if !t.live() { | |||
| 292 | + | return | |||
| 293 | + | } | |||
| 294 | + | t.cancelTimers() | |||
| 295 | + | t.clearLinger() | |||
| 296 | + | t.generation++ | |||
| 297 | + | t.ready = false | |||
| 298 | + | t.reconcile() | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func (t *modeToaster) clearLinger() { | |||
| 302 | + | if !t.lingering { | |||
| 303 | + | return | |||
| 304 | + | } | |||
| 305 | + | t.lingering = false | |||
| 306 | + | t.view.setBodyLingering(false) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func (t *modeToaster) dismissLinger() { | |||
| 310 | + | if !t.live() || !t.lingering { | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | t.clearLinger() | |||
| 314 | + | t.reconcile() | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func (t *modeToaster) clearFeedback() { | |||
| 318 | + | for _, group := range t.groups { | |||
| 319 | + | for _, row := range group.Rows { | |||
| 320 | + | t.view.setActionDimmed(row.Action, false) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | t.view.clearFlash() | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (t *modeToaster) cancelTimers() { | |||
| 327 | + | if t.showTimer != 0 { | |||
| 328 | + | glib.SourceRemove(t.showTimer) | |||
| 329 | + | t.showTimer = 0 | |||
| 330 | + | } | |||
| 331 | + | if t.flashTimer != 0 { | |||
| 332 | + | glib.SourceRemove(t.flashTimer) | |||
| 333 | + | t.flashTimer = 0 | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // flash lights the keycaps of a dispatched action. | |||
| 338 | + | func (t *modeToaster) flash(action input.Action) { | |||
| 339 | + | if !t.live() || t.lingering || !t.bodyShown() || !t.cfg.ModeLegendAnimations { | |||
| 340 | + | return | |||
| 341 | + | } | |||
| 342 | + | name := string(action) | |||
| 343 | + | if !t.view.flashAction(name) { | |||
| 344 | + | name = strings.ReplaceAll(name, "_", "-") | |||
| 345 | + | if !t.view.flashAction(name) { | |||
| 346 | + | return | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | if t.flashTimer != 0 { | |||
| 350 | + | glib.SourceRemove(t.flashTimer) | |||
| 351 | + | } | |||
| 352 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 353 | + | t.flashTimer = 0 | |||
| 354 | + | if t.live() { | |||
| 355 | + | t.view.clearFlash() | |||
| 356 | + | } | |||
| 357 | + | return false | |||
| 358 | + | }) | |||
| 359 | + | t.flashTimer = glib.TimeoutAdd(legendFlashDurationMs, &cb, 0) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | func (t *modeToaster) pointerMoved(x, y float64) { | |||
| 363 | + | t.pointerX, t.pointerY, t.pointerInside = x, y, true | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func (t *modeToaster) pointerLeft() { t.pointerInside = false } | |||
| 367 | + | ||||
| 368 | + | // keyPressed ends a lingering legend: the next key press means the user moved on. | |||
| 369 | + | func (t *modeToaster) keyPressed() { t.dismissLinger() } | |||
| 370 | + | ||||
| 371 | + | // pressStarted records a click outside the pill: it ends a lingering legend, | |||
| 372 | + | // and a mode that exits because of it must not start one. | |||
| 373 | + | func (t *modeToaster) pressStarted() { | |||
| 374 | + | if !t.live() { | |||
| 375 | + | return | |||
| 376 | + | } | |||
| 377 | + | t.dismissLinger() | |||
| 378 | + | t.pressInFlight = true | |||
| 379 | + | if t.pressIdle != 0 { | |||
| 380 | + | glib.SourceRemove(t.pressIdle) | |||
| 381 | + | } | |||
| 382 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 383 | + | t.pressInFlight = false | |||
| 384 | + | t.pressIdle = 0 | |||
| 385 | + | return false | |||
| 386 | + | }) | |||
| 387 | + | t.pressIdle = glib.IdleAdd(&cb, 0) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func (t *modeToaster) destroy() { | |||
| 391 | + | if t == nil { | |||
| 392 | + | return | |||
| 393 | + | } | |||
| 394 | + | t.cancelTimers() | |||
| 395 | + | if t.pressIdle != 0 { | |||
| 396 | + | glib.SourceRemove(t.pressIdle) | |||
| 397 | + | t.pressIdle = 0 | |||
| 398 | + | } | |||
| 399 | + | if t.view != nil { | |||
| 400 | + | t.view.destroy() | |||
| 401 | + | t.view = nil | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func pointInRect(x, y float64, rx, ry, width, height int) bool { | |||
| 406 | + | return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool { | |||
| 410 | + | if !enabled || !inside || placement == nil { | |||
| 411 | + | return false | |||
| 412 | + | } | |||
| 413 | + | rx, ry, w, h, ok := placement() | |||
| 414 | + | return ok && pointInRect(x, y, rx, ry, w, h) | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool { | |||
| 418 | + | return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement) | |||
| 419 | + | } | |||
A internal/ui/mode_toaster_fake_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||||
| 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.flashable = map[string]bool{} | |||
| 53 | + | for _, group := range groups { | |||
| 54 | + | for _, row := range group.Rows { | |||
| 55 | + | f.flashable[row.Action] = true | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (f *fakeModeToasterView) flashAction(action string) bool { | |||
| 61 | + | if !f.flashable[action] { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | f.flashed = map[string]bool{action: true} | |||
| 65 | + | return true | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (f *fakeModeToasterView) lingerRect() (int, int, int, int, bool) { | |||
| 69 | + | if f.rect == nil { | |||
| 70 | + | return 0, 0, 0, 0, false | |||
| 71 | + | } | |||
| 72 | + | return f.rect() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // newFakeModeToasterWindow returns a window whose mode toaster paints into a | |||
| 76 | + | // fake view. | |||
| 77 | + | func newFakeModeToasterWindow(id string) (*browserWindow, *fakeModeToasterView) { | |||
| 78 | + | view := newFakeModeToasterView() | |||
| 79 | + | return &browserWindow{id: id, modeToaster: newModeToasterWithView(view, nil)}, view | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // pumpMainContext runs pending GLib sources (show delays, flash timers). | |||
| 83 | + | func pumpMainContext() { | |||
| 84 | + | ctx := glib.MainContextDefault() | |||
| 85 | + | for ctx.Pending() { | |||
| 86 | + | ctx.Iteration(false) | |||
| 87 | + | } | |||
| 88 | + | } | |||
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 +542 -0
| @@ -0,0 +1,542 @@ | |||||
| 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 TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) { | |||
| 289 | + | toaster, view := newTestToaster(nil) | |||
| 290 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 291 | + | ||||
| 292 | + | toaster.flash(input.Action("vim-scroll-down")) | |||
| 293 | + | require.True(t, view.flashed["vim-scroll-down"]) | |||
| 294 | + | ||||
| 295 | + | // Underscore action names map onto dashed legend rows. | |||
| 296 | + | toaster.flash(input.Action("heading_next")) | |||
| 297 | + | require.True(t, view.flashed["heading-next"]) | |||
| 298 | + | ||||
| 299 | + | toaster.flash(input.Action("unknown-action")) | |||
| 300 | + | require.True(t, view.flashed["heading-next"], "an action without keycaps leaves the flash alone") | |||
| 301 | + | ||||
| 302 | + | waitFor(t, func() bool { return len(view.flashed) == 0 }) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func TestModeToasterFlashNeedsVisibleLegendAndAnimations(t *testing.T) { | |||
| 306 | + | toaster, view := newTestToaster(nil) | |||
| 307 | + | cfg := legendOn("always") | |||
| 308 | + | cfg.ModeLegendAnimations = false | |||
| 309 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 310 | + | toaster.flash(input.Action("vim-scroll-down")) | |||
| 311 | + | require.Empty(t, view.flashed) | |||
| 312 | + | ||||
| 313 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 314 | + | toaster.setCollapsed(true) | |||
| 315 | + | toaster.flash(input.Action("vim-scroll-down")) | |||
| 316 | + | require.Empty(t, view.flashed, "collapsed legend: nothing to flash") | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func TestModeToasterLingersWhenPointerIsOverLegend(t *testing.T) { | |||
| 320 | + | toaster, view := newTestToaster(nil) | |||
| 321 | + | cfg := legendOn("always") | |||
| 322 | + | cfg.ModeLegendLinger = true | |||
| 323 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 324 | + | view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true } | |||
| 325 | + | toaster.pointerMoved(50, 50) | |||
| 326 | + | ||||
| 327 | + | toaster.apply(input.ModeNormal, cfg, "", nil) | |||
| 328 | + | ||||
| 329 | + | require.True(t, toaster.lingering) | |||
| 330 | + | require.True(t, view.lingering) | |||
| 331 | + | require.True(t, view.bodyShown, "the legend stays on screen, read-only") | |||
| 332 | + | require.False(t, view.pillShown, "the mode ended: the header goes") | |||
| 333 | + | require.True(t, toaster.hasLegend() || toaster.lingering) | |||
| 334 | + | ||||
| 335 | + | toaster.keyPressed() | |||
| 336 | + | require.False(t, toaster.lingering) | |||
| 337 | + | require.False(t, view.bodyShown) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func TestModeToasterDoesNotLingerWhenPointerElsewhereOrPressInFlight(t *testing.T) { | |||
| 341 | + | cfg := legendOn("always") | |||
| 342 | + | cfg.ModeLegendLinger = true | |||
| 343 | + | ||||
| 344 | + | toaster, view := newTestToaster(nil) | |||
| 345 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 346 | + | view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true } | |||
| 347 | + | toaster.pointerMoved(900, 900) | |||
| 348 | + | toaster.apply(input.ModeNormal, cfg, "", nil) | |||
| 349 | + | require.False(t, toaster.lingering) | |||
| 350 | + | ||||
| 351 | + | toaster, view = newTestToaster(nil) | |||
| 352 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 353 | + | view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true } | |||
| 354 | + | toaster.pointerMoved(50, 50) | |||
| 355 | + | toaster.pressStarted() // the exit was caused by a click | |||
| 356 | + | toaster.apply(input.ModeNormal, cfg, "", nil) | |||
| 357 | + | require.False(t, toaster.lingering) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func TestModeToasterLingerEndsOnClickAndTabSwitch(t *testing.T) { | |||
| 361 | + | cfg := legendOn("always") | |||
| 362 | + | cfg.ModeLegendLinger = true | |||
| 363 | + | toaster, view := newTestToaster(nil) | |||
| 364 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 365 | + | view.rect = func() (int, int, int, int, bool) { return 0, 0, 500, 500, true } | |||
| 366 | + | toaster.pointerMoved(5, 5) | |||
| 367 | + | toaster.apply(input.ModeNormal, cfg, "", nil) | |||
| 368 | + | require.True(t, toaster.lingering) | |||
| 369 | + | ||||
| 370 | + | toaster.pressStarted() | |||
| 371 | + | require.False(t, toaster.lingering) | |||
| 372 | + | pumpMainContext() // the press is over; a later exit may linger again | |||
| 373 | + | ||||
| 374 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 375 | + | toaster.pointerMoved(5, 5) | |||
| 376 | + | toaster.apply(input.ModeNormal, cfg, "", nil) | |||
| 377 | + | require.True(t, toaster.lingering) | |||
| 378 | + | toaster.dismissLinger() | |||
| 379 | + | require.False(t, toaster.lingering) | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func TestModeToasterReleaseDropsLegendKeepsPill(t *testing.T) { | |||
| 383 | + | toaster, view := newTestToaster(nil) | |||
| 384 | + | cfg := legendOn("delay") | |||
| 385 | + | cfg.ModeLegendDelayMs = 40 | |||
| 386 | + | toaster.apply(input.ModeVim, cfg, "", testVimActions) | |||
| 387 | + | ||||
| 388 | + | toaster.release() | |||
| 389 | + | time.Sleep(90 * time.Millisecond) | |||
| 390 | + | pumpMainContext() | |||
| 391 | + | ||||
| 392 | + | require.True(t, view.pillShown, "tab teardown keeps the mode header") | |||
| 393 | + | require.False(t, view.bodyShown, "the pending show for the released workspace never fires") | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestModeToasterNilAndDestroyedAreSafe(t *testing.T) { | |||
| 397 | + | var nilToaster *modeToaster | |||
| 398 | + | nilToaster.destroy() | |||
| 399 | + | nilToaster.release() | |||
| 400 | + | nilToaster.dismissLinger() | |||
| 401 | + | nilToaster.setSuspended(true) | |||
| 402 | + | nilToaster.setCollapsed(true) | |||
| 403 | + | nilToaster.setPending("x") | |||
| 404 | + | nilToaster.setDetail("x") | |||
| 405 | + | nilToaster.flash(input.Action("x")) | |||
| 406 | + | nilToaster.apply(input.ModeVim, legendOn("always"), "", nil) | |||
| 407 | + | ||||
| 408 | + | toaster, view := newTestToaster(nil) | |||
| 409 | + | toaster.destroy() | |||
| 410 | + | require.True(t, view.destroyed) | |||
| 411 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 412 | + | toaster.setPending("x") | |||
| 413 | + | toaster.pillClicked() | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func TestModeLegendPreferenceClickWinsUntilSettled(t *testing.T) { | |||
| 417 | + | var p modeLegendPreference | |||
| 418 | + | require.False(t, p.current(false)) | |||
| 419 | + | require.True(t, p.current(true), "no click yet: the configuration decides") | |||
| 420 | + | ||||
| 421 | + | require.True(t, p.click(true), "first click starts the writer") | |||
| 422 | + | require.True(t, p.current(false), "the click wins while the config still says otherwise") | |||
| 423 | + | ||||
| 424 | + | p.configReloaded(false) // a reload from before the save landed | |||
| 425 | + | require.True(t, p.current(false), "a stale reload does not undo the click") | |||
| 426 | + | ||||
| 427 | + | next, ok := p.next() | |||
| 428 | + | require.True(t, ok) | |||
| 429 | + | require.True(t, next) | |||
| 430 | + | _, ok = p.next() | |||
| 431 | + | require.False(t, ok, "writer drained") | |||
| 432 | + | ||||
| 433 | + | p.configReloaded(true) // the save's own reload | |||
| 434 | + | require.False(t, p.current(false), "settled: the configuration is the truth again") | |||
| 435 | + | require.True(t, p.current(true)) | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) { | |||
| 439 | + | var p modeLegendPreference | |||
| 440 | + | require.True(t, p.click(true)) | |||
| 441 | + | require.False(t, p.click(false), "a running writer picks the new value up") | |||
| 442 | + | require.False(t, p.click(true)) | |||
| 443 | + | ||||
| 444 | + | next, ok := p.next() | |||
| 445 | + | require.True(t, ok) | |||
| 446 | + | require.True(t, next, "the file ends on the last click") | |||
| 447 | + | _, ok = p.next() | |||
| 448 | + | require.False(t, ok) | |||
| 449 | + | ||||
| 450 | + | require.True(t, p.click(false), "after draining, a new click starts a new writer") | |||
| 451 | + | _, _ = p.next() | |||
| 452 | + | _, _ = p.next() | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | func TestModeLegendPreferenceExternalEditWinsWhenNoSaveInFlight(t *testing.T) { | |||
| 456 | + | var p modeLegendPreference | |||
| 457 | + | require.True(t, p.click(true)) | |||
| 458 | + | _, _ = p.next() | |||
| 459 | + | _, _ = p.next() // save finished (and possibly failed) | |||
| 460 | + | ||||
| 461 | + | p.configReloaded(false) // the file still says false | |||
| 462 | + | require.False(t, p.current(false)) | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func TestSetModeLegendCollapsedUpdatesEveryWindowAndSavesAsync(t *testing.T) { | |||
| 466 | + | saved := make(chan bool, 4) | |||
| 467 | + | var mu sync.Mutex | |||
| 468 | + | app := &App{} | |||
| 469 | + | app.deps = &Dependencies{} | |||
| 470 | + | app.deps.HandlerDeps.SaveModeLegendCollapsed = func(_ context.Context, collapsed bool) error { | |||
| 471 | + | mu.Lock() | |||
| 472 | + | defer mu.Unlock() | |||
| 473 | + | saved <- collapsed | |||
| 474 | + | return nil | |||
| 475 | + | } | |||
| 476 | + | bw1, view1 := newFakeModeToasterWindow("win-1") | |||
| 477 | + | bw2, view2 := newFakeModeToasterWindow("win-2") | |||
| 478 | + | app.browserWindows = map[string]*browserWindow{bw1.id: bw1, bw2.id: bw2} | |||
| 479 | + | bw1.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 480 | + | bw2.modeToaster.apply(input.ModePane, legendOn("always"), "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}}) | |||
| 481 | + | ||||
| 482 | + | app.setModeLegendCollapsed(context.Background(), true) | |||
| 483 | + | ||||
| 484 | + | require.False(t, view1.bodyShown) | |||
| 485 | + | require.False(t, view2.bodyShown, "the toggle applies immediately in every window") | |||
| 486 | + | require.True(t, view1.pillShown) | |||
| 487 | + | require.True(t, view2.pillShown) | |||
| 488 | + | select { | |||
| 489 | + | case got := <-saved: | |||
| 490 | + | require.True(t, got) | |||
| 491 | + | case <-time.After(2 * time.Second): | |||
| 492 | + | t.Fatal("collapsed preference was not saved") | |||
| 493 | + | } | |||
| 494 | + | require.True(t, app.modeLegendCollapsed()) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func TestSetModeLegendCollapsedLogsSaveFailureAndKeepsState(t *testing.T) { | |||
| 498 | + | done := make(chan struct{}, 1) | |||
| 499 | + | app := &App{deps: &Dependencies{}} | |||
| 500 | + | app.deps.HandlerDeps.SaveModeLegendCollapsed = func(context.Context, bool) error { | |||
| 501 | + | defer func() { done <- struct{}{} }() | |||
| 502 | + | return context.DeadlineExceeded | |||
| 503 | + | } | |||
| 504 | + | bw, view := newFakeModeToasterWindow("win-1") | |||
| 505 | + | app.browserWindows = map[string]*browserWindow{bw.id: bw} | |||
| 506 | + | bw.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 507 | + | ||||
| 508 | + | app.setModeLegendCollapsed(context.Background(), true) | |||
| 509 | + | <-done | |||
| 510 | + | ||||
| 511 | + | require.False(t, view.bodyShown, "a failed save does not revert the visible state") | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func TestUpdateModeFrameUsesSharedCollapsedState(t *testing.T) { | |||
| 515 | + | snapshot := entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |||
| 516 | + | Styling: legendOn("always"), | |||
| 517 | + | VimMode: entity.VimModeConfig{Actions: testVimActions}, | |||
| 518 | + | }}} | |||
| 519 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)} | |||
| 520 | + | require.True(t, app.modeLegend.click(true)) | |||
| 521 | + | bw, view := newFakeModeToasterWindow("win-1") | |||
| 522 | + | ||||
| 523 | + | app.updateModeFrame(bw, input.ModeVim) | |||
| 524 | + | ||||
| 525 | + | require.True(t, view.pillShown) | |||
| 526 | + | require.False(t, view.bodyShown, "a window entering a mode honors the shared collapsed state") | |||
| 527 | + | _, _ = app.modeLegend.next() | |||
| 528 | + | _, _ = app.modeLegend.next() | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | func waitFor(t *testing.T, cond func() bool) { | |||
| 532 | + | t.Helper() | |||
| 533 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 534 | + | for time.Now().Before(deadline) { | |||
| 535 | + | pumpMainContext() | |||
| 536 | + | if cond() { | |||
| 537 | + | return | |||
| 538 | + | } | |||
| 539 | + | time.Sleep(5 * time.Millisecond) | |||
| 540 | + | } | |||
| 541 | + | t.Fatal("condition not reached in time") | |||
| 542 | + | } | |||
A internal/ui/mode_toaster_view.go +730 -0
| @@ -0,0 +1,730 @@ | |||||
| 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 | + | groups []legendGroup | |||
| 86 | + | groupBoxes []*gtk.Box | |||
| 87 | + | columns [][]*gtk.Box // group boxes per column box, as placed | |||
| 88 | + | columnBoxes []*gtk.Box | |||
| 89 | + | rows map[string][]*gtk.Widget | |||
| 90 | + | keycaps map[string][]*gtk.Widget | |||
| 91 | + | ||||
| 92 | + | bodyWanted bool | |||
| 93 | + | lingering bool | |||
| 94 | + | // placement is the legend rectangle in overlay coordinates; nil hides it. | |||
| 95 | + | placement func() (x, y, width, height int, ok bool) | |||
| 96 | + | lastColumns int | |||
| 97 | + | lastWidth, lastMaxContent int | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func newGTKModeToasterView(overlay *gtk.Overlay, window *gtk.ApplicationWindow, events modeToasterEvents) *gtkModeToasterView { | |||
| 101 | + | if overlay == nil || window == nil { | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | v := >kModeToasterView{overlay: overlay, window: window, events: events, animations: true} | |||
| 105 | + | v.buildPill() | |||
| 106 | + | v.buildBody() | |||
| 107 | + | overlay.AddOverlay(&v.pill.Widget) | |||
| 108 | + | overlay.SetClipOverlay(&v.pill.Widget, false) | |||
| 109 | + | overlay.SetMeasureOverlay(&v.pill.Widget, false) | |||
| 110 | + | overlay.AddOverlay(&v.root.Widget) | |||
| 111 | + | overlay.SetClipOverlay(&v.root.Widget, false) | |||
| 112 | + | overlay.SetMeasureOverlay(&v.root.Widget, false) | |||
| 113 | + | cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool { | |||
| 114 | + | if v.root == nil || widgetPtr != v.root.GoPointer() || v.placement == nil { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | x, y, w, h, ok := v.placement() | |||
| 118 | + | return ok && writeOverlayAllocation(allocation, x, y, w, h) | |||
| 119 | + | } | |||
| 120 | + | v.positionHandlerID = overlay.ConnectGetChildPosition(&cb) | |||
| 121 | + | v.installWindowControllers() | |||
| 122 | + | return v | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // buildPill creates the header. It starts out inert: pointer input reaches the | |||
| 126 | + | // pill only while it can toggle a legend (setPillInteractive). | |||
| 127 | + | func (v *gtkModeToasterView) buildPill() { | |||
| 128 | + | v.pill = gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |||
| 129 | + | v.pill.SetHalign(gtk.AlignStartValue) | |||
| 130 | + | v.pill.SetValign(gtk.AlignEndValue) | |||
| 131 | + | v.pill.SetHexpand(false) | |||
| 132 | + | v.pill.SetVexpand(false) | |||
| 133 | + | // Widget margins keep the allocation equal to the visible pill, which the | |||
| 134 | + | // click filter and the legend anchor rely on. | |||
| 135 | + | v.pill.SetMarginStart(modeToasterMargin) | |||
| 136 | + | v.pill.SetMarginBottom(modeToasterMargin) | |||
| 137 | + | v.pill.SetVisible(false) | |||
| 138 | + | // A click must never move keyboard focus off the pane's WebView: nothing in | |||
| 139 | + | // the pill can take focus, and a press is claimed by the pill's own gesture. | |||
| 140 | + | v.pill.SetCanFocus(false) | |||
| 141 | + | v.pill.SetFocusable(false) | |||
| 142 | + | v.pill.SetFocusOnClick(false) | |||
| 143 | + | v.pill.SetCanTarget(false) | |||
| 144 | + | v.pill.AddCssClass("mode-toaster-pill") | |||
| 145 | + | ||||
| 146 | + | empty := "" | |||
| 147 | + | v.pillLabel = gtk.NewLabel(&empty) | |||
| 148 | + | v.pillLabel.SetCanTarget(false) | |||
| 149 | + | v.pillLabel.SetCanFocus(false) | |||
| 150 | + | v.pillLabel.SetFocusable(false) | |||
| 151 | + | v.pillLabel.AddCssClass("mode-toaster-label") | |||
| 152 | + | v.pill.Append(&v.pillLabel.Widget) | |||
| 153 | + | ||||
| 154 | + | v.chevron = gtk.NewLabel(&empty) | |||
| 155 | + | v.chevron.SetCanTarget(false) | |||
| 156 | + | v.chevron.SetCanFocus(false) | |||
| 157 | + | v.chevron.SetFocusable(false) | |||
| 158 | + | v.chevron.SetVisible(false) | |||
| 159 | + | v.chevron.AddCssClass("mode-toaster-chevron") | |||
| 160 | + | v.pill.Append(&v.chevron.Widget) | |||
| 161 | + | ||||
| 162 | + | v.pillClick = gtk.NewGestureClick() | |||
| 163 | + | v.pillClick.SetButton(1) // GDK_BUTTON_PRIMARY | |||
| 164 | + | v.pillClickCb = func(_ gtk.GestureClick, nPress int, _, _ float64) { | |||
| 165 | + | // Claim the sequence so no ancestor controller sees this press. | |||
| 166 | + | v.pillClick.SetState(gtk.EventSequenceClaimedValue) | |||
| 167 | + | if nPress == 1 && v.events.pillClicked != nil { | |||
| 168 | + | v.events.pillClicked() | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | v.pillClick.ConnectPressed(&v.pillClickCb) | |||
| 172 | + | v.pill.AddController(&v.pillClick.EventController) | |||
| 173 | + | ||||
| 174 | + | // The legend itself is out of reach of the pointer, so the wheel over the | |||
| 175 | + | // pill scrolls a legend taller than its height budget. | |||
| 176 | + | v.pillScroll = gtk.NewEventControllerScroll(gtk.EventControllerScrollVerticalValue) | |||
| 177 | + | v.pillScrollCb = func(_ gtk.EventControllerScroll, _, dy float64) bool { | |||
| 178 | + | return v.scrollLegend(dy * legendScrollStep) | |||
| 179 | + | } | |||
| 180 | + | v.pillScroll.ConnectScroll(&v.pillScrollCb) | |||
| 181 | + | v.pill.AddController(&v.pillScroll.EventController) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (v *gtkModeToasterView) buildBody() { | |||
| 185 | + | v.root = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 186 | + | v.root.SetHalign(gtk.AlignStartValue) | |||
| 187 | + | v.root.SetValign(gtk.AlignStartValue) | |||
| 188 | + | v.root.SetCanFocus(false) | |||
| 189 | + | v.root.SetCanTarget(false) | |||
| 190 | + | v.root.SetVisible(false) | |||
| 191 | + | v.root.AddCssClass("mode-legend-anchor") | |||
| 192 | + | ||||
| 193 | + | v.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 194 | + | v.panel.SetCanFocus(false) | |||
| 195 | + | v.panel.SetCanTarget(false) | |||
| 196 | + | v.panel.AddCssClass("omnibox-container") | |||
| 197 | + | v.panel.AddCssClass("mode-legend-panel") | |||
| 198 | + | v.root.Append(&v.panel.Widget) | |||
| 199 | + | ||||
| 200 | + | v.columnBox = gtk.NewBox(gtk.OrientationHorizontalValue, legendColumnGap) | |||
| 201 | + | v.columnBox.SetHomogeneous(true) | |||
| 202 | + | v.columnBox.SetCanFocus(false) | |||
| 203 | + | v.columnBox.SetCanTarget(false) | |||
| 204 | + | v.columnBox.AddCssClass("mode-legend-columns") | |||
| 205 | + | ||||
| 206 | + | v.scroller = gtk.NewScrolledWindow() | |||
| 207 | + | v.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |||
| 208 | + | v.scroller.SetPropagateNaturalHeight(true) | |||
| 209 | + | v.scroller.SetHasFrame(false) | |||
| 210 | + | v.scroller.SetCanFocus(false) | |||
| 211 | + | v.scroller.SetCanTarget(false) | |||
| 212 | + | v.scroller.SetChild(&v.columnBox.Widget) | |||
| 213 | + | v.panel.Append(&v.scroller.Widget) | |||
| 214 | + | ||||
| 215 | + | hint := "esc to exit" | |||
| 216 | + | v.hint = gtk.NewLabel(&hint) | |||
| 217 | + | v.hint.SetCanFocus(false) | |||
| 218 | + | v.hint.SetCanTarget(false) | |||
| 219 | + | v.hint.SetHalign(gtk.AlignEndValue) | |||
| 220 | + | v.hint.AddCssClass("mode-legend-hint") | |||
| 221 | + | v.panel.Append(&v.hint.Widget) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // installWindowControllers watches the whole window, because a lingering | |||
| 225 | + | // legend ends on any key press or click, wherever it lands. | |||
| 226 | + | func (v *gtkModeToasterView) installWindowControllers() { | |||
| 227 | + | v.motion = gtk.NewEventControllerMotion() | |||
| 228 | + | v.motionCb = func(_ gtk.EventControllerMotion, x, y float64) { | |||
| 229 | + | if v.events.pointerMoved != nil { | |||
| 230 | + | v.events.pointerMoved(x, y) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | v.enterCb = v.motionCb | |||
| 234 | + | v.leaveCb = func(_ gtk.EventControllerMotion) { | |||
| 235 | + | if v.events.pointerLeft != nil { | |||
| 236 | + | v.events.pointerLeft() | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | v.motion.ConnectEnter(&v.enterCb) | |||
| 240 | + | v.motion.ConnectMotion(&v.motionCb) | |||
| 241 | + | v.motion.ConnectLeave(&v.leaveCb) | |||
| 242 | + | v.overlay.AddController(&v.motion.EventController) | |||
| 243 | + | ||||
| 244 | + | v.keys = gtk.NewEventControllerKey() | |||
| 245 | + | v.keys.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 246 | + | v.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool { | |||
| 247 | + | if v.events.keyPressed != nil { | |||
| 248 | + | v.events.keyPressed() | |||
| 249 | + | } | |||
| 250 | + | return false | |||
| 251 | + | } | |||
| 252 | + | v.keys.ConnectKeyPressed(&v.keyCb) | |||
| 253 | + | v.window.AddController(&v.keys.EventController) | |||
| 254 | + | ||||
| 255 | + | v.windowClick = gtk.NewGestureClick() | |||
| 256 | + | v.windowClick.SetButton(0) | |||
| 257 | + | v.windowClick.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 258 | + | v.windowClickC = func(_ gtk.GestureClick, _ int, x, y float64) { | |||
| 259 | + | // A press on the pill is the user driving the toaster, not leaving it: | |||
| 260 | + | // it must not end a lingering legend or count as a click elsewhere. | |||
| 261 | + | if v.pillContains(x, y) { | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | if v.events.pressedOutside != nil { | |||
| 265 | + | v.events.pressedOutside() | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | v.windowClick.ConnectPressed(&v.windowClickC) | |||
| 269 | + | v.window.AddController(&v.windowClick.EventController) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // pillContains reports whether window point (x, y) lies on the interactive pill. | |||
| 273 | + | func (v *gtkModeToasterView) pillContains(x, y float64) bool { | |||
| 274 | + | if !v.pillShown || !v.interactive { | |||
| 275 | + | return false | |||
| 276 | + | } | |||
| 277 | + | px, py, w, h, ok := v.pillBounds() | |||
| 278 | + | if !ok { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | // Window coordinates: the overlay sits below the window's own origin. | |||
| 282 | + | origin, out := &graphene.Point{}, &graphene.Point{} | |||
| 283 | + | if !v.overlay.ComputePoint(&v.window.Widget, origin, out) { | |||
| 284 | + | return false | |||
| 285 | + | } | |||
| 286 | + | return pointInRect(x, y, px+int(out.X), py+int(out.Y), w, h) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // pillBounds returns the pill's rectangle in overlay coordinates. The pill is | |||
| 290 | + | // pinned to the bottom-left by alignment and a fixed margin, so the rectangle | |||
| 291 | + | // follows from the overlay size. gtk_widget_compute_point is not used: for a | |||
| 292 | + | // pill with CSS padding it reports the origin shifted by the padding, which | |||
| 293 | + | // disagrees with where GTK draws and picks it. | |||
| 294 | + | func (v *gtkModeToasterView) pillBounds() (x, y, width, height int, ok bool) { | |||
| 295 | + | if v.pill == nil || v.overlay == nil { | |||
| 296 | + | return 0, 0, 0, 0, false | |||
| 297 | + | } | |||
| 298 | + | width, height = v.pill.GetAllocatedWidth(), v.pill.GetAllocatedHeight() | |||
| 299 | + | if width <= 0 || height <= 0 { | |||
| 300 | + | return 0, 0, 0, 0, false | |||
| 301 | + | } | |||
| 302 | + | overlayHeight := v.overlay.GetAllocatedHeight() | |||
| 303 | + | return modeToasterMargin, overlayHeight - modeToasterMargin - height, width, height, true | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func (v *gtkModeToasterView) setHeader(text string) { | |||
| 307 | + | if v.headerText == text { | |||
| 308 | + | return | |||
| 309 | + | } | |||
| 310 | + | v.headerText = text | |||
| 311 | + | v.pillLabel.SetText(text) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func (v *gtkModeToasterView) setMode(mode input.Mode) { | |||
| 315 | + | pillClass, panelClass := "", "" | |||
| 316 | + | if mode != input.ModeNormal { | |||
| 317 | + | pillClass = "mode-toaster-" + mode.String() | |||
| 318 | + | panelClass = "mode-legend-" + mode.String() | |||
| 319 | + | } | |||
| 320 | + | if pillClass != v.modeClass { | |||
| 321 | + | if v.modeClass != "" { | |||
| 322 | + | v.pill.RemoveCssClass(v.modeClass) | |||
| 323 | + | } | |||
| 324 | + | if pillClass != "" { | |||
| 325 | + | v.pill.AddCssClass(pillClass) | |||
| 326 | + | } | |||
| 327 | + | v.modeClass = pillClass | |||
| 328 | + | } | |||
| 329 | + | if panelClass != v.legendClass { | |||
| 330 | + | if v.legendClass != "" { | |||
| 331 | + | v.panel.RemoveCssClass(v.legendClass) | |||
| 332 | + | } | |||
| 333 | + | if panelClass != "" { | |||
| 334 | + | v.panel.AddCssClass(panelClass) | |||
| 335 | + | } | |||
| 336 | + | v.legendClass = panelClass | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func (v *gtkModeToasterView) setChevron(state chevronState) { | |||
| 341 | + | if v.chevronState == state { | |||
| 342 | + | return | |||
| 343 | + | } | |||
| 344 | + | v.chevronState = state | |||
| 345 | + | switch state { | |||
| 346 | + | case chevronExpanded: | |||
| 347 | + | v.chevron.SetText("▾") | |||
| 348 | + | v.chevron.SetVisible(true) | |||
| 349 | + | v.setTooltip("Collapse the key legend") | |||
| 350 | + | case chevronCollapsed: | |||
| 351 | + | v.chevron.SetText("▴") | |||
| 352 | + | v.chevron.SetVisible(true) | |||
| 353 | + | v.setTooltip("Expand the key legend") | |||
| 354 | + | default: | |||
| 355 | + | v.chevron.SetVisible(false) | |||
| 356 | + | v.setTooltip("") | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func (v *gtkModeToasterView) setTooltip(text string) { | |||
| 361 | + | if text == "" { | |||
| 362 | + | v.pill.SetTooltipText(nil) | |||
| 363 | + | return | |||
| 364 | + | } | |||
| 365 | + | v.pill.SetTooltipText(&text) | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func (v *gtkModeToasterView) setPillVisible(visible bool) { | |||
| 369 | + | if v.pillShown == visible { | |||
| 370 | + | return | |||
| 371 | + | } | |||
| 372 | + | v.pillShown = visible | |||
| 373 | + | v.pill.SetVisible(visible) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func (v *gtkModeToasterView) setPillInteractive(interactive bool) { | |||
| 377 | + | if v.interactive == interactive { | |||
| 378 | + | return | |||
| 379 | + | } | |||
| 380 | + | v.interactive = interactive | |||
| 381 | + | v.pill.SetCanTarget(interactive) | |||
| 382 | + | if interactive { | |||
| 383 | + | v.pill.AddCssClass("mode-toaster-interactive") | |||
| 384 | + | cursor := "pointer" | |||
| 385 | + | v.pill.SetCursorFromName(&cursor) | |||
| 386 | + | return | |||
| 387 | + | } | |||
| 388 | + | v.pill.RemoveCssClass("mode-toaster-interactive") | |||
| 389 | + | v.pill.SetCursorFromName(nil) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func (v *gtkModeToasterView) setStyle(style string) { | |||
| 393 | + | if v.styleClass == "" && style == "" { | |||
| 394 | + | return | |||
| 395 | + | } | |||
| 396 | + | class := "" | |||
| 397 | + | if style != "" { | |||
| 398 | + | class = "omnibox-style-" + style | |||
| 399 | + | } | |||
| 400 | + | if class == v.styleClass { | |||
| 401 | + | return | |||
| 402 | + | } | |||
| 403 | + | if v.styleClass != "" { | |||
| 404 | + | v.panel.RemoveCssClass(v.styleClass) | |||
| 405 | + | } | |||
| 406 | + | if class != "" { | |||
| 407 | + | v.panel.AddCssClass(class) | |||
| 408 | + | } | |||
| 409 | + | v.styleClass = class | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func (v *gtkModeToasterView) setAnimations(enabled bool) { | |||
| 413 | + | if v.animations == enabled { | |||
| 414 | + | return | |||
| 415 | + | } | |||
| 416 | + | v.animations = enabled | |||
| 417 | + | if enabled { | |||
| 418 | + | v.panel.RemoveCssClass("mode-legend-no-motion") | |||
| 419 | + | } else { | |||
| 420 | + | v.panel.AddCssClass("mode-legend-no-motion") | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func (v *gtkModeToasterView) setBodyLingering(lingering bool) { | |||
| 425 | + | if v.lingering == lingering { | |||
| 426 | + | return | |||
| 427 | + | } | |||
| 428 | + | v.lingering = lingering | |||
| 429 | + | if lingering { | |||
| 430 | + | v.panel.AddCssClass("mode-legend-lingering") | |||
| 431 | + | v.stopRefresh() | |||
| 432 | + | return | |||
| 433 | + | } | |||
| 434 | + | v.panel.RemoveCssClass("mode-legend-lingering") | |||
| 435 | + | if v.bodyWanted { | |||
| 436 | + | // The frozen placement is stale; track the window again. | |||
| 437 | + | v.startRefresh() | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // setLegend replaces the legend content; nil empties it. | |||
| 442 | + | func (v *gtkModeToasterView) setLegend(groups []legendGroup) { | |||
| 443 | + | v.clearColumns() | |||
| 444 | + | v.groupBoxes = nil | |||
| 445 | + | v.rows = make(map[string][]*gtk.Widget) | |||
| 446 | + | v.keycaps = make(map[string][]*gtk.Widget) | |||
| 447 | + | v.groups = groups | |||
| 448 | + | for _, group := range groups { | |||
| 449 | + | v.groupBoxes = append(v.groupBoxes, v.buildGroup(group)) | |||
| 450 | + | } | |||
| 451 | + | // Force the next geometry pass to place the groups. | |||
| 452 | + | v.lastColumns, v.lastWidth = 0, 0 | |||
| 453 | + | if v.bodyWanted && !v.lingering { | |||
| 454 | + | v.refreshGeometry() | |||
| 455 | + | } | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box { | |||
| 459 | + | box := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 460 | + | box.SetCanFocus(false) | |||
| 461 | + | box.SetCanTarget(false) | |||
| 462 | + | box.AddCssClass("mode-legend-group") | |||
| 463 | + | name := group.Name | |||
| 464 | + | title := gtk.NewLabel(&name) | |||
| 465 | + | title.SetCanFocus(false) | |||
| 466 | + | title.SetCanTarget(false) | |||
| 467 | + | title.SetHalign(gtk.AlignStartValue) | |||
| 468 | + | title.AddCssClass("mode-legend-group-title") | |||
| 469 | + | box.Append(&title.Widget) | |||
| 470 | + | for _, row := range group.Rows { | |||
| 471 | + | box.Append(&v.buildRow(row).Widget) | |||
| 472 | + | } | |||
| 473 | + | return box | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box { | |||
| 477 | + | box := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |||
| 478 | + | box.SetCanFocus(false) | |||
| 479 | + | box.SetCanTarget(false) | |||
| 480 | + | box.AddCssClass("mode-legend-row") | |||
| 481 | + | v.rows[row.Action] = append(v.rows[row.Action], &box.Widget) | |||
| 482 | + | for _, key := range row.Keys { | |||
| 483 | + | display := displayLegendKey(key) | |||
| 484 | + | keycap := gtk.NewLabel(&display) | |||
| 485 | + | keycap.SetCanFocus(false) | |||
| 486 | + | keycap.SetCanTarget(false) | |||
| 487 | + | keycap.AddCssClass("mode-legend-keycap") | |||
| 488 | + | box.Append(&keycap.Widget) | |||
| 489 | + | v.keycaps[row.Action] = append(v.keycaps[row.Action], &keycap.Widget) | |||
| 490 | + | } | |||
| 491 | + | desc := row.Desc | |||
| 492 | + | text := gtk.NewLabel(&desc) | |||
| 493 | + | text.SetCanFocus(false) | |||
| 494 | + | text.SetCanTarget(false) | |||
| 495 | + | text.SetHalign(gtk.AlignStartValue) | |||
| 496 | + | text.SetEllipsize(3) // PangoEllipsizeEnd | |||
| 497 | + | text.AddCssClass("mode-legend-description") | |||
| 498 | + | box.Append(&text.Widget) | |||
| 499 | + | return box | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // clearColumns detaches the group boxes and drops the column boxes, keeping | |||
| 503 | + | // the group boxes for the next layout. | |||
| 504 | + | func (v *gtkModeToasterView) clearColumns() { | |||
| 505 | + | for i, col := range v.columnBoxes { | |||
| 506 | + | for _, group := range v.columns[i] { | |||
| 507 | + | col.Remove(&group.Widget) | |||
| 508 | + | } | |||
| 509 | + | v.columnBox.Remove(&col.Widget) | |||
| 510 | + | } | |||
| 511 | + | v.columnBoxes, v.columns = nil, nil | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | // layoutColumns distributes the groups over count columns. | |||
| 515 | + | func (v *gtkModeToasterView) layoutColumns(count int) { | |||
| 516 | + | v.clearColumns() | |||
| 517 | + | for _, indices := range distributeLegendGroups(legendGroupHeights(v.groups), count) { | |||
| 518 | + | col := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 519 | + | col.SetCanFocus(false) | |||
| 520 | + | col.SetCanTarget(false) | |||
| 521 | + | col.SetHexpand(true) | |||
| 522 | + | col.SetValign(gtk.AlignStartValue) | |||
| 523 | + | col.AddCssClass("mode-legend-column") | |||
| 524 | + | placed := make([]*gtk.Box, 0, len(indices)) | |||
| 525 | + | for _, i := range indices { | |||
| 526 | + | col.Append(&v.groupBoxes[i].Widget) | |||
| 527 | + | placed = append(placed, v.groupBoxes[i]) | |||
| 528 | + | } | |||
| 529 | + | v.columnBox.Append(&col.Widget) | |||
| 530 | + | v.columnBoxes = append(v.columnBoxes, col) | |||
| 531 | + | v.columns = append(v.columns, placed) | |||
| 532 | + | } | |||
| 533 | + | v.lastColumns = count | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | func (v *gtkModeToasterView) setActionDimmed(action string, dimmed bool) { | |||
| 537 | + | for _, row := range v.rows[action] { | |||
| 538 | + | if dimmed { | |||
| 539 | + | row.AddCssClass("mode-legend-dim") | |||
| 540 | + | } else { | |||
| 541 | + | row.RemoveCssClass("mode-legend-dim") | |||
| 542 | + | } | |||
| 543 | + | } | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | func (v *gtkModeToasterView) flashAction(action string) bool { | |||
| 547 | + | caps := v.keycaps[action] | |||
| 548 | + | if len(caps) == 0 { | |||
| 549 | + | return false | |||
| 550 | + | } | |||
| 551 | + | v.clearFlash() | |||
| 552 | + | for _, keycap := range caps { | |||
| 553 | + | keycap.AddCssClass("mode-legend-flash") | |||
| 554 | + | } | |||
| 555 | + | return true | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | func (v *gtkModeToasterView) clearFlash() { | |||
| 559 | + | for _, caps := range v.keycaps { | |||
| 560 | + | for _, keycap := range caps { | |||
| 561 | + | keycap.RemoveCssClass("mode-legend-flash") | |||
| 562 | + | } | |||
| 563 | + | } | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | // setBodyVisible shows or hides the legend body. Showing waits for the first | |||
| 567 | + | // successful measurement. | |||
| 568 | + | func (v *gtkModeToasterView) setBodyVisible(visible bool) { | |||
| 569 | + | if visible == v.bodyWanted { | |||
| 570 | + | return | |||
| 571 | + | } | |||
| 572 | + | v.bodyWanted = visible | |||
| 573 | + | if !visible { | |||
| 574 | + | v.hideBody() | |||
| 575 | + | return | |||
| 576 | + | } | |||
| 577 | + | if !v.lingering { | |||
| 578 | + | v.startRefresh() | |||
| 579 | + | } | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | func (v *gtkModeToasterView) hideBody() { | |||
| 583 | + | v.stopRefresh() | |||
| 584 | + | v.placement = nil | |||
| 585 | + | v.root.SetVisible(false) | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func (v *gtkModeToasterView) lingerRect() (x, y, width, height int, ok bool) { | |||
| 589 | + | if v.placement == nil { | |||
| 590 | + | return 0, 0, 0, 0, false | |||
| 591 | + | } | |||
| 592 | + | return v.placement() | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | func (v *gtkModeToasterView) scrollLegend(delta float64) bool { | |||
| 596 | + | adj := v.scroller.GetVadjustment() | |||
| 597 | + | if adj == nil || adj.GetUpper() <= adj.GetPageSize() { | |||
| 598 | + | return false | |||
| 599 | + | } | |||
| 600 | + | adj.SetValue(adj.GetValue() + delta) | |||
| 601 | + | return true | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | func (v *gtkModeToasterView) stopRefresh() { | |||
| 605 | + | if v.refreshSource != 0 { | |||
| 606 | + | glib.SourceRemove(v.refreshSource) | |||
| 607 | + | v.refreshSource = 0 | |||
| 608 | + | } | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | // startRefresh measures outside GTK's allocation pass while the legend is | |||
| 612 | + | // shown, which also follows window resizes after the initial layout settles. | |||
| 613 | + | func (v *gtkModeToasterView) startRefresh() { | |||
| 614 | + | v.stopRefresh() | |||
| 615 | + | v.refreshGeometry() | |||
| 616 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 617 | + | if v.root == nil || !v.bodyWanted || v.lingering { | |||
| 618 | + | v.refreshSource = 0 | |||
| 619 | + | return false | |||
| 620 | + | } | |||
| 621 | + | v.refreshGeometry() | |||
| 622 | + | return true | |||
| 623 | + | }) | |||
| 624 | + | v.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0) | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | // refreshGeometry places the legend above the pill, from the pill's left edge, | |||
| 628 | + | // as wide as the window allows up to legendMaxWidth. | |||
| 629 | + | func (v *gtkModeToasterView) refreshGeometry() { | |||
| 630 | + | if v.root == nil || v.overlay == nil || !v.bodyWanted || v.lingering { | |||
| 631 | + | return | |||
| 632 | + | } | |||
| 633 | + | width, height := v.overlay.GetAllocatedWidth(), v.overlay.GetAllocatedHeight() | |||
| 634 | + | if width <= 0 || height <= 0 || len(v.groups) == 0 { | |||
| 635 | + | v.clearPlacement() | |||
| 636 | + | return | |||
| 637 | + | } | |||
| 638 | + | left, bottom := modeToasterMargin, height-modeToasterMargin | |||
| 639 | + | if v.pillShown { | |||
| 640 | + | px, py, _, _, ok := v.pillBounds() | |||
| 641 | + | if !ok { | |||
| 642 | + | // The pill has not been allocated yet; the next tick retries. | |||
| 643 | + | v.clearPlacement() | |||
| 644 | + | return | |||
| 645 | + | } | |||
| 646 | + | left, bottom = px, py-legendGap | |||
| 647 | + | } | |||
| 648 | + | bandWidth, columns := legendBandWidth(width-left-modeToasterMargin, len(v.groups)) | |||
| 649 | + | if bandWidth <= 0 { | |||
| 650 | + | v.clearPlacement() | |||
| 651 | + | return | |||
| 652 | + | } | |||
| 653 | + | if columns != v.lastColumns { | |||
| 654 | + | v.layoutColumns(columns) | |||
| 655 | + | } | |||
| 656 | + | if bandWidth != v.lastWidth { | |||
| 657 | + | v.panel.SetSizeRequest(bandWidth, -1) | |||
| 658 | + | v.lastWidth = bandWidth | |||
| 659 | + | } | |||
| 660 | + | if maxContent := legendBodyMaxHeight(height); maxContent != v.lastMaxContent { | |||
| 661 | + | v.scroller.SetMaxContentHeight(maxContent) | |||
| 662 | + | v.lastMaxContent = maxContent | |||
| 663 | + | } | |||
| 664 | + | // Measure the panel: GTK reports 0 for the hidden root. | |||
| 665 | + | naturalHeight := 0 | |||
| 666 | + | v.panel.Measure(gtk.OrientationVerticalValue, bandWidth, nil, &naturalHeight, nil, nil) | |||
| 667 | + | if naturalHeight <= 0 { | |||
| 668 | + | v.clearPlacement() | |||
| 669 | + | return | |||
| 670 | + | } | |||
| 671 | + | y := max(bottom-naturalHeight, 0) | |||
| 672 | + | previous := v.placement | |||
| 673 | + | v.placement = func() (int, int, int, int, bool) { return left, y, bandWidth, naturalHeight, true } | |||
| 674 | + | v.root.SetVisible(true) | |||
| 675 | + | if previous != nil { | |||
| 676 | + | if ox, oy, ow, oh, _ := previous(); ox == left && oy == y && ow == bandWidth && oh == naturalHeight { | |||
| 677 | + | return | |||
| 678 | + | } | |||
| 679 | + | } | |||
| 680 | + | v.root.QueueAllocate() | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | func (v *gtkModeToasterView) clearPlacement() { | |||
| 684 | + | if v.placement == nil { | |||
| 685 | + | return | |||
| 686 | + | } | |||
| 687 | + | v.placement = nil | |||
| 688 | + | v.root.SetVisible(false) | |||
| 689 | + | v.root.QueueAllocate() | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | func (v *gtkModeToasterView) destroy() { | |||
| 693 | + | if v == nil || v.overlay == nil { | |||
| 694 | + | return | |||
| 695 | + | } | |||
| 696 | + | v.stopRefresh() | |||
| 697 | + | v.overlay.RemoveController(&v.motion.EventController) | |||
| 698 | + | v.window.RemoveController(&v.keys.EventController) | |||
| 699 | + | v.window.RemoveController(&v.windowClick.EventController) | |||
| 700 | + | if v.positionHandlerID != 0 { | |||
| 701 | + | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(v.overlay.GoPointer()), v.positionHandlerID) | |||
| 702 | + | v.positionHandlerID = 0 | |||
| 703 | + | } | |||
| 704 | + | v.placement = nil | |||
| 705 | + | v.overlay.RemoveOverlay(&v.pill.Widget) | |||
| 706 | + | v.overlay.RemoveOverlay(&v.root.Widget) | |||
| 707 | + | v.overlay, v.window = nil, nil | |||
| 708 | + | v.pill, v.root = nil, nil | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | // newModeToaster builds the mode toaster of a window inside its content | |||
| 712 | + | // overlay. collapsed is the configured initial state of the legend; onToggle | |||
| 713 | + | // receives the state a click on the pill asks for. | |||
| 714 | + | func newModeToaster(overlay *gtk.Overlay, window *gtk.ApplicationWindow, collapsed bool, onToggle func(bool)) *modeToaster { | |||
| 715 | + | t := newModeToasterWithView(nil, onToggle) | |||
| 716 | + | t.collapsed = collapsed | |||
| 717 | + | t.cfg.ModeLegendCollapsed = collapsed | |||
| 718 | + | view := newGTKModeToasterView(overlay, window, modeToasterEvents{ | |||
| 719 | + | pillClicked: t.pillClicked, | |||
| 720 | + | pointerMoved: t.pointerMoved, | |||
| 721 | + | pointerLeft: t.pointerLeft, | |||
| 722 | + | keyPressed: t.keyPressed, | |||
| 723 | + | pressedOutside: t.pressStarted, | |||
| 724 | + | }) | |||
| 725 | + | if view == nil { | |||
| 726 | + | return nil | |||
| 727 | + | } | |||
| 728 | + | t.view = view | |||
| 729 | + | return t | |||
| 730 | + | } | |||
M internal/ui/runtime_config_test.go +3 -0
| @@ -28,6 +28,9 @@ func TestDependenciesValidateAcceptsRuntimeConfigOnly(t *testing.T) { | |||||
| 28 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { | 28 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { | ||
| 29 | return nil | 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 | background-color: alpha(var(--destructive), 0.9); | 1404 | background-color: alpha(var(--destructive), 0.9); | ||
| 1405 | color: var(--destructive-text); | 1405 | color: var(--destructive-text); | ||
| 1406 | } | 1406 | } | ||
| 1407 | - | ||||
| 1408 | - | /* Toast with custom styling (mode indicator toasters) */ | |||
| 1409 | - | .toast-custom { | |||
| 1410 | - | color: #ffffff; | |||
| 1411 | - | } | |||
| 1412 | - | ||||
| 1413 | - | /* Mode-specific toast colors */ | |||
| 1414 | - | .toast-pane-mode { | |||
| 1415 | - | background-color: var(--pane-mode-color); | |||
| 1416 | - | color: var(--pane-mode-text); | |||
| 1417 | - | } | |||
| 1418 | - | ||||
| 1419 | - | .toast-vim-mode { | |||
| 1420 | - | background-color: var(--vim-mode-color); | |||
| 1421 | - | color: var(--vim-mode-text); | |||
| 1422 | - | } | |||
| 1423 | - | ||||
| 1424 | - | .toast-tab-mode { | |||
| 1425 | - | background-color: var(--tab-mode-color); | |||
| 1426 | - | color: var(--tab-mode-text); | |||
| 1427 | - | } | |||
| 1428 | - | ||||
| 1429 | - | .toast-session-mode { | |||
| 1430 | - | background-color: var(--session-mode-color); | |||
| 1431 | - | color: var(--session-mode-text); | |||
| 1432 | - | } | |||
| 1433 | - | ||||
| 1434 | - | .toast-resize-mode { | |||
| 1435 | - | background-color: var(--resize-mode-color); | |||
| 1436 | - | color: var(--resize-mode-text); | |||
| 1437 | - | } | |||
| 1438 | - | ||||
| 1439 | - | .toast-prefix-mode { | |||
| 1440 | - | background-color: var(--pane-mode-color); | |||
| 1441 | - | color: var(--pane-mode-text); | |||
| 1442 | - | } | |||
| 1443 | ` | 1407 | ` | ||
| 1444 | } | 1408 | } | ||
| 1445 | 1409 | ||||
M internal/ui/theme/css_test.go +14 -0
| @@ -427,3 +427,17 @@ func TestVimModeUsesOwnColor(t *testing.T) { | |||||
| 427 | t.Fatal("palette missing Vim color") | 427 | t.Fatal("palette missing Vim color") | ||
| 428 | } | 428 | } | ||
| 429 | } | 429 | } | ||
| 430 | + | ||||
| 431 | + | func TestModeLegendCSSStylesToasterPillAndBand(t *testing.T) { | |||
| 432 | + | css := generateModeLegendCSS() | |||
| 433 | + | for _, mode := range []string{"pane", "vim", "resize", "tab", "session", "prefix"} { | |||
| 434 | + | assert.Contains(t, css, ".mode-toaster-pill.mode-toaster-"+mode+" {", mode) | |||
| 435 | + | assert.Contains(t, css, ".omnibox-container.mode-legend-panel.mode-legend-"+mode+" {", mode) | |||
| 436 | + | } | |||
| 437 | + | for _, class := range []string{".mode-toaster-chevron", ".mode-toaster-interactive", ".mode-legend-columns", ".mode-legend-column", ".mode-legend-hint"} { | |||
| 438 | + | assert.Contains(t, css, class) | |||
| 439 | + | } | |||
| 440 | + | assert.NotContains(t, css, "mode-legend-title", "the legend no longer has its own heading") | |||
| 441 | + | assert.NotContains(t, css, "mode-legend-content", "the flow box is gone") | |||
| 442 | + | assert.NotContains(t, GenerateCSS(DefaultDarkPalette()), ".toast-vim-mode", "modes no longer reuse the generic toast") | |||
| 443 | + | } | |||
M internal/ui/theme/mode_legend_css.go +22 -10
| @@ -17,26 +17,38 @@ var modeLegendColors = []struct{ mode, color, text string }{ | |||||
| 17 | 17 | ||||
| 18 | func generateModeLegendCSS() string { | 18 | func generateModeLegendCSS() string { | ||
| 19 | var sb strings.Builder | 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 | .mode-legend-anchor { background-color: transparent; } | 22 | .mode-legend-anchor { background-color: transparent; } | ||
| 23 | + | .mode-toaster-pill { | |||
| 24 | + | border-radius: 0.25em; | |||
| 25 | + | padding: 0.375em 0.625em; | |||
| 26 | + | font-size: 0.875em; | |||
| 27 | + | font-weight: 600; | |||
| 28 | + | box-shadow: 0 2px 8px alpha(black, 0.3); | |||
| 29 | + | min-width: 2.5em; | |||
| 30 | + | } | |||
| 31 | + | .mode-toaster-chevron { margin-left: 0.5em; font-size: 0.75em; opacity: 0.85; } | |||
| 32 | + | .mode-toaster-pill.mode-toaster-interactive:hover { box-shadow: 0 2px 10px alpha(black, 0.45); } | |||
| 33 | + | .mode-toaster-pill.mode-toaster-interactive:hover .mode-toaster-chevron { opacity: 1; } | |||
| 22 | .omnibox-container.mode-legend-panel { | 34 | .omnibox-container.mode-legend-panel { | ||
| 23 | border-style: solid; | 35 | border-style: solid; | ||
| 24 | border-width: 0.125em; | 36 | border-width: 0.125em; | ||
| 25 | - | padding: 0 0 0.375em 0; | 37 | + | padding: 0.625em 0.5em 0.375em 0.5em; |
| 26 | min-width: 0; | 38 | min-width: 0; | ||
| 27 | animation: mode-legend-appear 120ms ease-out; | 39 | animation: mode-legend-appear 120ms ease-out; | ||
| 28 | } | 40 | } | ||
| 29 | .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; } | 41 | .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; } | ||
| 30 | .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; } | 42 | .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; } | ||
| 31 | - | .mode-legend-title { | 43 | + | .mode-legend-hint { |
| 32 | font-family: var(--font-mono); | 44 | font-family: var(--font-mono); | ||
| 33 | - | font-size: 0.8125em; | 45 | + | font-size: 0.6875em; |
| 34 | - | font-weight: bold; | 46 | + | color: var(--surface-muted); |
| 35 | - | padding: 0.375em 0.75em; | 47 | + | margin: 0.25em 0.5em 0 0; |
| 36 | - | margin-bottom: 0.375em; | |||
| 37 | } | 48 | } | ||
| 38 | - | .mode-legend-content { padding: 0 0.25em; } | 49 | + | .mode-legend-columns { padding: 0 0.25em; } |
| 39 | - | .mode-legend-group { margin: 0 0.375em; min-width: 0; } | 50 | + | .mode-legend-column { min-width: 0; } |
| 51 | + | .mode-legend-group { margin: 0 0.375em 0.5em 0.375em; min-width: 0; } | |||
| 40 | .mode-legend-group-title { | 52 | .mode-legend-group-title { | ||
| 41 | font-family: var(--font-mono); | 53 | font-family: var(--font-mono); | ||
| 42 | font-size: 0.6875em; | 54 | font-size: 0.6875em; | ||
| @@ -66,7 +78,7 @@ func generateModeLegendCSS() string { | |||||
| 66 | `) | 78 | `) | ||
| 67 | for _, m := range modeLegendColors { | 79 | for _, m := range modeLegendColors { | ||
| 68 | fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); } | 80 | fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); } | ||
| 69 | - | .mode-legend-%[1]s .mode-legend-title { background-color: var(%[2]s); color: var(%[3]s); } | 81 | + | .mode-toaster-pill.mode-toaster-%[1]s { background-color: var(%[2]s); color: var(%[3]s); } |
| 70 | .mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); } | 82 | .mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); } | ||
| 71 | .mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); } | 83 | .mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); } | ||
| 72 | .mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: var(%[3]s); } | 84 | .mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: var(%[3]s); } | ||
M internal/ui/vim_mode.go +6 -44
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 7 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | - | "github.com/bnema/dumber/internal/ui/component" | |||
| 11 | "github.com/bnema/dumber/internal/ui/input" | 10 | "github.com/bnema/dumber/internal/ui/input" | ||
| 12 | ) | 11 | ) | ||
| 13 | 12 | ||||
| @@ -127,10 +126,8 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||||
| 127 | // Entering Vim Mode accents the active pane; leaving removes the accent. | 126 | // Entering Vim Mode accents the active pane; leaving removes the accent. | ||
| 128 | a.handleVimModeOwnership(ctx, bw, to, from) | 127 | a.handleVimModeOwnership(ctx, bw, to, from) | ||
| 129 | 128 | ||||
| 129 | + | // Retarget the border and reconcile the mode toaster (indicator + legend). | |||
| 130 | a.updateModeFrame(bw, to) | 130 | a.updateModeFrame(bw, to) | ||
| 131 | - | ||||
| 132 | - | // Show/hide this window's mode indicator toaster based on mode and config. | |||
| 133 | - | a.updateModeIndicatorToaster(ctx, bw, to) | |||
| 134 | } | 131 | } | ||
| 135 | 132 | ||||
| 136 | // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent | 133 | // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent | ||
| @@ -277,25 +274,6 @@ func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) { | |||||
| 277 | a.vimMode.resetPulse() | 274 | a.vimMode.resetPulse() | ||
| 278 | } | 275 | } | ||
| 279 | 276 | ||||
| 280 | - | // updateModeIndicatorToaster reconciles one window's mode toaster with its | |||
| 281 | - | // current mode and the live mode-indicator preference. | |||
| 282 | - | func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) { | |||
| 283 | - | if bw == nil || bw.modeToaster == nil { | |||
| 284 | - | return | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal { | |||
| 288 | - | bw.modeToaster.Hide() | |||
| 289 | - | return | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | - | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |||
| 293 | - | component.WithDuration(0), | |||
| 294 | - | component.WithPosition(component.ToastPositionBottomLeft), | |||
| 295 | - | component.WithModeClass(getModeToastClass(mode)), | |||
| 296 | - | ) | |||
| 297 | - | } | |||
| 298 | - | ||||
| 299 | // bindVimModeSequenceToaster wires pending Vim-sequence text onto this | 277 | // bindVimModeSequenceToaster wires pending Vim-sequence text onto this | ||
| 300 | // window's modeToaster only (never appToaster / pane toasters). | 278 | // window's modeToaster only (never appToaster / pane toasters). | ||
| 301 | func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) { | 279 | func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) { | ||
| @@ -312,7 +290,7 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) | |||||
| 312 | 290 | ||||
| 313 | // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster | 291 | // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster | ||
| 314 | // while a sequence is incomplete, and restores stable "VIM MODE" when pending | 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 | // silent reset under ModalState avoids stale pending callbacks. | 294 | // silent reset under ModalState avoids stale pending callbacks. | ||
| 317 | func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) { | 295 | func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) { | ||
| 318 | if a == nil || bw == nil { | 296 | if a == nil || bw == nil { | ||
| @@ -323,15 +301,11 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin | |||||
| 323 | // sub-mode, and the legend is suspended. Nothing to repaint. | 301 | // sub-mode, and the legend is suspended. Nothing to repaint. | ||
| 324 | return | 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 | if bw.modeToaster == nil { | 304 | if bw.modeToaster == nil { | ||
| 331 | return | 305 | return | ||
| 332 | } | 306 | } | ||
| 333 | 307 | ||||
| 334 | - | a.showVimModeToast(ctx, bw, pending) | 308 | + | bw.modeToaster.setPending(pending) |
| 335 | logging.FromContext(ctx).Debug(). | 309 | logging.FromContext(ctx).Debug(). | ||
| 336 | Str("window_id", bw.id). | 310 | Str("window_id", bw.id). | ||
| 337 | Str("pending", pending). | 311 | Str("pending", pending). | ||
| @@ -339,22 +313,10 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin | |||||
| 339 | } | 313 | } | ||
| 340 | 314 | ||||
| 341 | // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the | 315 | // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the | ||
| 342 | - | // per-window mode toaster, honoring the indicator toggle. | 316 | + | // per-window mode toaster. |
| 343 | - | func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) { | 317 | + | func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) { |
| 344 | if bw == nil || bw.modeToaster == nil { | 318 | if bw == nil || bw.modeToaster == nil { | ||
| 345 | return | 319 | return | ||
| 346 | } | 320 | } | ||
| 347 | - | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled { | 321 | + | bw.modeToaster.setDetail(detail) |
| 348 | - | bw.modeToaster.Hide() | |||
| 349 | - | return | |||
| 350 | - | } | |||
| 351 | - | text := input.ModeVim.DisplayName() | |||
| 352 | - | if detail != "" { | |||
| 353 | - | text = text + " · " + detail | |||
| 354 | - | } | |||
| 355 | - | bw.modeToaster.Show(ctx, text, component.ToastInfo, | |||
| 356 | - | component.WithDuration(0), | |||
| 357 | - | component.WithPosition(component.ToastPositionBottomLeft), | |||
| 358 | - | component.WithModeClass(getModeToastClass(input.ModeVim)), | |||
| 359 | - | ) | |||
| 360 | } | 322 | } | ||
M internal/ui/vim_page.go +4 -4
| @@ -28,8 +28,8 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port | |||||
| 28 | } | 28 | } | ||
| 29 | }) | 29 | }) | ||
| 30 | a.showVimModeToast(ctx, bw, label) | 30 | a.showVimModeToast(ctx, bw, label) | ||
| 31 | - | if bw.modeFrame != nil { | 31 | + | if bw.modeToaster != nil { |
| 32 | - | bw.modeFrame.setSuspended(true) | 32 | + | bw.modeToaster.setSuspended(true) |
| 33 | } | 33 | } | ||
| 34 | logging.FromContext(ctx).Debug(). | 34 | logging.FromContext(ctx).Debug(). | ||
| 35 | Str("window_id", bw.id). | 35 | Str("window_id", bw.id). | ||
| @@ -54,8 +54,8 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason | |||||
| 54 | } | 54 | } | ||
| 55 | if stillVim { | 55 | if stillVim { | ||
| 56 | a.showVimModeToast(ctx, bw, "") | 56 | a.showVimModeToast(ctx, bw, "") | ||
| 57 | - | if bw.modeFrame != nil { | 57 | + | if bw.modeToaster != nil { |
| 58 | - | bw.modeFrame.setSuspended(false) | 58 | + | bw.modeToaster.setSuspended(false) |
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
| 61 | logging.FromContext(ctx).Debug(). | 61 | logging.FromContext(ctx).Debug(). | ||