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 | 75 | KeybindingsProvider: {} | |
| 76 | 76 | KeybindingsSaver: {} | |
| 77 | 77 | OmniboxPreferencesSaver: {} | |
| 78 | + | ModeLegendPreferencesSaver: {} | |
| 78 | 79 | DownloadResponse: {} | |
| 79 | 80 | PermissionDialogPresenter: {} | |
| 80 | 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 | 16 | ### Changed | |
| 17 | 17 | ||
| 18 | + | - **Mode toaster and legend**: The bottom-left mode indicator and the key legend are now one expandable "mode toaster". The legend expands above the pill as a wide band (up to 1100 px, one to four columns by window width, groups kept in order with balanced column heights) instead of a centered panel. Click the toaster to collapse or expand the legend; the choice applies to every window and mode and is saved as `workspace.styling.mode_legend_collapsed`. With `mode_indicator_toaster_enabled = false` the toaster stays as the legend's header unless `mode_legend = "off"`. | |
| 18 | 19 | - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style. | |
| 19 | 20 | - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker. | |
| 20 | 21 | - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration. | |
M docs/config/index.md +5 -3
| @@ -662,11 +662,12 @@ desc = "Open floating pane on GitHub" | |||
| 662 | 662 | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | |
| 663 | 663 | | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color | | |
| 664 | 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 | | |
| 666 | - | | `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend sits on the active mode border in that mode's color | | |
| 667 | - | | `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the legend when set to `delay` (>= 0) | | |
| 665 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active. When `false` and `mode_legend` is not `off`, the header is still shown because the legend expands from it | | |
| 666 | + | | `workspace.styling.mode_legend` | string | `"delay"` | `always`, `delay`, or `off`; the legend expands from the bottom-left mode toaster in that mode's color. `off` keeps only the toaster header, without the expand/collapse chevron | | |
| 667 | + | | `workspace.styling.mode_legend_delay_ms` | int | `500` | Wait before showing the expanded legend when set to `delay` (>= 0) | | |
| 668 | 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 | 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 | 671 | | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms | | |
| 671 | 672 | ||
| 672 | 673 | **Example:** | |
| @@ -686,6 +687,7 @@ mode_legend = "delay" | |||
| 686 | 687 | mode_legend_delay_ms = 500 | |
| 687 | 688 | mode_legend_linger = true | |
| 688 | 689 | mode_legend_animations = true | |
| 690 | + | mode_legend_collapsed = false | |
| 689 | 691 | transition_duration = 120 | |
| 690 | 692 | ``` | |
| 691 | 693 | ||
M docs/reference/configuration.md +3 -2
| @@ -143,11 +143,12 @@ | |||
| 143 | 143 | | `workspace.styling.session_mode_color` | string | `#9B59B6` | | | |
| 144 | 144 | | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend | | |
| 145 | 145 | | `workspace.styling.resize_mode_color` | string | `#00D4AA` | | | |
| 146 | - | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active | | |
| 147 | - | | `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` | | |
| 146 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active (still shown when the legend is not `off`) | | |
| 147 | + | | `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off`; `off` keeps only the toaster header | | |
| 148 | 148 | | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` | | |
| 149 | 149 | | `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. | | |
| 150 | 150 | | `workspace.styling.mode_legend_animations` | bool | `true` | | | |
| 151 | + | | `workspace.styling.mode_legend_collapsed` | bool | `false` | Collapse the legend to the mode toaster header; toggled by clicking the toaster and saved for all modes | | |
| 151 | 152 | | `workspace.styling.transition_duration` | int | `120` | | | |
| 152 | 153 | | `session.auto_restore` | bool | `false` | | | |
| 153 | 154 | | `session.snapshot_interval_ms` | int | `5000` | | | |
M docs/reference/keybindings.md +1 -1
| @@ -60,7 +60,7 @@ Default actions missing from your config are always active. Your own bindings ov | |||
| 60 | 60 | ||
| 61 | 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 | 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 | 46 | type HandlerDeps struct { | |
| 47 | 47 | SaveConfig func(context.Context, dto.WebUIConfig) error | |
| 48 | 48 | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error | |
| 49 | + | SaveModeLegendCollapsed func(context.Context, bool) error | |
| 49 | 50 | KeybindingsGetter KeybindingsGetter | |
| 50 | 51 | KeybindingSetter KeybindingSetter | |
| 51 | 52 | KeybindingResetter KeybindingResetter | |
A internal/application/port/mocks/mode_legend_preferences.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockModeLegendPreferencesSaver creates a new instance of MockModeLegendPreferencesSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockModeLegendPreferencesSaver(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockModeLegendPreferencesSaver { | |
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 20 | + | helper.Helper() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | mock := &MockModeLegendPreferencesSaver{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { | |
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 28 | + | helper.Helper() | |
| 29 | + | } | |
| 30 | + | mock.AssertExpectations(t) | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | return mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // MockModeLegendPreferencesSaver is an autogenerated mock type for the ModeLegendPreferencesSaver type | |
| 37 | + | type MockModeLegendPreferencesSaver struct { | |
| 38 | + | mock.Mock | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type MockModeLegendPreferencesSaver_Expecter struct { | |
| 42 | + | mock *mock.Mock | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (_m *MockModeLegendPreferencesSaver) EXPECT() *MockModeLegendPreferencesSaver_Expecter { | |
| 46 | + | return &MockModeLegendPreferencesSaver_Expecter{mock: &_m.Mock} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // SaveModeLegendCollapsed provides a mock function for the type MockModeLegendPreferencesSaver | |
| 50 | + | func (_mock *MockModeLegendPreferencesSaver) SaveModeLegendCollapsed(ctx context.Context, collapsed bool) error { | |
| 51 | + | ret := _mock.Called(ctx, collapsed) | |
| 52 | + | ||
| 53 | + | if len(ret) == 0 { | |
| 54 | + | panic("no return value specified for SaveModeLegendCollapsed") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var r0 error | |
| 58 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, bool) error); ok { | |
| 59 | + | r0 = returnFunc(ctx, collapsed) | |
| 60 | + | } else { | |
| 61 | + | r0 = ret.Error(0) | |
| 62 | + | } | |
| 63 | + | return r0 | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveModeLegendCollapsed' | |
| 67 | + | type MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call struct { | |
| 68 | + | *mock.Call | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // SaveModeLegendCollapsed is a helper method to define mock.On call | |
| 72 | + | // - ctx context.Context | |
| 73 | + | // - collapsed bool | |
| 74 | + | func (_e *MockModeLegendPreferencesSaver_Expecter) SaveModeLegendCollapsed(ctx any, collapsed any) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call { | |
| 75 | + | return &MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call{Call: _e.mock.On("SaveModeLegendCollapsed", ctx, collapsed)} | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) Run(run func(ctx context.Context, collapsed bool)) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call { | |
| 79 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 80 | + | var arg0 context.Context | |
| 81 | + | if args[0] != nil { | |
| 82 | + | arg0 = args[0].(context.Context) | |
| 83 | + | } | |
| 84 | + | var arg1 bool | |
| 85 | + | if args[1] != nil { | |
| 86 | + | arg1 = args[1].(bool) | |
| 87 | + | } | |
| 88 | + | run( | |
| 89 | + | arg0, | |
| 90 | + | arg1, | |
| 91 | + | ) | |
| 92 | + | }) | |
| 93 | + | return _c | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) Return(err error) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call { | |
| 97 | + | _c.Call.Return(err) | |
| 98 | + | return _c | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) RunAndReturn(run func(ctx context.Context, collapsed bool) error) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call { | |
| 102 | + | _c.Call.Return(run) | |
| 103 | + | return _c | |
| 104 | + | } | |
A internal/application/port/mode_legend_preferences.go +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // ModeLegendPreferencesSaver persists mode legend preference changes. | |
| 6 | + | type ModeLegendPreferencesSaver interface { | |
| 7 | + | SaveModeLegendCollapsed(ctx context.Context, collapsed bool) error | |
| 8 | + | } | |
A internal/application/usecase/save_mode_legend_collapsed.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // SaveModeLegendCollapsedUseCase persists whether the mode legend is collapsed | |
| 11 | + | // to its toaster header. The preference is shared by every mode and window. | |
| 12 | + | type SaveModeLegendCollapsedUseCase struct { | |
| 13 | + | saver port.ModeLegendPreferencesSaver | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func NewSaveModeLegendCollapsedUseCase(saver port.ModeLegendPreferencesSaver) *SaveModeLegendCollapsedUseCase { | |
| 17 | + | return &SaveModeLegendCollapsedUseCase{saver: saver} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (uc *SaveModeLegendCollapsedUseCase) Execute(ctx context.Context, collapsed bool) error { | |
| 21 | + | if uc == nil || uc.saver == nil { | |
| 22 | + | return fmt.Errorf("mode legend preferences saver is nil") | |
| 23 | + | } | |
| 24 | + | return uc.saver.SaveModeLegendCollapsed(ctx, collapsed) | |
| 25 | + | } | |
A internal/application/usecase/save_mode_legend_collapsed_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestSaveModeLegendCollapsedUseCase_PersistsValue(t *testing.T) { | |
| 13 | + | ctx := context.Background() | |
| 14 | + | for _, collapsed := range []bool{true, false} { | |
| 15 | + | saver := portmocks.NewMockModeLegendPreferencesSaver(t) | |
| 16 | + | saver.EXPECT().SaveModeLegendCollapsed(ctx, collapsed).Return(nil).Once() | |
| 17 | + | ||
| 18 | + | require.NoError(t, NewSaveModeLegendCollapsedUseCase(saver).Execute(ctx, collapsed)) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestSaveModeLegendCollapsedUseCase_ReturnsSaverError(t *testing.T) { | |
| 23 | + | ctx := context.Background() | |
| 24 | + | saver := portmocks.NewMockModeLegendPreferencesSaver(t) | |
| 25 | + | saver.EXPECT().SaveModeLegendCollapsed(ctx, true).Return(errors.New("disk full")).Once() | |
| 26 | + | ||
| 27 | + | require.ErrorContains(t, NewSaveModeLegendCollapsedUseCase(saver).Execute(ctx, true), "disk full") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestSaveModeLegendCollapsedUseCase_NilSaverReturnsError(t *testing.T) { | |
| 31 | + | require.Error(t, NewSaveModeLegendCollapsedUseCase(nil).Execute(context.Background(), true)) | |
| 32 | + | var uc *SaveModeLegendCollapsedUseCase | |
| 33 | + | require.Error(t, uc.Execute(context.Background(), true)) | |
| 34 | + | } | |
M internal/bootstrap/handler_deps.go +2 -0
| @@ -21,10 +21,12 @@ func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) { | |||
| 21 | 21 | gateway := config.NewKeybindingsGateway(mgr) | |
| 22 | 22 | saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr)) | |
| 23 | 23 | omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr)) | |
| 24 | + | modeLegendSaveUC := usecase.NewSaveModeLegendCollapsedUseCase(config.NewModeLegendPreferencesGateway(mgr)) | |
| 24 | 25 | ||
| 25 | 26 | return &port.HandlerDeps{ | |
| 26 | 27 | SaveConfig: saveUC.Execute, | |
| 27 | 28 | SaveOmniboxInitialBehavior: omniboxSaveUC.Execute, | |
| 29 | + | SaveModeLegendCollapsed: modeLegendSaveUC.Execute, | |
| 28 | 30 | KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway), | |
| 29 | 31 | KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway), | |
| 30 | 32 | KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway), | |
M internal/domain/entity/config_types.go +1 -0
| @@ -194,6 +194,7 @@ type WorkspaceStylingConfig struct { | |||
| 194 | 194 | ModeLegendDelayMs int `mapstructure:"mode_legend_delay_ms" yaml:"mode_legend_delay_ms" toml:"mode_legend_delay_ms" json:"mode_legend_delay_ms"` //nolint:lll // struct tags must stay on one line | |
| 195 | 195 | ModeLegendLinger bool `mapstructure:"mode_legend_linger" yaml:"mode_legend_linger" toml:"mode_legend_linger" json:"mode_legend_linger"` //nolint:lll // struct tags must stay on one line | |
| 196 | 196 | ModeLegendAnimations bool `mapstructure:"mode_legend_animations" yaml:"mode_legend_animations" toml:"mode_legend_animations" json:"mode_legend_animations"` //nolint:lll // struct tags must stay on one line | |
| 197 | + | ModeLegendCollapsed bool `mapstructure:"mode_legend_collapsed" yaml:"mode_legend_collapsed" toml:"mode_legend_collapsed" json:"mode_legend_collapsed"` //nolint:lll // struct tags must stay on one line | |
| 197 | 198 | ||
| 198 | 199 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | |
| 199 | 200 | } | |
M internal/infrastructure/config/defaults.go +1 -0
| @@ -445,6 +445,7 @@ func DefaultConfig() *Config { | |||
| 445 | 445 | ModeLegend: "delay", | |
| 446 | 446 | ModeLegendDelayMs: 500, | |
| 447 | 447 | ModeLegendAnimations: true, | |
| 448 | + | ModeLegendCollapsed: false, | |
| 448 | 449 | TransitionDuration: defaultTransitionDuration, | |
| 449 | 450 | }, | |
| 450 | 451 | }, | |
M internal/infrastructure/config/defaults_test.go +1 -0
| @@ -19,6 +19,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 19 | 19 | assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs) | |
| 20 | 20 | assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations) | |
| 21 | 21 | assert.True(t, cfg.Workspace.Styling.ModeLegendLinger) | |
| 22 | + | assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed) | |
| 22 | 23 | assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor) | |
| 23 | 24 | ||
| 24 | 25 | // Engine defaults (replaces old Performance/Privacy sections) | |
M internal/infrastructure/config/loader.go +1 -0
| @@ -754,6 +754,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 754 | 754 | m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend) | |
| 755 | 755 | m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs) | |
| 756 | 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 | 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 | 17 | assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs) | |
| 18 | 18 | assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations) | |
| 19 | 19 | assert.True(t, cfg.Workspace.Styling.ModeLegendLinger) | |
| 20 | + | assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed) | |
| 20 | 21 | assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor) | |
| 21 | 22 | } | |
| 22 | 23 | ||
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 | 740 | Key: "workspace.styling.mode_indicator_toaster_enabled", | |
| 741 | 741 | Type: "bool", | |
| 742 | 742 | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled), | |
| 743 | - | Description: "Show the mode toast while a mode is active", | |
| 743 | + | Description: "Show the bottom-left mode toaster while a mode is active (kept as the legend header while the legend is not off)", | |
| 744 | 744 | Section: SectionWorkspace, | |
| 745 | 745 | }, | |
| 746 | 746 | { | |
| @@ -771,6 +771,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 771 | 771 | Description: "Animate the mode legend and pressed keycaps", | |
| 772 | 772 | Section: SectionWorkspace, | |
| 773 | 773 | }, | |
| 774 | + | { | |
| 775 | + | Key: "workspace.styling.mode_legend_collapsed", | |
| 776 | + | Type: "bool", | |
| 777 | + | Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendCollapsed), | |
| 778 | + | Description: "Collapse the mode legend to its header; toggled by clicking the mode toaster and shared by all modes and windows", | |
| 779 | + | Section: SectionWorkspace, | |
| 780 | + | }, | |
| 774 | 781 | { | |
| 775 | 782 | Key: "workspace.styling.transition_duration", | |
| 776 | 783 | Type: "int", | |
M internal/infrastructure/config/schema_provider_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestModeLegendSchemaDefaults(t *testing.T) { | |||
| 25 | 25 | {"workspace.styling.mode_legend_linger", "bool", "true", ""}, | |
| 26 | 26 | {"workspace.styling.vim_mode_color", "string", "#F7768E", ""}, | |
| 27 | 27 | {"workspace.styling.mode_legend_animations", "bool", "true", ""}, | |
| 28 | + | {"workspace.styling.mode_legend_collapsed", "bool", "false", ""}, | |
| 28 | 29 | } { | |
| 29 | 30 | entry, ok := keys[tc.key] | |
| 30 | 31 | require.True(t, ok, "missing schema key %s", tc.key) | |
M internal/ui/app.go +2 -21
| @@ -126,6 +126,7 @@ type App struct { | |||
| 126 | 126 | vimModePolicyUC *usecase.VimModePolicyUseCase | |
| 127 | 127 | vimNavigationUC *usecase.VimNavigationUseCase | |
| 128 | 128 | vimMode vimModeState | |
| 129 | + | modeLegend modeLegendPreference | |
| 129 | 130 | ||
| 130 | 131 | // Omnibox configuration (omnibox is created per workspace view) | |
| 131 | 132 | omniboxCfg component.OmniboxConfig | |
| @@ -3929,26 +3930,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||
| 3929 | 3930 | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | |
| 3930 | 3931 | } | |
| 3931 | 3932 | ||
| 3932 | - | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| 3933 | - | func getModeToastClass(mode input.Mode) string { | |
| 3934 | - | switch mode { | |
| 3935 | - | case input.ModePane: | |
| 3936 | - | return "toast-pane-mode" | |
| 3937 | - | case input.ModeVim: | |
| 3938 | - | return "toast-vim-mode" | |
| 3939 | - | case input.ModeTab: | |
| 3940 | - | return "toast-tab-mode" | |
| 3941 | - | case input.ModeSession: | |
| 3942 | - | return "toast-session-mode" | |
| 3943 | - | case input.ModeResize: | |
| 3944 | - | return "toast-resize-mode" | |
| 3945 | - | case input.ModePrefix: | |
| 3946 | - | return "toast-prefix-mode" | |
| 3947 | - | default: | |
| 3948 | - | return "" | |
| 3949 | - | } | |
| 3950 | - | } | |
| 3951 | - | ||
| 3952 | 3933 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | |
| 3953 | 3934 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |
| 3954 | 3935 | if !a.createWorkspaceViewWithoutAttach(ctx, tab) { | |
| @@ -4345,6 +4326,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||
| 4345 | 4326 | a.runtimeConfig = newRuntimeConfigState(nil) | |
| 4346 | 4327 | } | |
| 4347 | 4328 | a.runtimeConfig.Update(snapshot) | |
| 4329 | + | a.modeLegend.configReloaded(snapshot.UI.Workspace.Styling.ModeLegendCollapsed) | |
| 4348 | 4330 | if a.contentCoord != nil { | |
| 4349 | 4331 | a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts) | |
| 4350 | 4332 | } | |
| @@ -4385,7 +4367,6 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt | |||
| 4385 | 4367 | mode = bw.keyboardHandler.Mode() | |
| 4386 | 4368 | } | |
| 4387 | 4369 | a.updateModeFrame(bw, mode) | |
| 4388 | - | a.updateModeIndicatorToaster(ctx, bw, mode) | |
| 4389 | 4370 | } | |
| 4390 | 4371 | } | |
| 4391 | 4372 | ||
M internal/ui/app_clipboard_test.go +3 -0
| @@ -38,6 +38,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||
| 38 | 38 | HandlerDeps: port.HandlerDeps{ | |
| 39 | 39 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | |
| 40 | 40 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 41 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { return nil }, | |
| 41 | 42 | }, | |
| 42 | 43 | Clipboard: nil, | |
| 43 | 44 | } | |
| @@ -66,6 +67,7 @@ func TestNew_ClosesHistoryRecorderWhenRegisterHandlersFails(t *testing.T) { | |||
| 66 | 67 | HandlerDeps: port.HandlerDeps{ | |
| 67 | 68 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | |
| 68 | 69 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 70 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { return nil }, | |
| 69 | 71 | }, | |
| 70 | 72 | } | |
| 71 | 73 | ||
| @@ -101,6 +103,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |||
| 101 | 103 | HandlerDeps: port.HandlerDeps{ | |
| 102 | 104 | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, | |
| 103 | 105 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 106 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { return nil }, | |
| 104 | 107 | }, | |
| 105 | 108 | Clipboard: clipboard, | |
| 106 | 109 | } | |
M internal/ui/app_mode_frame.go +127 -9
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 4 | 8 | "github.com/bnema/dumber/internal/ui/input" | |
| 5 | 9 | "github.com/bnema/dumber/internal/ui/layout" | |
| 6 | 10 | ) | |
| @@ -33,38 +37,152 @@ func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) { | |||
| 33 | 37 | return | |
| 34 | 38 | } | |
| 35 | 39 | a.setModeFrameTab(bw) | |
| 36 | - | newTarget := a.modeFrameTarget(bw, mode) | |
| 37 | 40 | // Rebuilds can temporarily lack a pane widget. Drop the old border while | |
| 38 | 41 | // the next focus/rebuild event retries the target. | |
| 39 | - | bw.modeFrame.setTarget(newTarget, modeFrameClass(mode)) | |
| 40 | - | if bw.modeFrame.visible { | |
| 42 | + | bw.modeFrame.setTarget(a.modeFrameTarget(bw, mode), modeFrameClass(mode)) | |
| 43 | + | if bw.modeFrame.target != nil { | |
| 41 | 44 | bw.modeFrame.refreshGeometry() | |
| 42 | 45 | } | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 48 | + | // updateModeFrame reconciles the window's mode border and mode toaster with | |
| 49 | + | // mode and the live configuration. | |
| 45 | 50 | func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) { | |
| 46 | - | if bw == nil || bw.modeFrame == nil { | |
| 51 | + | if bw == nil { | |
| 47 | 52 | return | |
| 48 | 53 | } | |
| 49 | 54 | a.setModeFrameTab(bw) | |
| 50 | 55 | cfg := a.runtimeConfigSnapshot().UI | |
| 51 | - | bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling, | |
| 52 | - | cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session)) | |
| 56 | + | if bw.modeFrame != nil { | |
| 57 | + | bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode)) | |
| 58 | + | } | |
| 59 | + | if bw.modeToaster != nil { | |
| 60 | + | styling := cfg.Workspace.Styling | |
| 61 | + | styling.ModeLegendCollapsed = a.modeLegendCollapsed() | |
| 62 | + | bw.modeToaster.apply(mode, styling, cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session)) | |
| 63 | + | } | |
| 53 | 64 | } | |
| 54 | 65 | ||
| 55 | 66 | func (a *App) setModeFrameTab(bw *browserWindow) { | |
| 67 | + | if bw.modeFrame == nil { | |
| 68 | + | return | |
| 69 | + | } | |
| 56 | 70 | tabID := bw.modeFrame.tabID | |
| 57 | 71 | bw.modeFrame.tabID = "" | |
| 58 | 72 | if tab := a.activeTabForBrowserWindow(bw); tab != nil { | |
| 59 | 73 | bw.modeFrame.tabID = tab.ID | |
| 60 | 74 | } | |
| 61 | 75 | if tabID != bw.modeFrame.tabID { | |
| 62 | - | bw.modeFrame.dismissLinger() | |
| 76 | + | bw.modeToaster.dismissLinger() | |
| 63 | 77 | } | |
| 64 | 78 | } | |
| 65 | 79 | ||
| 66 | 80 | func (a *App) modeFrameAction(bw *browserWindow, action input.Action) { | |
| 67 | - | if bw != nil && bw.modeFrame != nil { | |
| 68 | - | bw.modeFrame.flash(action) | |
| 81 | + | if bw != nil && bw.modeToaster != nil { | |
| 82 | + | bw.modeToaster.flash(action) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // modeLegendPreference is the app-wide "legend collapsed" state, shared by | |
| 87 | + | // every mode and window. A click is applied at once and saved in the | |
| 88 | + | // background. It keeps priority over the configured value, which may still be | |
| 89 | + | // the previous one while 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 | 1019 | // Mode toaster | |
| 1020 | 1020 | // ============================================================================ | |
| 1021 | 1021 | ||
| 1022 | - | func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) { | |
| 1023 | - | t.Helper() | |
| 1024 | - | ||
| 1025 | - | container := mocks.NewMockBoxWidget(t) | |
| 1026 | - | label := mocks.NewMockLabelWidget(t) | |
| 1027 | - | factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once() | |
| 1028 | - | container.EXPECT().AddCssClass("toast").Once() | |
| 1029 | - | container.EXPECT().AddCssClass("toast-info").Once() | |
| 1030 | - | container.EXPECT().SetHalign(mock.Anything).Once() | |
| 1031 | - | container.EXPECT().SetValign(mock.Anything).Once() | |
| 1032 | - | container.EXPECT().SetHexpand(false).Once() | |
| 1033 | - | container.EXPECT().SetVexpand(false).Once() | |
| 1034 | - | container.EXPECT().SetCanTarget(false).Once() | |
| 1035 | - | container.EXPECT().SetCanFocus(false).Once() | |
| 1036 | - | container.EXPECT().SetVisible(false).Once() | |
| 1037 | - | factory.EXPECT().NewLabel("").Return(label).Once() | |
| 1038 | - | label.EXPECT().SetCanTarget(false).Once() | |
| 1039 | - | label.EXPECT().SetCanFocus(false).Once() | |
| 1040 | - | container.EXPECT().Append(label).Once() | |
| 1041 | - | ||
| 1042 | - | return component.NewToaster(factory), container, label | |
| 1043 | - | } | |
| 1044 | - | ||
| 1045 | - | func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) { | |
| 1046 | - | container.EXPECT().RemoveCssClass("toast-info").Once() | |
| 1047 | - | container.EXPECT().AddCssClass("toast-vim-mode").Once() | |
| 1048 | - | container.EXPECT().SetHalign(mock.Anything).Once() | |
| 1049 | - | container.EXPECT().SetValign(mock.Anything).Once() | |
| 1050 | - | label.EXPECT().SetText("VIM MODE").Once() | |
| 1051 | - | container.EXPECT().SetVisible(true).Once() | |
| 1022 | + | func enabledIndicatorSnapshot() entity.RuntimeConfigSnapshot { | |
| 1023 | + | return entity.RuntimeConfigSnapshot{ | |
| 1024 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |
| 1025 | + | Styling: entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"}, | |
| 1026 | + | }}, | |
| 1027 | + | } | |
| 1052 | 1028 | } | |
| 1053 | 1029 | ||
| 1054 | 1030 | func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) { | |
| 1055 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 1056 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |
| 1057 | - | expectVimModeToastShown(container, label) | |
| 1058 | - | // These optional calls are made only by the old brief auto-dismiss path. | |
| 1059 | - | container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe() | |
| 1060 | - | container.EXPECT().SetVisible(false).Maybe() | |
| 1061 | - | ||
| 1062 | - | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |
| 1063 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |
| 1064 | - | ModeIndicatorToasterEnabled: true, | |
| 1065 | - | }}}, | |
| 1066 | - | })} | |
| 1067 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |
| 1031 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())} | |
| 1032 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 1068 | 1033 | app.browserWindows = map[string]*browserWindow{bw.id: bw} | |
| 1069 | - | app.lastFocusedWindowID = bw.id | |
| 1070 | 1034 | ||
| 1071 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | |
| 1072 | - | require.True(t, toaster.IsVisible()) | |
| 1035 | + | app.updateModeFrame(bw, input.ModeVim) | |
| 1036 | + | require.True(t, view.pillShown) | |
| 1037 | + | require.Equal(t, "VIM MODE", view.header) | |
| 1073 | 1038 | ||
| 1039 | + | // Nothing but a mode change hides it: no timer dismisses the persistent pill. | |
| 1074 | 1040 | deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond) | |
| 1075 | 1041 | mainContext := glib.MainContextDefault() | |
| 1076 | 1042 | for time.Now().Before(deadline) { | |
| @@ -1079,116 +1045,95 @@ func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) { | |||
| 1079 | 1045 | mainContext.Iteration(false) | |
| 1080 | 1046 | } | |
| 1081 | 1047 | } | |
| 1082 | - | ||
| 1083 | - | assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit") | |
| 1048 | + | assert.True(t, view.pillShown, "Vim Mode pill must remain visible until mode exit") | |
| 1084 | 1049 | } | |
| 1085 | 1050 | ||
| 1086 | 1051 | func TestVimMode_ModeExitHidesToaster(t *testing.T) { | |
| 1087 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 1088 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |
| 1089 | - | expectVimModeToastShown(container, label) | |
| 1090 | - | container.EXPECT().RemoveCssClass("toast-custom").Once() | |
| 1091 | - | container.EXPECT().RemoveCssClass("toast-vim-mode").Once() | |
| 1092 | - | container.EXPECT().SetVisible(false).Once() | |
| 1052 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())} | |
| 1053 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 1054 | + | ||
| 1055 | + | app.updateModeFrame(bw, input.ModeVim) | |
| 1056 | + | require.True(t, view.pillShown) | |
| 1057 | + | app.updateModeFrame(bw, input.ModeNormal) | |
| 1093 | 1058 | ||
| 1059 | + | assert.False(t, view.pillShown, "leaving Vim Mode must hide its persistent pill") | |
| 1060 | + | } | |
| 1061 | + | ||
| 1062 | + | func TestVimMode_DisabledIndicatorWithLegendOffShowsNothing(t *testing.T) { | |
| 1094 | 1063 | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |
| 1095 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |
| 1096 | - | ModeIndicatorToasterEnabled: true, | |
| 1097 | - | }}}, | |
| 1064 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |
| 1065 | + | Styling: entity.WorkspaceStylingConfig{ModeLegend: "off"}, | |
| 1066 | + | }}, | |
| 1098 | 1067 | })} | |
| 1099 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |
| 1068 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 1100 | 1069 | ||
| 1101 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | |
| 1102 | - | require.True(t, toaster.IsVisible()) | |
| 1103 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal) | |
| 1070 | + | app.updateModeFrame(bw, input.ModeVim) | |
| 1104 | 1071 | ||
| 1105 | - | assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast") | |
| 1072 | + | assert.False(t, view.pillShown) | |
| 1073 | + | assert.False(t, view.bodyShown) | |
| 1106 | 1074 | } | |
| 1107 | 1075 | ||
| 1108 | - | func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) { | |
| 1109 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 1110 | - | toaster, _, _ := newModeToasterForVimModeTest(t, factory) | |
| 1111 | - | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})} | |
| 1112 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |
| 1076 | + | func TestVimMode_DisabledIndicatorKeepsPillAsLegendHeader(t *testing.T) { | |
| 1077 | + | snapshot := entity.RuntimeConfigSnapshot{ | |
| 1078 | + | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ | |
| 1079 | + | Styling: entity.WorkspaceStylingConfig{ModeLegend: "always"}, | |
| 1080 | + | VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}}, | |
| 1081 | + | }}, | |
| 1082 | + | } | |
| 1083 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)} | |
| 1084 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 1113 | 1085 | ||
| 1114 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | |
| 1086 | + | app.updateModeFrame(bw, input.ModeVim) | |
| 1115 | 1087 | ||
| 1116 | - | assert.False(t, toaster.IsVisible()) | |
| 1088 | + | assert.True(t, view.pillShown, "the pill is the legend header even when the indicator is disabled") | |
| 1089 | + | assert.True(t, view.bodyShown) | |
| 1117 | 1090 | } | |
| 1118 | 1091 | ||
| 1119 | 1092 | func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) { | |
| 1120 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 1121 | - | toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory) | |
| 1122 | - | toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory) | |
| 1123 | - | expectVimModeToastShown(container1, label1) | |
| 1124 | - | expectVimModeToastShown(container2, label2) | |
| 1125 | - | container1.EXPECT().RemoveCssClass("toast-custom").Once() | |
| 1126 | - | container1.EXPECT().RemoveCssClass("toast-vim-mode").Once() | |
| 1127 | - | container1.EXPECT().SetVisible(false).Once() | |
| 1128 | - | ||
| 1129 | - | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |
| 1130 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |
| 1131 | - | ModeIndicatorToasterEnabled: true, | |
| 1132 | - | }}}, | |
| 1133 | - | })} | |
| 1134 | - | bw1 := &browserWindow{id: "win-1", modeToaster: toaster1} | |
| 1135 | - | bw2 := &browserWindow{id: "win-2", modeToaster: toaster2} | |
| 1093 | + | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())} | |
| 1094 | + | bw1, view1 := newFakeModeToasterWindow("win-1") | |
| 1095 | + | bw2, view2 := newFakeModeToasterWindow("win-2") | |
| 1136 | 1096 | ||
| 1137 | - | app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim) | |
| 1138 | - | app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim) | |
| 1139 | - | app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal) | |
| 1097 | + | app.updateModeFrame(bw1, input.ModeVim) | |
| 1098 | + | app.updateModeFrame(bw2, input.ModeVim) | |
| 1099 | + | app.updateModeFrame(bw1, input.ModeNormal) | |
| 1140 | 1100 | ||
| 1141 | - | assert.False(t, toaster1.IsVisible()) | |
| 1142 | - | assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast") | |
| 1101 | + | assert.False(t, view1.pillShown) | |
| 1102 | + | assert.True(t, view2.pillShown, "exiting one window must not hide another window's pill") | |
| 1143 | 1103 | } | |
| 1144 | 1104 | ||
| 1145 | 1105 | func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) { | |
| 1146 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 1147 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |
| 1148 | - | expectVimModeToastShown(container, label) | |
| 1149 | - | container.EXPECT().RemoveCssClass("toast-custom").Once() | |
| 1150 | - | container.EXPECT().RemoveCssClass("toast-vim-mode").Once() | |
| 1151 | - | container.EXPECT().SetVisible(false).Once() | |
| 1152 | - | ||
| 1153 | - | enabled := entity.RuntimeConfigSnapshot{ | |
| 1154 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |
| 1155 | - | ModeIndicatorToasterEnabled: true, | |
| 1156 | - | }}}, | |
| 1157 | - | } | |
| 1158 | 1106 | keyboardHandler := newKeyboardHandlerInVimMode(t) | |
| 1159 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler} | |
| 1107 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 1108 | + | bw.keyboardHandler = keyboardHandler | |
| 1160 | 1109 | app := &App{ | |
| 1161 | - | runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled), | |
| 1110 | + | runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot()), | |
| 1162 | 1111 | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 1163 | 1112 | } | |
| 1164 | 1113 | ||
| 1165 | - | app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim) | |
| 1166 | - | require.True(t, toaster.IsVisible()) | |
| 1167 | - | app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{}) | |
| 1114 | + | app.updateModeFrame(bw, input.ModeVim) | |
| 1115 | + | require.True(t, view.pillShown) | |
| 1116 | + | disabled := enabledIndicatorSnapshot() | |
| 1117 | + | disabled.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false | |
| 1118 | + | app.applyRuntimeConfigChange(context.Background(), disabled) | |
| 1168 | 1119 | ||
| 1169 | - | assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast") | |
| 1120 | + | assert.False(t, view.pillShown, "runtime preference disable must reconcile an existing pill") | |
| 1170 | 1121 | } | |
| 1171 | 1122 | ||
| 1172 | 1123 | func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) { | |
| 1173 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 1174 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |
| 1175 | - | expectVimModeToastShown(container, label) | |
| 1176 | - | ||
| 1177 | 1124 | keyboardHandler := newKeyboardHandlerInVimMode(t) | |
| 1178 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler} | |
| 1125 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 1126 | + | bw.keyboardHandler = keyboardHandler | |
| 1127 | + | off := enabledIndicatorSnapshot() | |
| 1128 | + | off.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false | |
| 1179 | 1129 | app := &App{ | |
| 1180 | - | runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}), | |
| 1130 | + | runtimeConfig: runtimeConfigStateFromSnapshotForTest(off), | |
| 1181 | 1131 | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 1182 | 1132 | } | |
| 1183 | - | enabled := entity.RuntimeConfigSnapshot{ | |
| 1184 | - | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |
| 1185 | - | ModeIndicatorToasterEnabled: true, | |
| 1186 | - | }}}, | |
| 1187 | - | } | |
| 1188 | 1133 | ||
| 1189 | - | app.applyRuntimeConfigChange(context.Background(), enabled) | |
| 1134 | + | app.applyRuntimeConfigChange(context.Background(), enabledIndicatorSnapshot()) | |
| 1190 | 1135 | ||
| 1191 | - | assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode") | |
| 1136 | + | assert.True(t, view.pillShown, "runtime preference enable must reconcile active Vim Mode") | |
| 1192 | 1137 | } | |
| 1193 | 1138 | ||
| 1194 | 1139 | // ============================================================================ | |
M internal/ui/app_vim_page_test.go +14 -25
| @@ -5,41 +5,31 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | - | "github.com/stretchr/testify/mock" | |
| 9 | 8 | "github.com/stretchr/testify/require" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/dumber/internal/application/dto" | |
| 12 | 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 13 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 13 | "github.com/bnema/dumber/internal/ui/input" | |
| 15 | - | "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 16 | 14 | ) | |
| 17 | 15 | ||
| 18 | - | // newVimPageTestWindow builds a window in Vim Mode with a real mode toaster | |
| 19 | - | // bound to the pending-sequence callback, as initChrome does. It records every | |
| 20 | - | // text shown on the toast so tests can assert what the user sees. | |
| 16 | + | // newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound | |
| 17 | + | // to the pending-sequence callback, as initChrome does. It records every header | |
| 18 | + | // the pill shows so tests can assert what the user sees. | |
| 21 | 19 | func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) { | |
| 22 | 20 | t.Helper() | |
| 23 | - | factory := mocks.NewMockWidgetFactory(t) | |
| 24 | - | toaster, container, label := newModeToasterForVimModeTest(t, factory) | |
| 25 | - | shown := &[]string{} | |
| 26 | - | container.EXPECT().RemoveCssClass(mock.Anything).Maybe() | |
| 27 | - | container.EXPECT().AddCssClass(mock.Anything).Maybe() | |
| 28 | - | container.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 29 | - | container.EXPECT().SetValign(mock.Anything).Maybe() | |
| 30 | - | container.EXPECT().SetVisible(mock.Anything).Maybe() | |
| 31 | - | label.EXPECT().SetText(mock.Anything).Run(func(text string) { *shown = append(*shown, text) }).Maybe() | |
| 32 | - | ||
| 33 | 21 | app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{ | |
| 34 | 22 | UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{ | |
| 35 | 23 | ModeIndicatorToasterEnabled: true, | |
| 24 | + | ModeLegend: "off", | |
| 36 | 25 | }}}, | |
| 37 | 26 | })} | |
| 38 | - | bw := &browserWindow{id: "win-1", modeToaster: toaster} | |
| 27 | + | bw, view := newFakeModeToasterWindow("win-1") | |
| 39 | 28 | app.browserWindows = map[string]*browserWindow{bw.id: bw} | |
| 40 | 29 | bw.keyboardHandler = newKeyboardHandlerInVimMode(t) | |
| 41 | 30 | app.bindVimModeSequenceToaster(context.Background(), bw) | |
| 42 | - | return app, bw, shown | |
| 31 | + | app.updateModeFrame(bw, input.ModeVim) | |
| 32 | + | return app, bw, &view.headers | |
| 43 | 33 | } | |
| 44 | 34 | ||
| 45 | 35 | var visualOutcome = hintsOutcome(dto.VimPageVisual) | |
| @@ -83,16 +73,15 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) { | |||
| 83 | 73 | // restored when the interaction ends. | |
| 84 | 74 | func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |
| 85 | 75 | app, bw, _ := newVimPageTestWindow(t) | |
| 86 | - | frame := &modeFrame{mode: input.ModeVim, visible: true} | |
| 87 | - | bw.modeFrame = frame | |
| 76 | + | toaster := bw.modeToaster | |
| 88 | 77 | wv := portmocks.NewMockWebView(t) | |
| 89 | 78 | ||
| 90 | 79 | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | |
| 91 | - | require.True(t, frame.suspended, "legend must be suspended while the page captures keys") | |
| 80 | + | require.True(t, toaster.suspended, "legend must be suspended while the page captures keys") | |
| 92 | 81 | ||
| 93 | 82 | wv.EXPECT().ID().Return(1).Maybe() | |
| 94 | 83 | app.endVimPageInteraction(context.Background(), bw, true) | |
| 95 | - | require.False(t, frame.suspended, "legend must be restored when the interaction ends") | |
| 84 | + | require.False(t, toaster.suspended, "legend must be restored when the interaction ends") | |
| 96 | 85 | require.False(t, bw.keyboardHandler.PageKeyCaptureActive()) | |
| 97 | 86 | } | |
| 98 | 87 | ||
| @@ -101,8 +90,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) { | |||
| 101 | 90 | // suspended legend stay in place. | |
| 102 | 91 | func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |
| 103 | 92 | app, bw, shown := newVimPageTestWindow(t) | |
| 104 | - | frame := &modeFrame{mode: input.ModeVim, visible: true} | |
| 105 | - | bw.modeFrame = frame | |
| 93 | + | toaster := bw.modeToaster | |
| 106 | 94 | wv := portmocks.NewMockWebView(t) | |
| 107 | 95 | app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome) | |
| 108 | 96 | require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1]) | |
| @@ -117,16 +105,17 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) { | |||
| 117 | 105 | require.Equal(t, want, (*shown)[len(*shown)-1]) | |
| 118 | 106 | require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode) | |
| 119 | 107 | require.NotNil(t, bw.vimPageInteractionWebView) | |
| 120 | - | require.True(t, frame.suspended, "legend stays hidden while the page owns the keys") | |
| 108 | + | require.True(t, toaster.suspended, "legend stays hidden while the page owns the keys") | |
| 121 | 109 | } | |
| 122 | 110 | } | |
| 123 | 111 | ||
| 124 | 112 | func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) { | |
| 125 | 113 | app, bw, shown := newVimPageTestWindow(t) | |
| 114 | + | before := len(*shown) | |
| 126 | 115 | ||
| 127 | 116 | app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret) | |
| 128 | 117 | ||
| 129 | - | require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended") | |
| 118 | + | require.Len(t, *shown, before, "a late mode report must not repaint the indicator once the interaction ended") | |
| 130 | 119 | } | |
| 131 | 120 | ||
| 132 | 121 | // exitModeWithin runs fn and fails the test if it does not return in time, | |
M internal/ui/browser_window.go +18 -19
| @@ -35,7 +35,7 @@ type browserWindow struct { | |||
| 35 | 35 | tabs *entity.TabList // per-window tab list (single source of truth for tab state) | |
| 36 | 36 | mainWindow *window.MainWindow | |
| 37 | 37 | appToaster *component.Toaster | |
| 38 | - | modeToaster *component.Toaster | |
| 38 | + | modeToaster *modeToaster | |
| 39 | 39 | touchpadNavIndicator *component.TouchpadNavigationIndicator | |
| 40 | 40 | modeFrame *modeFrame | |
| 41 | 41 | sessionManager *component.SessionManager | |
| @@ -104,8 +104,11 @@ func (bw *browserWindow) clearShellState() { | |||
| 104 | 104 | bw.touchpadNavIndicator.Destroy() | |
| 105 | 105 | } | |
| 106 | 106 | bw.appToaster = nil | |
| 107 | - | bw.modeToaster = nil | |
| 108 | 107 | bw.touchpadNavIndicator = nil | |
| 108 | + | if bw.modeToaster != nil { | |
| 109 | + | bw.modeToaster.destroy() | |
| 110 | + | bw.modeToaster = nil | |
| 111 | + | } | |
| 109 | 112 | if bw.modeFrame != nil { | |
| 110 | 113 | bw.modeFrame.destroy() | |
| 111 | 114 | bw.modeFrame = nil | |
| @@ -141,7 +144,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |||
| 141 | 144 | // no hidden-panel queries or widget row rebuilds. | |
| 142 | 145 | bw.initToasterOverlay(a) | |
| 143 | 146 | bw.initTouchpadNavigationIndicator() | |
| 144 | - | bw.initBorderOverlay(a) | |
| 147 | + | bw.initBorderOverlay(ctx, a) | |
| 145 | 148 | bw.initAccentPicker(ctx, a) | |
| 146 | 149 | bw.initSessionManager(ctx, a) | |
| 147 | 150 | bw.initTabPicker(ctx, a) | |
| @@ -160,15 +163,6 @@ func (bw *browserWindow) initToasterOverlay(a *App) { | |||
| 160 | 163 | } | |
| 161 | 164 | } | |
| 162 | 165 | } | |
| 163 | - | ||
| 164 | - | bw.modeToaster = component.NewToaster(a.widgetFactory) | |
| 165 | - | if bw.modeToaster != nil { | |
| 166 | - | if widget := bw.modeToaster.Widget(); widget != nil { | |
| 167 | - | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |
| 168 | - | bw.mainWindow.AddOverlay(gtkWidget) | |
| 169 | - | } | |
| 170 | - | } | |
| 171 | - | } | |
| 172 | 166 | } | |
| 173 | 167 | ||
| 174 | 168 | func (bw *browserWindow) initTouchpadNavigationIndicator() { | |
| @@ -184,12 +178,19 @@ func (bw *browserWindow) initTouchpadNavigationIndicator() { | |||
| 184 | 178 | } | |
| 185 | 179 | } | |
| 186 | 180 | ||
| 187 | - | func (bw *browserWindow) initBorderOverlay(a *App) { | |
| 181 | + | // initBorderOverlay creates the mode border and the mode toaster, which share | |
| 182 | + | // the window's content overlay. | |
| 183 | + | func (bw *browserWindow) initBorderOverlay(ctx context.Context, a *App) { | |
| 188 | 184 | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |
| 189 | 185 | return | |
| 190 | 186 | } | |
| 191 | 187 | ||
| 192 | - | bw.modeFrame = newModeFrame(bw.mainWindow.ContentOverlay(), bw.mainWindow.Window()) | |
| 188 | + | overlay := bw.mainWindow.ContentOverlay() | |
| 189 | + | bw.modeFrame = newModeFrame(overlay) | |
| 190 | + | collapsed := a.modeLegendCollapsed() | |
| 191 | + | bw.modeToaster = newModeToaster(overlay, bw.mainWindow.Window(), collapsed, func(next bool) { | |
| 192 | + | a.setModeLegendCollapsed(ctx, next) | |
| 193 | + | }) | |
| 193 | 194 | } | |
| 194 | 195 | ||
| 195 | 196 | func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) { | |
| @@ -359,13 +360,11 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) { | |||
| 359 | 360 | ||
| 360 | 361 | a.releaseFloatingSessionsForTab(ctx, tab.ID) | |
| 361 | 362 | if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID { | |
| 362 | - | bw.modeFrame.cancelTimers() | |
| 363 | - | bw.modeFrame.dismissLinger() | |
| 364 | - | bw.modeFrame.generation++ | |
| 365 | 363 | bw.modeFrame.setTarget(nil, "") | |
| 366 | 364 | bw.modeFrame.tabID = "" | |
| 367 | - | bw.modeFrame.hide() | |
| 368 | - | a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode) | |
| 365 | + | // The legend belonged to the released workspace; the header stays for | |
| 366 | + | // the mode. | |
| 367 | + | bw.modeToaster.release() | |
| 369 | 368 | } | |
| 370 | 369 | if a.contentCoord != nil && tab.Workspace != nil { | |
| 371 | 370 | for _, pane := range tab.Workspace.AllPanes() { | |
M internal/ui/browser_window_test.go +1 -1
| @@ -34,7 +34,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |||
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | setShellField(t, removed, "appToaster", &component.Toaster{}) | |
| 37 | - | setShellField(t, removed, "modeToaster", &component.Toaster{}) | |
| 37 | + | setShellField(t, removed, "modeToaster", &modeToaster{}) | |
| 38 | 38 | setShellField(t, removed, "modeFrame", &modeFrame{}) | |
| 39 | 39 | setShellField(t, removed, "sessionManager", &component.SessionManager{}) | |
| 40 | 40 | setShellField(t, removed, "tabPicker", &component.TabPicker{}) | |
M internal/ui/component/toaster.go +0 -31
| @@ -80,9 +80,6 @@ type ToastOptions struct { | |||
| 80 | 80 | TextColor string | |
| 81 | 81 | // Position determines where the toast appears on screen. | |
| 82 | 82 | Position ToastPosition | |
| 83 | - | // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode"). | |
| 84 | - | // When set, this class is applied atomically with Show() to avoid visual flicker. | |
| 85 | - | ModeClass string | |
| 86 | 83 | } | |
| 87 | 84 | ||
| 88 | 85 | // ToastOption is a functional option for configuring toast display. | |
| @@ -117,14 +114,6 @@ func WithPosition(pos ToastPosition) ToastOption { | |||
| 117 | 114 | } | |
| 118 | 115 | } | |
| 119 | 116 | ||
| 120 | - | // WithModeClass sets a mode-specific CSS class for styling. | |
| 121 | - | // This is applied atomically within Show() to avoid visual flicker. | |
| 122 | - | func WithModeClass(class string) ToastOption { | |
| 123 | - | return func(o *ToastOptions) { | |
| 124 | - | o.ModeClass = class | |
| 125 | - | } | |
| 126 | - | } | |
| 127 | - | ||
| 128 | 117 | // defaultToastOptions returns the default toast options. | |
| 129 | 118 | func defaultToastOptions() ToastOptions { | |
| 130 | 119 | return ToastOptions{ | |
| @@ -150,7 +139,6 @@ type Toaster struct { | |||
| 150 | 139 | hasCustomStyle bool | |
| 151 | 140 | customBgColor string // Current custom background color | |
| 152 | 141 | customTextColor string // Current custom text color | |
| 153 | - | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | |
| 154 | 142 | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | |
| 155 | 143 | ||
| 156 | 144 | mu sync.Mutex | |
| @@ -223,11 +211,6 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op | |||
| 223 | 211 | // Apply custom colors if specified | |
| 224 | 212 | t.applyCustomStyle(options) | |
| 225 | 213 | ||
| 226 | - | // Apply mode class if specified (atomically to avoid flicker) | |
| 227 | - | if options.ModeClass != "" { | |
| 228 | - | t.applyModeClassInternal(options.ModeClass) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | 214 | // Apply position | |
| 232 | 215 | t.applyPosition(options.Position) | |
| 233 | 216 | ||
| @@ -385,11 +368,6 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||
| 385 | 368 | func (t *Toaster) clearCustomStyle() { | |
| 386 | 369 | if t.hasCustomStyle { | |
| 387 | 370 | t.container.RemoveCssClass("toast-custom") | |
| 388 | - | // Remove mode-specific class if one was applied | |
| 389 | - | if t.customModeClass != "" { | |
| 390 | - | t.container.RemoveCssClass(t.customModeClass) | |
| 391 | - | t.customModeClass = "" | |
| 392 | - | } | |
| 393 | 371 | // Remove custom CSS provider if one was added | |
| 394 | 372 | if t.customCSSProvider != nil { | |
| 395 | 373 | styleCtx := t.container.GtkWidget().GetStyleContext() | |
| @@ -407,15 +385,6 @@ func (t *Toaster) clearCustomStyle() { | |||
| 407 | 385 | } | |
| 408 | 386 | } | |
| 409 | 387 | ||
| 410 | - | // applyModeClassInternal applies mode class without locking (must be called with lock held). | |
| 411 | - | func (t *Toaster) applyModeClassInternal(modeClass string) { | |
| 412 | - | // Remove level class and add mode class | |
| 413 | - | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 414 | - | t.container.AddCssClass(modeClass) | |
| 415 | - | t.hasCustomStyle = true | |
| 416 | - | t.customModeClass = modeClass // Store class name for cleanup tracking | |
| 417 | - | } | |
| 418 | - | ||
| 419 | 388 | // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration. | |
| 420 | 389 | // Must be called with lock held. | |
| 421 | 390 | func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) { | |
M internal/ui/deps.go +3 -0
| @@ -130,6 +130,9 @@ func (d *Dependencies) Validate() error { | |||
| 130 | 130 | if d.HandlerDeps.SaveOmniboxInitialBehavior == nil { | |
| 131 | 131 | return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior") | |
| 132 | 132 | } | |
| 133 | + | if d.HandlerDeps.SaveModeLegendCollapsed == nil { | |
| 134 | + | return ErrMissingDependency("HandlerDeps.SaveModeLegendCollapsed") | |
| 135 | + | } | |
| 133 | 136 | // Use cases are optional - can be nil if not needed | |
| 134 | 137 | return nil | |
| 135 | 138 | } | |
M internal/ui/mode_frame.go +33 -481
| @@ -1,106 +1,41 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "strings" | |
| 5 | - | ||
| 6 | 4 | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 5 | "github.com/bnema/dumber/internal/ui/input" | |
| 8 | 6 | "github.com/bnema/dumber/internal/ui/layout" | |
| 9 | - | "github.com/bnema/puregotk/v4/gdk" | |
| 10 | 7 | "github.com/bnema/puregotk/v4/glib" | |
| 11 | 8 | "github.com/bnema/puregotk/v4/gobject" | |
| 12 | 9 | "github.com/bnema/puregotk/v4/graphene" | |
| 13 | 10 | "github.com/bnema/puregotk/v4/gtk" | |
| 14 | 11 | ) | |
| 15 | 12 | ||
| 16 | - | const ( | |
| 17 | - | legendColumnSpacing = 8 | |
| 18 | - | legendMaxWidth = 700 | |
| 19 | - | legendCompactWidth = 460 | |
| 20 | - | legendTwoColumnWidth = 650 | |
| 21 | - | legendInset = 16 | |
| 22 | - | legendHeaderSpace = 80 | |
| 23 | - | legendRefreshMs = 100 | |
| 24 | - | ) | |
| 13 | + | const borderRefreshMs = 100 | |
| 25 | 14 | ||
| 26 | - | // modeFrame owns the per-window mode border and its non-interactive legend. | |
| 27 | - | // GTK access is restricted to the main thread, as with the rest of the UI. | |
| 15 | + | // modeFrame owns the per-window mode border drawn around its target pane or | |
| 16 | + | // workspace, and the Vim pulse on it. The mode indicator and legend belong to | |
| 17 | + | // modeToaster. GTK access is restricted to the main thread, as with the rest of | |
| 18 | + | // the UI. | |
| 28 | 19 | type modeFrame struct { | |
| 29 | 20 | mode input.Mode | |
| 30 | 21 | tabID entity.TabID | |
| 31 | 22 | target layout.Widget | |
| 32 | 23 | frameClass string | |
| 33 | - | root *gtk.Box | |
| 34 | 24 | border *gtk.Box | |
| 35 | 25 | borderStyle cssClassTarget // border's CSS classes; a seam for tests | |
| 36 | 26 | borderRect func() (x, y, width, height int, ok bool) | |
| 37 | - | panel *gtk.Box | |
| 38 | - | content *gtk.FlowBox | |
| 39 | - | scroller *gtk.ScrolledWindow | |
| 40 | - | heading *gtk.Label | |
| 41 | - | rows map[string][]*gtk.Widget | |
| 42 | - | keycaps map[string][]*gtk.Widget | |
| 43 | - | pending string | |
| 44 | - | visible bool | |
| 45 | - | // suspended hides the legend while an in-page interaction (link hints, | |
| 46 | - | // visual selection) owns the keys; the border keeps tracking its target. | |
| 47 | - | suspended bool | |
| 48 | - | style string | |
| 49 | - | modeClass string | |
| 50 | - | config entity.WorkspaceStylingConfig | |
| 51 | - | showTimer uint | |
| 52 | - | flashTimer uint | |
| 53 | - | generation uint64 | |
| 54 | - | placement func() (x, y, width, height int, ok bool) | |
| 55 | - | refreshSource uint | |
| 56 | - | lastColumns uint | |
| 57 | - | lastPanelWidth int | |
| 58 | - | lastMaxHeight int | |
| 59 | - | overlay *gtk.Overlay | |
| 60 | - | window *gtk.ApplicationWindow | |
| 61 | - | pressInFlight bool | |
| 62 | - | pressIdle uint | |
| 63 | - | positionHandlerID uint | |
| 64 | - | pulseCycle bool | |
| 65 | - | lingering bool | |
| 66 | - | pointerX, pointerY float64 | |
| 67 | - | pointerInside bool | |
| 68 | - | motionController *gtk.EventControllerMotion | |
| 69 | - | keyController *gtk.EventControllerKey | |
| 70 | - | clickController *gtk.GestureClick | |
| 71 | - | motionCb func(gtk.EventControllerMotion, float64, float64) | |
| 72 | - | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 73 | - | leaveCb func(gtk.EventControllerMotion) | |
| 74 | - | keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | |
| 75 | - | clickCb func(gtk.GestureClick, int, float64, float64) | |
| 27 | + | overlay *gtk.Overlay | |
| 28 | + | ||
| 29 | + | refreshSource uint | |
| 30 | + | positionHandlerID uint | |
| 31 | + | pulseCycle bool | |
| 76 | 32 | } | |
| 77 | 33 | ||
| 78 | - | func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame { | |
| 79 | - | if overlay == nil || window == nil { | |
| 34 | + | func newModeFrame(overlay *gtk.Overlay) *modeFrame { | |
| 35 | + | if overlay == nil { | |
| 80 | 36 | return nil | |
| 81 | 37 | } | |
| 82 | - | f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window} | |
| 83 | - | f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 84 | - | f.root.SetHalign(gtk.AlignStartValue) | |
| 85 | - | f.root.SetValign(gtk.AlignStartValue) | |
| 86 | - | f.root.SetCanFocus(false) | |
| 87 | - | f.root.SetCanTarget(false) | |
| 88 | - | f.root.SetVisible(false) | |
| 89 | - | f.root.AddCssClass("mode-legend-anchor") | |
| 90 | - | f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 91 | - | f.panel.SetCanFocus(false) | |
| 92 | - | f.panel.SetCanTarget(false) | |
| 93 | - | f.panel.AddCssClass("omnibox-container") | |
| 94 | - | f.panel.AddCssClass("mode-legend-panel") | |
| 95 | - | f.root.Append(&f.panel.Widget) | |
| 96 | - | title := "" | |
| 97 | - | f.heading = gtk.NewLabel(&title) | |
| 98 | - | f.heading.SetCanTarget(false) | |
| 99 | - | f.heading.SetCanFocus(false) | |
| 100 | - | f.heading.SetHalign(gtk.AlignStartValue) | |
| 101 | - | f.heading.AddCssClass("mode-legend-title") | |
| 102 | - | f.panel.Append(&f.heading.Widget) | |
| 103 | - | f.initLegendContent() | |
| 38 | + | f := &modeFrame{mode: input.ModeNormal, overlay: overlay} | |
| 104 | 39 | // The border is its own overlay: inset shadows on pane containers are | |
| 105 | 40 | // painted below web content and would be invisible. | |
| 106 | 41 | f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| @@ -112,129 +47,36 @@ func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFram | |||
| 112 | 47 | overlay.AddOverlay(&f.border.Widget) | |
| 113 | 48 | overlay.SetClipOverlay(&f.border.Widget, false) | |
| 114 | 49 | overlay.SetMeasureOverlay(&f.border.Widget, false) | |
| 115 | - | overlay.AddOverlay(&f.root.Widget) | |
| 116 | - | overlay.SetClipOverlay(&f.root.Widget, false) | |
| 117 | - | overlay.SetMeasureOverlay(&f.root.Widget, false) | |
| 118 | 50 | // A single positioned overlay tracks the active pane without reparenting it. | |
| 119 | 51 | cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool { | |
| 120 | - | placement := f.placement | |
| 121 | - | if f.border != nil && widgetPtr == f.border.GoPointer() { | |
| 122 | - | placement = f.borderRect | |
| 123 | - | } else if widgetPtr != f.root.GoPointer() { | |
| 124 | - | return false | |
| 125 | - | } | |
| 126 | - | if placement == nil { | |
| 52 | + | if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil { | |
| 127 | 53 | return false | |
| 128 | 54 | } | |
| 129 | - | x, y, w, h, ok := placement() | |
| 55 | + | x, y, w, h, ok := f.borderRect() | |
| 130 | 56 | if !ok { | |
| 131 | 57 | return false | |
| 132 | 58 | } | |
| 133 | 59 | return writeOverlayAllocation(allocation, x, y, w, h) | |
| 134 | 60 | } | |
| 135 | 61 | f.positionHandlerID = overlay.ConnectGetChildPosition(&cb) | |
| 136 | - | f.installDismissControllers() | |
| 137 | 62 | return f | |
| 138 | 63 | } | |
| 139 | 64 | ||
| 140 | - | func (f *modeFrame) installDismissControllers() { | |
| 141 | - | overlay := f.overlay | |
| 142 | - | window := f.window | |
| 143 | - | f.motionController = gtk.NewEventControllerMotion() | |
| 144 | - | f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) { | |
| 145 | - | f.pointerX, f.pointerY, f.pointerInside = x, y, true | |
| 146 | - | } | |
| 147 | - | f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false } | |
| 148 | - | f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) { | |
| 149 | - | f.pointerX, f.pointerY, f.pointerInside = x, y, true | |
| 150 | - | } | |
| 151 | - | f.motionController.ConnectEnter(&f.enterCb) | |
| 152 | - | f.motionController.ConnectMotion(&f.motionCb) | |
| 153 | - | f.motionController.ConnectLeave(&f.leaveCb) | |
| 154 | - | overlay.AddController(&f.motionController.EventController) | |
| 155 | - | f.keyController = gtk.NewEventControllerKey() | |
| 156 | - | f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 157 | - | f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool { | |
| 158 | - | f.dismissLinger() | |
| 159 | - | return false | |
| 160 | - | } | |
| 161 | - | f.keyController.ConnectKeyPressed(&f.keyCb) | |
| 162 | - | window.AddController(&f.keyController.EventController) | |
| 163 | - | f.clickController = gtk.NewGestureClick() | |
| 164 | - | f.clickController.SetButton(0) | |
| 165 | - | f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 166 | - | f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) { | |
| 167 | - | f.dismissLinger() | |
| 168 | - | f.pressInFlight = true | |
| 169 | - | if f.pressIdle != 0 { | |
| 170 | - | glib.SourceRemove(f.pressIdle) | |
| 171 | - | } | |
| 172 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 173 | - | f.pressInFlight = false | |
| 174 | - | f.pressIdle = 0 | |
| 175 | - | return false | |
| 176 | - | }) | |
| 177 | - | f.pressIdle = glib.IdleAdd(&cb, 0) | |
| 178 | - | } | |
| 179 | - | f.clickController.ConnectPressed(&f.clickCb) | |
| 180 | - | window.AddController(&f.clickController.EventController) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | func (f *modeFrame) initLegendContent() { | |
| 184 | - | f.content = gtk.NewFlowBox() | |
| 185 | - | f.content.SetCanFocus(false) | |
| 186 | - | f.content.SetCanTarget(false) | |
| 187 | - | f.content.SetOrientation(gtk.OrientationHorizontalValue) | |
| 188 | - | f.content.SetSelectionMode(gtk.SelectionNoneValue) | |
| 189 | - | f.content.SetMinChildrenPerLine(1) | |
| 190 | - | f.content.SetMaxChildrenPerLine(3) | |
| 191 | - | f.content.SetColumnSpacing(legendColumnSpacing) | |
| 192 | - | f.content.SetRowSpacing(4) | |
| 193 | - | f.content.AddCssClass("mode-legend-content") | |
| 194 | - | f.scroller = gtk.NewScrolledWindow() | |
| 195 | - | f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 196 | - | f.scroller.SetPropagateNaturalHeight(true) | |
| 197 | - | f.scroller.SetHasFrame(false) | |
| 198 | - | f.scroller.SetCanFocus(false) | |
| 199 | - | f.scroller.SetCanTarget(false) | |
| 200 | - | f.scroller.SetChild(&f.content.Widget) | |
| 201 | - | f.panel.Append(&f.scroller.Widget) | |
| 202 | - | } | |
| 203 | - | ||
| 204 | 65 | func (f *modeFrame) destroy() { | |
| 205 | 66 | if f == nil { | |
| 206 | 67 | return | |
| 207 | 68 | } | |
| 208 | - | f.cancelTimers() | |
| 209 | - | f.dismissLinger() | |
| 210 | 69 | f.stopGeometryRefresh() | |
| 211 | - | if f.pressIdle != 0 { | |
| 212 | - | glib.SourceRemove(f.pressIdle) | |
| 213 | - | f.pressIdle = 0 | |
| 214 | - | } | |
| 215 | - | if f.overlay != nil { | |
| 216 | - | f.overlay.RemoveController(&f.motionController.EventController) | |
| 217 | - | } | |
| 218 | - | if f.window != nil { | |
| 219 | - | f.window.RemoveController(&f.keyController.EventController) | |
| 220 | - | f.window.RemoveController(&f.clickController.EventController) | |
| 221 | - | } | |
| 222 | 70 | f.setTarget(nil, "") | |
| 223 | 71 | if f.overlay != nil && f.positionHandlerID != 0 { | |
| 224 | 72 | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID) | |
| 225 | 73 | f.positionHandlerID = 0 | |
| 226 | 74 | } | |
| 227 | - | f.placement = nil | |
| 228 | 75 | f.borderRect = nil | |
| 229 | - | if f.overlay != nil && f.root != nil { | |
| 230 | - | f.overlay.RemoveOverlay(&f.root.Widget) | |
| 231 | - | } | |
| 232 | 76 | if f.overlay != nil && f.border != nil { | |
| 233 | 77 | f.overlay.RemoveOverlay(&f.border.Widget) | |
| 234 | 78 | } | |
| 235 | 79 | f.overlay = nil | |
| 236 | - | f.window = nil | |
| 237 | - | f.root = nil | |
| 238 | 80 | f.border = nil | |
| 239 | 81 | f.borderStyle = nil | |
| 240 | 82 | } | |
| @@ -244,6 +86,17 @@ type cssClassTarget interface { | |||
| 244 | 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 | 100 | func (f *modeFrame) setTarget(target layout.Widget, class string) { | |
| 248 | 101 | if f == nil { | |
| 249 | 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 | 170 | func (f *modeFrame) stopGeometryRefresh() { | |
| 479 | 171 | if f == nil || f.refreshSource == 0 { | |
| 480 | 172 | return | |
| @@ -483,92 +175,40 @@ func (f *modeFrame) stopGeometryRefresh() { | |||
| 483 | 175 | f.refreshSource = 0 | |
| 484 | 176 | } | |
| 485 | 177 | ||
| 486 | - | func (f *modeFrame) show() { | |
| 487 | - | if f == nil || f.mode == input.ModeNormal || f.target == nil { | |
| 488 | - | return | |
| 489 | - | } | |
| 490 | - | f.visible = true | |
| 491 | - | f.startGeometryRefresh() | |
| 492 | - | } | |
| 493 | - | ||
| 494 | - | // Measure outside GTK allocation while the legend is active, including window | |
| 178 | + | // Measure outside GTK allocation while the border is active, including window | |
| 495 | 179 | // resizes and splitter drags after the initial layout has settled. | |
| 496 | 180 | func (f *modeFrame) startGeometryRefresh() { | |
| 497 | 181 | f.stopGeometryRefresh() | |
| 498 | 182 | f.refreshGeometry() | |
| 499 | 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 | 185 | f.refreshSource = 0 | |
| 502 | 186 | return false | |
| 503 | 187 | } | |
| 504 | 188 | f.refreshGeometry() | |
| 505 | 189 | return true | |
| 506 | 190 | }) | |
| 507 | - | f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0) | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | func (f *modeFrame) resetLegendHeight() { | |
| 511 | - | if f.lastMaxHeight != 0 { | |
| 512 | - | f.scroller.SetSizeRequest(-1, -1) | |
| 513 | - | f.scroller.SetPropagateNaturalHeight(true) | |
| 514 | - | f.lastMaxHeight = 0 | |
| 515 | - | } | |
| 191 | + | f.refreshSource = glib.TimeoutAdd(borderRefreshMs, &cb, 0) | |
| 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 | 195 | func (f *modeFrame) clearPlacement() { | |
| 520 | 196 | if f.borderRect != nil { | |
| 521 | 197 | f.borderRect = nil | |
| 522 | 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 { | |
| 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. | |
| 202 | + | // refreshGeometry measures the target outside GTK's positioning callback. | |
| 558 | 203 | func (f *modeFrame) refreshGeometry() { | |
| 559 | - | if f.lingering { | |
| 560 | - | return | |
| 561 | - | } | |
| 562 | - | previous := f.placement | |
| 563 | 204 | if f.target == nil || f.overlay == nil { | |
| 564 | 205 | f.clearPlacement() | |
| 565 | 206 | return | |
| 566 | 207 | } | |
| 567 | - | overlayWidget := &f.overlay.Widget | |
| 568 | 208 | point := &graphene.Point{X: 0, Y: 0} | |
| 569 | 209 | out := &graphene.Point{} | |
| 570 | 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 | 212 | f.clearPlacement() | |
| 573 | 213 | return | |
| 574 | 214 | } | |
| @@ -577,48 +217,7 @@ func (f *modeFrame) refreshGeometry() { | |||
| 577 | 217 | f.clearPlacement() | |
| 578 | 218 | return | |
| 579 | 219 | } | |
| 580 | - | x, bottom := int(out.X), int(out.Y)+height | |
| 581 | - | f.placeBorder(x, int(out.Y), width, height) | |
| 582 | - | if !f.visible || f.suspended { | |
| 583 | - | f.clearLegend() | |
| 584 | - | return | |
| 585 | - | } | |
| 586 | - | // Keep every keycap visible even in a narrow split. No webview resize. | |
| 587 | - | panelWidth := min(width-legendInset, legendMaxWidth) | |
| 588 | - | columns := uint(3) | |
| 589 | - | if panelWidth < legendCompactWidth { | |
| 590 | - | panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth) | |
| 591 | - | x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2 | |
| 592 | - | columns = 1 | |
| 593 | - | } else { | |
| 594 | - | if panelWidth < legendTwoColumnWidth { | |
| 595 | - | columns = 2 | |
| 596 | - | } | |
| 597 | - | x += (width - panelWidth) / 2 | |
| 598 | - | } | |
| 599 | - | if panelWidth <= 0 { | |
| 600 | - | f.clearLegend() | |
| 601 | - | return | |
| 602 | - | } | |
| 603 | - | f.sizeLegendColumns(columns, panelWidth) | |
| 604 | - | naturalHeight := f.measureLegendHeight(panelWidth) | |
| 605 | - | if naturalHeight <= 0 { | |
| 606 | - | f.clearLegend() | |
| 607 | - | return | |
| 608 | - | } | |
| 609 | - | y := bottom - naturalHeight - 4 | |
| 610 | - | if y < 0 { | |
| 611 | - | y = 0 | |
| 612 | - | } | |
| 613 | - | f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true } | |
| 614 | - | f.root.SetVisible(true) | |
| 615 | - | if previous != nil { | |
| 616 | - | oldX, oldY, oldWidth, oldHeight, _ := previous() | |
| 617 | - | if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight { | |
| 618 | - | return | |
| 619 | - | } | |
| 620 | - | } | |
| 621 | - | f.root.QueueAllocate() | |
| 220 | + | f.placeBorder(int(out.X), int(out.Y), width, height) | |
| 622 | 221 | } | |
| 623 | 222 | ||
| 624 | 223 | func (f *modeFrame) placeBorder(x, y, width, height int) { | |
| @@ -631,50 +230,3 @@ func (f *modeFrame) placeBorder(x, y, width, height int) { | |||
| 631 | 230 | f.border.SetVisible(true) | |
| 632 | 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 | 28 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { | |
| 29 | 29 | return nil | |
| 30 | 30 | }, | |
| 31 | + | SaveModeLegendCollapsed: func(context.Context, bool) error { | |
| 32 | + | return nil | |
| 33 | + | }, | |
| 31 | 34 | }, | |
| 32 | 35 | } | |
| 33 | 36 | ||
M internal/ui/theme/css.go +0 -36
| @@ -1404,42 +1404,6 @@ func generateToasterCSS(p Palette) string { | |||
| 1404 | 1404 | background-color: alpha(var(--destructive), 0.9); | |
| 1405 | 1405 | color: var(--destructive-text); | |
| 1406 | 1406 | } | |
| 1407 | - | ||
| 1408 | - | /* Toast with custom styling (mode indicator toasters) */ | |
| 1409 | - | .toast-custom { | |
| 1410 | - | color: #ffffff; | |
| 1411 | - | } | |
| 1412 | - | ||
| 1413 | - | /* Mode-specific toast colors */ | |
| 1414 | - | .toast-pane-mode { | |
| 1415 | - | background-color: var(--pane-mode-color); | |
| 1416 | - | color: var(--pane-mode-text); | |
| 1417 | - | } | |
| 1418 | - | ||
| 1419 | - | .toast-vim-mode { | |
| 1420 | - | background-color: var(--vim-mode-color); | |
| 1421 | - | color: var(--vim-mode-text); | |
| 1422 | - | } | |
| 1423 | - | ||
| 1424 | - | .toast-tab-mode { | |
| 1425 | - | background-color: var(--tab-mode-color); | |
| 1426 | - | color: var(--tab-mode-text); | |
| 1427 | - | } | |
| 1428 | - | ||
| 1429 | - | .toast-session-mode { | |
| 1430 | - | background-color: var(--session-mode-color); | |
| 1431 | - | color: var(--session-mode-text); | |
| 1432 | - | } | |
| 1433 | - | ||
| 1434 | - | .toast-resize-mode { | |
| 1435 | - | background-color: var(--resize-mode-color); | |
| 1436 | - | color: var(--resize-mode-text); | |
| 1437 | - | } | |
| 1438 | - | ||
| 1439 | - | .toast-prefix-mode { | |
| 1440 | - | background-color: var(--pane-mode-color); | |
| 1441 | - | color: var(--pane-mode-text); | |
| 1442 | - | } | |
| 1443 | 1407 | ` | |
| 1444 | 1408 | } | |
| 1445 | 1409 | ||
M internal/ui/theme/css_test.go +14 -0
| @@ -427,3 +427,17 @@ func TestVimModeUsesOwnColor(t *testing.T) { | |||
| 427 | 427 | t.Fatal("palette missing Vim color") | |
| 428 | 428 | } | |
| 429 | 429 | } | |
| 430 | + | ||
| 431 | + | func TestModeLegendCSSStylesToasterPillAndBand(t *testing.T) { | |
| 432 | + | css := generateModeLegendCSS() | |
| 433 | + | for _, mode := range []string{"pane", "vim", "resize", "tab", "session", "prefix"} { | |
| 434 | + | assert.Contains(t, css, ".mode-toaster-pill.mode-toaster-"+mode+" {", mode) | |
| 435 | + | assert.Contains(t, css, ".omnibox-container.mode-legend-panel.mode-legend-"+mode+" {", mode) | |
| 436 | + | } | |
| 437 | + | for _, class := range []string{".mode-toaster-chevron", ".mode-toaster-interactive", ".mode-legend-columns", ".mode-legend-column", ".mode-legend-hint"} { | |
| 438 | + | assert.Contains(t, css, class) | |
| 439 | + | } | |
| 440 | + | assert.NotContains(t, css, "mode-legend-title", "the legend no longer has its own heading") | |
| 441 | + | assert.NotContains(t, css, "mode-legend-content", "the flow box is gone") | |
| 442 | + | assert.NotContains(t, GenerateCSS(DefaultDarkPalette()), ".toast-vim-mode", "modes no longer reuse the generic toast") | |
| 443 | + | } | |
M internal/ui/theme/mode_legend_css.go +22 -10
| @@ -17,26 +17,38 @@ var modeLegendColors = []struct{ mode, color, text string }{ | |||
| 17 | 17 | ||
| 18 | 18 | func generateModeLegendCSS() string { | |
| 19 | 19 | var sb strings.Builder | |
| 20 | - | sb.WriteString(`/* Mode frame legend: omnibox surface, tinted with the active mode color. */ | |
| 20 | + | sb.WriteString(`/* Mode toaster: a bottom-left pill whose legend expands above it. The legend | |
| 21 | + | is an omnibox surface tinted with the active mode color. */ | |
| 21 | 22 | .mode-legend-anchor { background-color: transparent; } | |
| 23 | + | .mode-toaster-pill { | |
| 24 | + | border-radius: 0.25em; | |
| 25 | + | padding: 0.375em 0.625em; | |
| 26 | + | font-size: 0.875em; | |
| 27 | + | font-weight: 600; | |
| 28 | + | box-shadow: 0 2px 8px alpha(black, 0.3); | |
| 29 | + | min-width: 2.5em; | |
| 30 | + | } | |
| 31 | + | .mode-toaster-chevron { margin-left: 0.5em; font-size: 0.75em; opacity: 0.85; } | |
| 32 | + | .mode-toaster-pill.mode-toaster-interactive:hover { box-shadow: 0 2px 10px alpha(black, 0.45); } | |
| 33 | + | .mode-toaster-pill.mode-toaster-interactive:hover .mode-toaster-chevron { opacity: 1; } | |
| 22 | 34 | .omnibox-container.mode-legend-panel { | |
| 23 | 35 | border-style: solid; | |
| 24 | 36 | border-width: 0.125em; | |
| 25 | - | padding: 0 0 0.375em 0; | |
| 37 | + | padding: 0.625em 0.5em 0.375em 0.5em; | |
| 26 | 38 | min-width: 0; | |
| 27 | 39 | animation: mode-legend-appear 120ms ease-out; | |
| 28 | 40 | } | |
| 29 | 41 | .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; } | |
| 30 | 42 | .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; } | |
| 31 | - | .mode-legend-title { | |
| 43 | + | .mode-legend-hint { | |
| 32 | 44 | font-family: var(--font-mono); | |
| 33 | - | font-size: 0.8125em; | |
| 34 | - | font-weight: bold; | |
| 35 | - | padding: 0.375em 0.75em; | |
| 36 | - | margin-bottom: 0.375em; | |
| 45 | + | font-size: 0.6875em; | |
| 46 | + | color: var(--surface-muted); | |
| 47 | + | margin: 0.25em 0.5em 0 0; | |
| 37 | 48 | } | |
| 38 | - | .mode-legend-content { padding: 0 0.25em; } | |
| 39 | - | .mode-legend-group { margin: 0 0.375em; min-width: 0; } | |
| 49 | + | .mode-legend-columns { padding: 0 0.25em; } | |
| 50 | + | .mode-legend-column { min-width: 0; } | |
| 51 | + | .mode-legend-group { margin: 0 0.375em 0.5em 0.375em; min-width: 0; } | |
| 40 | 52 | .mode-legend-group-title { | |
| 41 | 53 | font-family: var(--font-mono); | |
| 42 | 54 | font-size: 0.6875em; | |
| @@ -66,7 +78,7 @@ func generateModeLegendCSS() string { | |||
| 66 | 78 | `) | |
| 67 | 79 | for _, m := range modeLegendColors { | |
| 68 | 80 | fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); } | |
| 69 | - | .mode-legend-%[1]s .mode-legend-title { background-color: var(%[2]s); color: var(%[3]s); } | |
| 81 | + | .mode-toaster-pill.mode-toaster-%[1]s { background-color: var(%[2]s); color: var(%[3]s); } | |
| 70 | 82 | .mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); } | |
| 71 | 83 | .mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); } | |
| 72 | 84 | .mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: var(%[3]s); } | |
M internal/ui/vim_mode.go +6 -44
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | - | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 10 | "github.com/bnema/dumber/internal/ui/input" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| @@ -127,10 +126,8 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||
| 127 | 126 | // Entering Vim Mode accents the active pane; leaving removes the accent. | |
| 128 | 127 | a.handleVimModeOwnership(ctx, bw, to, from) | |
| 129 | 128 | ||
| 129 | + | // Retarget the border and reconcile the mode toaster (indicator + legend). | |
| 130 | 130 | a.updateModeFrame(bw, to) | |
| 131 | - | ||
| 132 | - | // Show/hide this window's mode indicator toaster based on mode and config. | |
| 133 | - | a.updateModeIndicatorToaster(ctx, bw, to) | |
| 134 | 131 | } | |
| 135 | 132 | ||
| 136 | 133 | // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent | |
| @@ -277,25 +274,6 @@ func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) { | |||
| 277 | 274 | a.vimMode.resetPulse() | |
| 278 | 275 | } | |
| 279 | 276 | ||
| 280 | - | // updateModeIndicatorToaster reconciles one window's mode toaster with its | |
| 281 | - | // current mode and the live mode-indicator preference. | |
| 282 | - | func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) { | |
| 283 | - | if bw == nil || bw.modeToaster == nil { | |
| 284 | - | return | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal { | |
| 288 | - | bw.modeToaster.Hide() | |
| 289 | - | return | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 293 | - | component.WithDuration(0), | |
| 294 | - | component.WithPosition(component.ToastPositionBottomLeft), | |
| 295 | - | component.WithModeClass(getModeToastClass(mode)), | |
| 296 | - | ) | |
| 297 | - | } | |
| 298 | - | ||
| 299 | 277 | // bindVimModeSequenceToaster wires pending Vim-sequence text onto this | |
| 300 | 278 | // window's modeToaster only (never appToaster / pane toasters). | |
| 301 | 279 | func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) { | |
| @@ -312,7 +290,7 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) | |||
| 312 | 290 | ||
| 313 | 291 | // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster | |
| 314 | 292 | // while a sequence is incomplete, and restores stable "VIM MODE" when pending | |
| 315 | - | // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster; | |
| 293 | + | // clears. Mode exit is handled by handleModeChange/updateModeFrame; | |
| 316 | 294 | // silent reset under ModalState avoids stale pending callbacks. | |
| 317 | 295 | func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) { | |
| 318 | 296 | if a == nil || bw == nil { | |
| @@ -323,15 +301,11 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin | |||
| 323 | 301 | // sub-mode, and the legend is suspended. Nothing to repaint. | |
| 324 | 302 | return | |
| 325 | 303 | } | |
| 326 | - | if bw.modeFrame != nil { | |
| 327 | - | cfg := a.runtimeConfigSnapshot().UI.Workspace | |
| 328 | - | bw.modeFrame.setPending(pending, cfg.VimMode.Actions) | |
| 329 | - | } | |
| 330 | 304 | if bw.modeToaster == nil { | |
| 331 | 305 | return | |
| 332 | 306 | } | |
| 333 | 307 | ||
| 334 | - | a.showVimModeToast(ctx, bw, pending) | |
| 308 | + | bw.modeToaster.setPending(pending) | |
| 335 | 309 | logging.FromContext(ctx).Debug(). | |
| 336 | 310 | Str("window_id", bw.id). | |
| 337 | 311 | Str("pending", pending). | |
| @@ -339,22 +313,10 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin | |||
| 339 | 313 | } | |
| 340 | 314 | ||
| 341 | 315 | // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the | |
| 342 | - | // per-window mode toaster, honoring the indicator toggle. | |
| 343 | - | func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) { | |
| 316 | + | // per-window mode toaster. | |
| 317 | + | func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) { | |
| 344 | 318 | if bw == nil || bw.modeToaster == nil { | |
| 345 | 319 | return | |
| 346 | 320 | } | |
| 347 | - | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 348 | - | bw.modeToaster.Hide() | |
| 349 | - | return | |
| 350 | - | } | |
| 351 | - | text := input.ModeVim.DisplayName() | |
| 352 | - | if detail != "" { | |
| 353 | - | text = text + " · " + detail | |
| 354 | - | } | |
| 355 | - | bw.modeToaster.Show(ctx, text, component.ToastInfo, | |
| 356 | - | component.WithDuration(0), | |
| 357 | - | component.WithPosition(component.ToastPositionBottomLeft), | |
| 358 | - | component.WithModeClass(getModeToastClass(input.ModeVim)), | |
| 359 | - | ) | |
| 321 | + | bw.modeToaster.setDetail(detail) | |
| 360 | 322 | } | |
M internal/ui/vim_page.go +4 -4
| @@ -28,8 +28,8 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port | |||
| 28 | 28 | } | |
| 29 | 29 | }) | |
| 30 | 30 | a.showVimModeToast(ctx, bw, label) | |
| 31 | - | if bw.modeFrame != nil { | |
| 32 | - | bw.modeFrame.setSuspended(true) | |
| 31 | + | if bw.modeToaster != nil { | |
| 32 | + | bw.modeToaster.setSuspended(true) | |
| 33 | 33 | } | |
| 34 | 34 | logging.FromContext(ctx).Debug(). | |
| 35 | 35 | Str("window_id", bw.id). | |
| @@ -54,8 +54,8 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason | |||
| 54 | 54 | } | |
| 55 | 55 | if stillVim { | |
| 56 | 56 | a.showVimModeToast(ctx, bw, "") | |
| 57 | - | if bw.modeFrame != nil { | |
| 58 | - | bw.modeFrame.setSuspended(false) | |
| 57 | + | if bw.modeToaster != nil { | |
| 58 | + | bw.modeToaster.setSuspended(false) | |
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | logging.FromContext(ctx).Debug(). | |