detached 6e02386

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
44 files · +2893 -1002
M .mockery.yaml +1 -0
@@ -75,6 +75,7 @@ packages:
7575 KeybindingsProvider: {}
7676 KeybindingsSaver: {}
7777 OmniboxPreferencesSaver: {}
78+ ModeLegendPreferencesSaver: {}
7879 DownloadResponse: {}
7980 PermissionDialogPresenter: {}
8081 PermissionRepository: {}
M CHANGELOG.md +1 -0
@@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.
1515
1616 ### Changed
1717
18+- **Mode toaster and legend**: The bottom-left mode indicator and the key legend are now one expandable "mode toaster". The legend expands above the pill as a wide band (up to 1100 px, one to four columns by window width, groups kept in order with balanced column heights) instead of a centered panel. Click the toaster to collapse or expand the legend; the choice applies to every window and mode and is saved as `workspace.styling.mode_legend_collapsed`. With `mode_indicator_toaster_enabled = false` the toaster stays as the legend's header unless `mode_legend = "off"`.
1819 - **Omnibox borders**: Outer borders scale with the configured UI size and remain more pronounced for the default `command` style.
1920 - **Omnibox status indicators**: The history-order control is now a passive status badge switched with `Ctrl+R`, and favorites use a terminal-style `*` marker.
2021 - **Favorites organization**: Favorites now use a tags-first model. Existing folder assignments are migrated into deterministic tags, with collision handling and folded-name uniqueness preserved during migration.
M docs/config/index.md +5 -3
@@ -662,11 +662,12 @@ desc = "Open floating pane on GitHub"
662662 | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
663663 | `workspace.styling.vim_mode_color` | string | `"#F7768E"` | Vim mode border, pulse, toast, and legend color |
664664 | `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) |
668668 | `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. |
669669 | `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 |
670671 | `workspace.styling.transition_duration` | int | `120` | Base Vim border pulse duration (ms); legend appearance is 120 ms |
671672
672673 **Example:**
@@ -686,6 +687,7 @@ mode_legend = "delay"
686687 mode_legend_delay_ms = 500
687688 mode_legend_linger = true
688689 mode_legend_animations = true
690+mode_legend_collapsed = false
689691 transition_duration = 120
690692 ```
691693
M docs/reference/configuration.md +3 -2
@@ -143,11 +143,12 @@
143143 | `workspace.styling.session_mode_color` | string | `#9B59B6` | |
144144 | `workspace.styling.vim_mode_color` | string | `#F7768E` | Vim mode border, pulse, toast, and legend |
145145 | `workspace.styling.resize_mode_color` | string | `#00D4AA` | |
146-| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toast while a mode is active |
147-| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off` |
146+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show the bottom-left mode toaster header while a mode is active (still shown when the legend is not `off`) |
147+| `workspace.styling.mode_legend` | string | `delay` | `always`, `delay`, `off`; `off` keeps only the toaster header |
148148 | `workspace.styling.mode_legend_delay_ms` | int | `500` | >= 0; used with `delay` |
149149 | `workspace.styling.mode_legend_linger` | bool | `true` | Keep the legend visible, read-only, after its mode ends while the pointer is over it, until the next key press or click. |
150150 | `workspace.styling.mode_legend_animations` | bool | `true` | |
151+| `workspace.styling.mode_legend_collapsed` | bool | `false` | Collapse the legend to the mode toaster header; toggled by clicking the toaster and saved for all modes |
151152 | `workspace.styling.transition_duration` | int | `120` | |
152153 | `session.auto_restore` | bool | `false` | |
153154 | `session.snapshot_interval_ms` | int | `5000` | |
M docs/reference/keybindings.md +1 -1
@@ -60,7 +60,7 @@ Default actions missing from your config are always active. Your own bindings ov
6060
6161 ## Vim Mode (`Ctrl+Y`)
6262
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.
6464
6565 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.
6666
M internal/application/port/handler_deps.go +1 -0
@@ -46,6 +46,7 @@ type AutoCopyConfig interface {
4646 type HandlerDeps struct {
4747 SaveConfig func(context.Context, dto.WebUIConfig) error
4848 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
49+ SaveModeLegendCollapsed func(context.Context, bool) error
4950 KeybindingsGetter KeybindingsGetter
5051 KeybindingSetter KeybindingSetter
5152 KeybindingResetter KeybindingResetter
A internal/application/port/mocks/mode_legend_preferences.go +104 -0
@@ -0,0 +1,104 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockModeLegendPreferencesSaver creates a new instance of MockModeLegendPreferencesSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockModeLegendPreferencesSaver(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockModeLegendPreferencesSaver {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &MockModeLegendPreferencesSaver{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// MockModeLegendPreferencesSaver is an autogenerated mock type for the ModeLegendPreferencesSaver type
37+type MockModeLegendPreferencesSaver struct {
38+ mock.Mock
39+}
40+
41+type MockModeLegendPreferencesSaver_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *MockModeLegendPreferencesSaver) EXPECT() *MockModeLegendPreferencesSaver_Expecter {
46+ return &MockModeLegendPreferencesSaver_Expecter{mock: &_m.Mock}
47+}
48+
49+// SaveModeLegendCollapsed provides a mock function for the type MockModeLegendPreferencesSaver
50+func (_mock *MockModeLegendPreferencesSaver) SaveModeLegendCollapsed(ctx context.Context, collapsed bool) error {
51+ ret := _mock.Called(ctx, collapsed)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for SaveModeLegendCollapsed")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) error); ok {
59+ r0 = returnFunc(ctx, collapsed)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveModeLegendCollapsed'
67+type MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call struct {
68+ *mock.Call
69+}
70+
71+// SaveModeLegendCollapsed is a helper method to define mock.On call
72+// - ctx context.Context
73+// - collapsed bool
74+func (_e *MockModeLegendPreferencesSaver_Expecter) SaveModeLegendCollapsed(ctx any, collapsed any) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
75+ return &MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call{Call: _e.mock.On("SaveModeLegendCollapsed", ctx, collapsed)}
76+}
77+
78+func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) Run(run func(ctx context.Context, collapsed bool)) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ var arg1 bool
85+ if args[1] != nil {
86+ arg1 = args[1].(bool)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ )
92+ })
93+ return _c
94+}
95+
96+func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) Return(err error) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
97+ _c.Call.Return(err)
98+ return _c
99+}
100+
101+func (_c *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call) RunAndReturn(run func(ctx context.Context, collapsed bool) error) *MockModeLegendPreferencesSaver_SaveModeLegendCollapsed_Call {
102+ _c.Call.Return(run)
103+ return _c
104+}
A internal/application/port/mode_legend_preferences.go +8 -0
@@ -0,0 +1,8 @@
1+package port
2+
3+import "context"
4+
5+// ModeLegendPreferencesSaver persists mode legend preference changes.
6+type ModeLegendPreferencesSaver interface {
7+ SaveModeLegendCollapsed(ctx context.Context, collapsed bool) error
8+}
A internal/application/usecase/save_mode_legend_collapsed.go +25 -0
@@ -0,0 +1,25 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// SaveModeLegendCollapsedUseCase persists whether the mode legend is collapsed
11+// to its toaster header. The preference is shared by every mode and window.
12+type SaveModeLegendCollapsedUseCase struct {
13+ saver port.ModeLegendPreferencesSaver
14+}
15+
16+func NewSaveModeLegendCollapsedUseCase(saver port.ModeLegendPreferencesSaver) *SaveModeLegendCollapsedUseCase {
17+ return &SaveModeLegendCollapsedUseCase{saver: saver}
18+}
19+
20+func (uc *SaveModeLegendCollapsedUseCase) Execute(ctx context.Context, collapsed bool) error {
21+ if uc == nil || uc.saver == nil {
22+ return fmt.Errorf("mode legend preferences saver is nil")
23+ }
24+ return uc.saver.SaveModeLegendCollapsed(ctx, collapsed)
25+}
A internal/application/usecase/save_mode_legend_collapsed_test.go +34 -0
@@ -0,0 +1,34 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSaveModeLegendCollapsedUseCase_PersistsValue(t *testing.T) {
13+ ctx := context.Background()
14+ for _, collapsed := range []bool{true, false} {
15+ saver := portmocks.NewMockModeLegendPreferencesSaver(t)
16+ saver.EXPECT().SaveModeLegendCollapsed(ctx, collapsed).Return(nil).Once()
17+
18+ require.NoError(t, NewSaveModeLegendCollapsedUseCase(saver).Execute(ctx, collapsed))
19+ }
20+}
21+
22+func TestSaveModeLegendCollapsedUseCase_ReturnsSaverError(t *testing.T) {
23+ ctx := context.Background()
24+ saver := portmocks.NewMockModeLegendPreferencesSaver(t)
25+ saver.EXPECT().SaveModeLegendCollapsed(ctx, true).Return(errors.New("disk full")).Once()
26+
27+ require.ErrorContains(t, NewSaveModeLegendCollapsedUseCase(saver).Execute(ctx, true), "disk full")
28+}
29+
30+func TestSaveModeLegendCollapsedUseCase_NilSaverReturnsError(t *testing.T) {
31+ require.Error(t, NewSaveModeLegendCollapsedUseCase(nil).Execute(context.Background(), true))
32+ var uc *SaveModeLegendCollapsedUseCase
33+ require.Error(t, uc.Execute(context.Background(), true))
34+}
M internal/bootstrap/handler_deps.go +2 -0
@@ -21,10 +21,12 @@ func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) {
2121 gateway := config.NewKeybindingsGateway(mgr)
2222 saveUC := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
2323 omniboxSaveUC := usecase.NewSaveOmniboxInitialBehaviorUseCase(config.NewOmniboxPreferencesGateway(mgr))
24+ modeLegendSaveUC := usecase.NewSaveModeLegendCollapsedUseCase(config.NewModeLegendPreferencesGateway(mgr))
2425
2526 return &port.HandlerDeps{
2627 SaveConfig: saveUC.Execute,
2728 SaveOmniboxInitialBehavior: omniboxSaveUC.Execute,
29+ SaveModeLegendCollapsed: modeLegendSaveUC.Execute,
2830 KeybindingsGetter: usecase.NewGetKeybindingsUseCase(gateway),
2931 KeybindingSetter: usecase.NewSetKeybindingUseCase(gateway, gateway),
3032 KeybindingResetter: usecase.NewResetKeybindingUseCase(gateway),
M internal/domain/entity/config_types.go +1 -0
@@ -194,6 +194,7 @@ type WorkspaceStylingConfig struct {
194194 ModeLegendDelayMs int `mapstructure:"mode_legend_delay_ms" yaml:"mode_legend_delay_ms" toml:"mode_legend_delay_ms" json:"mode_legend_delay_ms"` //nolint:lll // struct tags must stay on one line
195195 ModeLegendLinger bool `mapstructure:"mode_legend_linger" yaml:"mode_legend_linger" toml:"mode_legend_linger" json:"mode_legend_linger"` //nolint:lll // struct tags must stay on one line
196196 ModeLegendAnimations bool `mapstructure:"mode_legend_animations" yaml:"mode_legend_animations" toml:"mode_legend_animations" json:"mode_legend_animations"` //nolint:lll // struct tags must stay on one line
197+ ModeLegendCollapsed bool `mapstructure:"mode_legend_collapsed" yaml:"mode_legend_collapsed" toml:"mode_legend_collapsed" json:"mode_legend_collapsed"` //nolint:lll // struct tags must stay on one line
197198
198199 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
199200 }
M internal/infrastructure/config/defaults.go +1 -0
@@ -445,6 +445,7 @@ func DefaultConfig() *Config {
445445 ModeLegend: "delay",
446446 ModeLegendDelayMs: 500,
447447 ModeLegendAnimations: true,
448+ ModeLegendCollapsed: false,
448449 TransitionDuration: defaultTransitionDuration,
449450 },
450451 },
M internal/infrastructure/config/defaults_test.go +1 -0
@@ -19,6 +19,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1919 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
2020 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
2121 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
22+ assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed)
2223 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
2324
2425 // Engine defaults (replaces old Performance/Privacy sections)
M internal/infrastructure/config/loader.go +1 -0
@@ -754,6 +754,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
754754 m.viper.SetDefault("workspace.styling.mode_legend", defaults.Workspace.Styling.ModeLegend)
755755 m.viper.SetDefault("workspace.styling.mode_legend_delay_ms", defaults.Workspace.Styling.ModeLegendDelayMs)
756756 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)
757758 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
758759 }
759760
M internal/infrastructure/config/loader_test.go +1 -0
@@ -17,6 +17,7 @@ func TestModeLegendDefaultsThroughLoader(t *testing.T) {
1717 assert.Equal(t, 500, cfg.Workspace.Styling.ModeLegendDelayMs)
1818 assert.True(t, cfg.Workspace.Styling.ModeLegendAnimations)
1919 assert.True(t, cfg.Workspace.Styling.ModeLegendLinger)
20+ assert.False(t, cfg.Workspace.Styling.ModeLegendCollapsed)
2021 assert.Equal(t, "#F7768E", cfg.Workspace.Styling.VimModeColor)
2122 }
2223
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
740740 Key: "workspace.styling.mode_indicator_toaster_enabled",
741741 Type: "bool",
742742 Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),
743- Description: "Show the mode toast while a mode is active",
743+ Description: "Show the bottom-left mode toaster while a mode is active (kept as the legend header while the legend is not off)",
744744 Section: SectionWorkspace,
745745 },
746746 {
@@ -771,6 +771,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
771771 Description: "Animate the mode legend and pressed keycaps",
772772 Section: SectionWorkspace,
773773 },
774+ {
775+ Key: "workspace.styling.mode_legend_collapsed",
776+ Type: "bool",
777+ Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeLegendCollapsed),
778+ Description: "Collapse the mode legend to its header; toggled by clicking the mode toaster and shared by all modes and windows",
779+ Section: SectionWorkspace,
780+ },
774781 {
775782 Key: "workspace.styling.transition_duration",
776783 Type: "int",
M internal/infrastructure/config/schema_provider_test.go +1 -0
@@ -25,6 +25,7 @@ func TestModeLegendSchemaDefaults(t *testing.T) {
2525 {"workspace.styling.mode_legend_linger", "bool", "true", ""},
2626 {"workspace.styling.vim_mode_color", "string", "#F7768E", ""},
2727 {"workspace.styling.mode_legend_animations", "bool", "true", ""},
28+ {"workspace.styling.mode_legend_collapsed", "bool", "false", ""},
2829 } {
2930 entry, ok := keys[tc.key]
3031 require.True(t, ok, "missing schema key %s", tc.key)
M internal/ui/app.go +2 -21
@@ -126,6 +126,7 @@ type App struct {
126126 vimModePolicyUC *usecase.VimModePolicyUseCase
127127 vimNavigationUC *usecase.VimNavigationUseCase
128128 vimMode vimModeState
129+ modeLegend modeLegendPreference
129130
130131 // Omnibox configuration (omnibox is created per workspace view)
131132 omniboxCfg component.OmniboxConfig
@@ -3929,26 +3930,6 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
39293930 a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
39303931 }
39313932
3932-// getModeToastClass returns the CSS class for the given mode's toast styling.
3933-func getModeToastClass(mode input.Mode) string {
3934- switch mode {
3935- case input.ModePane:
3936- return "toast-pane-mode"
3937- case input.ModeVim:
3938- return "toast-vim-mode"
3939- case input.ModeTab:
3940- return "toast-tab-mode"
3941- case input.ModeSession:
3942- return "toast-session-mode"
3943- case input.ModeResize:
3944- return "toast-resize-mode"
3945- case input.ModePrefix:
3946- return "toast-prefix-mode"
3947- default:
3948- return ""
3949- }
3950-}
3951-
39523933 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
39533934 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
39543935 if !a.createWorkspaceViewWithoutAttach(ctx, tab) {
@@ -4345,6 +4326,7 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
43454326 a.runtimeConfig = newRuntimeConfigState(nil)
43464327 }
43474328 a.runtimeConfig.Update(snapshot)
4329+ a.modeLegend.configReloaded(snapshot.UI.Workspace.Styling.ModeLegendCollapsed)
43484330 if a.contentCoord != nil {
43494331 a.contentCoord.UpdatePopupConfig(snapshot.UI.Workspace.BrowsingContexts)
43504332 }
@@ -4385,7 +4367,6 @@ func (a *App) applyRuntimeConfigChange(ctx context.Context, snapshot entity.Runt
43854367 mode = bw.keyboardHandler.Mode()
43864368 }
43874369 a.updateModeFrame(bw, mode)
4388- a.updateModeIndicatorToaster(ctx, bw, mode)
43894370 }
43904371 }
43914372
M internal/ui/app_clipboard_test.go +3 -0
@@ -38,6 +38,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
3838 HandlerDeps: port.HandlerDeps{
3939 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
4040 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
41+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
4142 },
4243 Clipboard: nil,
4344 }
@@ -66,6 +67,7 @@ func TestNew_ClosesHistoryRecorderWhenRegisterHandlersFails(t *testing.T) {
6667 HandlerDeps: port.HandlerDeps{
6768 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
6869 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
70+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
6971 },
7072 }
7173
@@ -101,6 +103,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
101103 HandlerDeps: port.HandlerDeps{
102104 SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
103105 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
106+ SaveModeLegendCollapsed: func(context.Context, bool) error { return nil },
104107 },
105108 Clipboard: clipboard,
106109 }
M internal/ui/app_mode_frame.go +127 -9
@@ -1,6 +1,10 @@
11 package ui
22
33 import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/logging"
48 "github.com/bnema/dumber/internal/ui/input"
59 "github.com/bnema/dumber/internal/ui/layout"
610 )
@@ -33,38 +37,152 @@ func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) {
3337 return
3438 }
3539 a.setModeFrameTab(bw)
36- newTarget := a.modeFrameTarget(bw, mode)
3740 // Rebuilds can temporarily lack a pane widget. Drop the old border while
3841 // the next focus/rebuild event retries the target.
39- bw.modeFrame.setTarget(newTarget, modeFrameClass(mode))
40- if bw.modeFrame.visible {
42+ bw.modeFrame.setTarget(a.modeFrameTarget(bw, mode), modeFrameClass(mode))
43+ if bw.modeFrame.target != nil {
4144 bw.modeFrame.refreshGeometry()
4245 }
4346 }
4447
48+// updateModeFrame reconciles the window's mode border and mode toaster with
49+// mode and the live configuration.
4550 func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {
46- if bw == nil || bw.modeFrame == nil {
51+ if bw == nil {
4752 return
4853 }
4954 a.setModeFrameTab(bw)
5055 cfg := a.runtimeConfigSnapshot().UI
51- bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode), cfg.Workspace.Styling,
52- cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
56+ if bw.modeFrame != nil {
57+ bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode))
58+ }
59+ if bw.modeToaster != nil {
60+ styling := cfg.Workspace.Styling
61+ styling.ModeLegendCollapsed = a.modeLegendCollapsed()
62+ bw.modeToaster.apply(mode, styling, cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
63+ }
5364 }
5465
5566 func (a *App) setModeFrameTab(bw *browserWindow) {
67+ if bw.modeFrame == nil {
68+ return
69+ }
5670 tabID := bw.modeFrame.tabID
5771 bw.modeFrame.tabID = ""
5872 if tab := a.activeTabForBrowserWindow(bw); tab != nil {
5973 bw.modeFrame.tabID = tab.ID
6074 }
6175 if tabID != bw.modeFrame.tabID {
62- bw.modeFrame.dismissLinger()
76+ bw.modeToaster.dismissLinger()
6377 }
6478 }
6579
6680 func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
67- if bw != nil && bw.modeFrame != nil {
68- bw.modeFrame.flash(action)
81+ if bw != nil && bw.modeToaster != nil {
82+ bw.modeToaster.flash(action)
83+ }
84+}
85+
86+// modeLegendPreference is the app-wide "legend collapsed" state, shared by
87+// every mode and window. A click is applied at once and saved in the
88+// background. It keeps priority over the configured value, which may still be
89+// the previous one while 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
69148 }
70149 }
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) {
10191019 // Mode toaster
10201020 // ============================================================================
10211021
1022-func newModeToasterForVimModeTest(t *testing.T, factory *mocks.MockWidgetFactory) (*component.Toaster, *mocks.MockBoxWidget, *mocks.MockLabelWidget) {
1023- t.Helper()
1024-
1025- container := mocks.NewMockBoxWidget(t)
1026- label := mocks.NewMockLabelWidget(t)
1027- factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(container).Once()
1028- container.EXPECT().AddCssClass("toast").Once()
1029- container.EXPECT().AddCssClass("toast-info").Once()
1030- container.EXPECT().SetHalign(mock.Anything).Once()
1031- container.EXPECT().SetValign(mock.Anything).Once()
1032- container.EXPECT().SetHexpand(false).Once()
1033- container.EXPECT().SetVexpand(false).Once()
1034- container.EXPECT().SetCanTarget(false).Once()
1035- container.EXPECT().SetCanFocus(false).Once()
1036- container.EXPECT().SetVisible(false).Once()
1037- factory.EXPECT().NewLabel("").Return(label).Once()
1038- label.EXPECT().SetCanTarget(false).Once()
1039- label.EXPECT().SetCanFocus(false).Once()
1040- container.EXPECT().Append(label).Once()
1041-
1042- return component.NewToaster(factory), container, label
1043-}
1044-
1045-func expectVimModeToastShown(container *mocks.MockBoxWidget, label *mocks.MockLabelWidget) {
1046- container.EXPECT().RemoveCssClass("toast-info").Once()
1047- container.EXPECT().AddCssClass("toast-vim-mode").Once()
1048- container.EXPECT().SetHalign(mock.Anything).Once()
1049- container.EXPECT().SetValign(mock.Anything).Once()
1050- label.EXPECT().SetText("VIM MODE").Once()
1051- container.EXPECT().SetVisible(true).Once()
1022+func enabledIndicatorSnapshot() entity.RuntimeConfigSnapshot {
1023+ return entity.RuntimeConfigSnapshot{
1024+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1025+ Styling: entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: "off"},
1026+ }},
1027+ }
10521028 }
10531029
10541030 func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
1055- factory := mocks.NewMockWidgetFactory(t)
1056- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1057- expectVimModeToastShown(container, label)
1058- // These optional calls are made only by the old brief auto-dismiss path.
1059- container.EXPECT().RemoveCssClass("toast-vim-mode").Maybe()
1060- container.EXPECT().SetVisible(false).Maybe()
1061-
1062- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1063- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1064- ModeIndicatorToasterEnabled: true,
1065- }}},
1066- })}
1067- bw := &browserWindow{id: "win-1", modeToaster: toaster}
1031+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1032+ bw, view := newFakeModeToasterWindow("win-1")
10681033 app.browserWindows = map[string]*browserWindow{bw.id: bw}
1069- app.lastFocusedWindowID = bw.id
10701034
1071- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1072- require.True(t, toaster.IsVisible())
1035+ app.updateModeFrame(bw, input.ModeVim)
1036+ require.True(t, view.pillShown)
1037+ require.Equal(t, "VIM MODE", view.header)
10731038
1039+ // Nothing but a mode change hides it: no timer dismisses the persistent pill.
10741040 deadline := time.Now().Add(time.Duration(component.ToastBriefDurationMs+100) * time.Millisecond)
10751041 mainContext := glib.MainContextDefault()
10761042 for time.Now().Before(deadline) {
@@ -1079,116 +1045,95 @@ func TestVimMode_ToasterRemainsVisibleUntilModeExit(t *testing.T) {
10791045 mainContext.Iteration(false)
10801046 }
10811047 }
1082-
1083- assert.True(t, toaster.IsVisible(), "Vim Mode toast must remain visible until mode exit")
1048+ assert.True(t, view.pillShown, "Vim Mode pill must remain visible until mode exit")
10841049 }
10851050
10861051 func TestVimMode_ModeExitHidesToaster(t *testing.T) {
1087- factory := mocks.NewMockWidgetFactory(t)
1088- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1089- expectVimModeToastShown(container, label)
1090- container.EXPECT().RemoveCssClass("toast-custom").Once()
1091- container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1092- container.EXPECT().SetVisible(false).Once()
1052+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1053+ bw, view := newFakeModeToasterWindow("win-1")
1054+
1055+ app.updateModeFrame(bw, input.ModeVim)
1056+ require.True(t, view.pillShown)
1057+ app.updateModeFrame(bw, input.ModeNormal)
10931058
1059+ assert.False(t, view.pillShown, "leaving Vim Mode must hide its persistent pill")
1060+}
1061+
1062+func TestVimMode_DisabledIndicatorWithLegendOffShowsNothing(t *testing.T) {
10941063 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1095- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1096- ModeIndicatorToasterEnabled: true,
1097- }}},
1064+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1065+ Styling: entity.WorkspaceStylingConfig{ModeLegend: "off"},
1066+ }},
10981067 })}
1099- bw := &browserWindow{id: "win-1", modeToaster: toaster}
1068+ bw, view := newFakeModeToasterWindow("win-1")
11001069
1101- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1102- require.True(t, toaster.IsVisible())
1103- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeNormal)
1070+ app.updateModeFrame(bw, input.ModeVim)
11041071
1105- assert.False(t, toaster.IsVisible(), "leaving Vim Mode must hide its persistent toast")
1072+ assert.False(t, view.pillShown)
1073+ assert.False(t, view.bodyShown)
11061074 }
11071075
1108-func TestVimMode_DisabledModeIndicatorPreferenceSuppressesToaster(t *testing.T) {
1109- factory := mocks.NewMockWidgetFactory(t)
1110- toaster, _, _ := newModeToasterForVimModeTest(t, factory)
1111- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{})}
1112- bw := &browserWindow{id: "win-1", modeToaster: toaster}
1076+func TestVimMode_DisabledIndicatorKeepsPillAsLegendHeader(t *testing.T) {
1077+ snapshot := entity.RuntimeConfigSnapshot{
1078+ UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
1079+ Styling: entity.WorkspaceStylingConfig{ModeLegend: "always"},
1080+ VimMode: entity.VimModeConfig{Actions: map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}}},
1081+ }},
1082+ }
1083+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)}
1084+ bw, view := newFakeModeToasterWindow("win-1")
11131085
1114- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1086+ app.updateModeFrame(bw, input.ModeVim)
11151087
1116- assert.False(t, toaster.IsVisible())
1088+ assert.True(t, view.pillShown, "the pill is the legend header even when the indicator is disabled")
1089+ assert.True(t, view.bodyShown)
11171090 }
11181091
11191092 func TestVimMode_MultiWindowToastersExitIndependently(t *testing.T) {
1120- factory := mocks.NewMockWidgetFactory(t)
1121- toaster1, container1, label1 := newModeToasterForVimModeTest(t, factory)
1122- toaster2, container2, label2 := newModeToasterForVimModeTest(t, factory)
1123- expectVimModeToastShown(container1, label1)
1124- expectVimModeToastShown(container2, label2)
1125- container1.EXPECT().RemoveCssClass("toast-custom").Once()
1126- container1.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1127- container1.EXPECT().SetVisible(false).Once()
1128-
1129- app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
1130- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1131- ModeIndicatorToasterEnabled: true,
1132- }}},
1133- })}
1134- bw1 := &browserWindow{id: "win-1", modeToaster: toaster1}
1135- bw2 := &browserWindow{id: "win-2", modeToaster: toaster2}
1093+ app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot())}
1094+ bw1, view1 := newFakeModeToasterWindow("win-1")
1095+ bw2, view2 := newFakeModeToasterWindow("win-2")
11361096
1137- app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeVim)
1138- app.updateModeIndicatorToaster(context.Background(), bw2, input.ModeVim)
1139- app.updateModeIndicatorToaster(context.Background(), bw1, input.ModeNormal)
1097+ app.updateModeFrame(bw1, input.ModeVim)
1098+ app.updateModeFrame(bw2, input.ModeVim)
1099+ app.updateModeFrame(bw1, input.ModeNormal)
11401100
1141- assert.False(t, toaster1.IsVisible())
1142- assert.True(t, toaster2.IsVisible(), "exiting one window must not hide another window's Vim Mode toast")
1101+ assert.False(t, view1.pillShown)
1102+ assert.True(t, view2.pillShown, "exiting one window must not hide another window's pill")
11431103 }
11441104
11451105 func TestVimMode_RuntimePreferenceDisableHidesPersistentToaster(t *testing.T) {
1146- factory := mocks.NewMockWidgetFactory(t)
1147- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1148- expectVimModeToastShown(container, label)
1149- container.EXPECT().RemoveCssClass("toast-custom").Once()
1150- container.EXPECT().RemoveCssClass("toast-vim-mode").Once()
1151- container.EXPECT().SetVisible(false).Once()
1152-
1153- enabled := entity.RuntimeConfigSnapshot{
1154- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1155- ModeIndicatorToasterEnabled: true,
1156- }}},
1157- }
11581106 keyboardHandler := newKeyboardHandlerInVimMode(t)
1159- bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1107+ bw, view := newFakeModeToasterWindow("win-1")
1108+ bw.keyboardHandler = keyboardHandler
11601109 app := &App{
1161- runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabled),
1110+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(enabledIndicatorSnapshot()),
11621111 browserWindows: map[string]*browserWindow{bw.id: bw},
11631112 }
11641113
1165- app.updateModeIndicatorToaster(context.Background(), bw, input.ModeVim)
1166- require.True(t, toaster.IsVisible())
1167- app.applyRuntimeConfigChange(context.Background(), entity.RuntimeConfigSnapshot{})
1114+ app.updateModeFrame(bw, input.ModeVim)
1115+ require.True(t, view.pillShown)
1116+ disabled := enabledIndicatorSnapshot()
1117+ disabled.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
1118+ app.applyRuntimeConfigChange(context.Background(), disabled)
11681119
1169- assert.False(t, toaster.IsVisible(), "runtime preference disable must reconcile an existing persistent toast")
1120+ assert.False(t, view.pillShown, "runtime preference disable must reconcile an existing pill")
11701121 }
11711122
11721123 func TestVimMode_RuntimePreferenceEnableShowsPersistentToaster(t *testing.T) {
1173- factory := mocks.NewMockWidgetFactory(t)
1174- toaster, container, label := newModeToasterForVimModeTest(t, factory)
1175- expectVimModeToastShown(container, label)
1176-
11771124 keyboardHandler := newKeyboardHandlerInVimMode(t)
1178- bw := &browserWindow{id: "win-1", modeToaster: toaster, keyboardHandler: keyboardHandler}
1125+ bw, view := newFakeModeToasterWindow("win-1")
1126+ bw.keyboardHandler = keyboardHandler
1127+ off := enabledIndicatorSnapshot()
1128+ off.UI.Workspace.Styling.ModeIndicatorToasterEnabled = false
11791129 app := &App{
1180- runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{}),
1130+ runtimeConfig: runtimeConfigStateFromSnapshotForTest(off),
11811131 browserWindows: map[string]*browserWindow{bw.id: bw},
11821132 }
1183- enabled := entity.RuntimeConfigSnapshot{
1184- UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
1185- ModeIndicatorToasterEnabled: true,
1186- }}},
1187- }
11881133
1189- app.applyRuntimeConfigChange(context.Background(), enabled)
1134+ app.applyRuntimeConfigChange(context.Background(), enabledIndicatorSnapshot())
11901135
1191- assert.True(t, toaster.IsVisible(), "runtime preference enable must reconcile active Vim Mode")
1136+ assert.True(t, view.pillShown, "runtime preference enable must reconcile active Vim Mode")
11921137 }
11931138
11941139 // ============================================================================
M internal/ui/app_vim_page_test.go +14 -25
@@ -5,41 +5,31 @@ import (
55 "testing"
66 "time"
77
8- "github.com/stretchr/testify/mock"
98 "github.com/stretchr/testify/require"
109
1110 "github.com/bnema/dumber/internal/application/dto"
1211 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1312 "github.com/bnema/dumber/internal/domain/entity"
1413 "github.com/bnema/dumber/internal/ui/input"
15- "github.com/bnema/dumber/internal/ui/layout/mocks"
1614 )
1715
18-// newVimPageTestWindow builds a window in Vim Mode with a real mode toaster
19-// bound to the pending-sequence callback, as initChrome does. It records every
20-// text shown on the toast so tests can assert what the user sees.
16+// newVimPageTestWindow builds a window in Vim Mode with a mode toaster bound
17+// to the pending-sequence callback, as initChrome does. It records every header
18+// the pill shows so tests can assert what the user sees.
2119 func newVimPageTestWindow(t *testing.T) (*App, *browserWindow, *[]string) {
2220 t.Helper()
23- factory := mocks.NewMockWidgetFactory(t)
24- toaster, container, label := newModeToasterForVimModeTest(t, factory)
25- shown := &[]string{}
26- container.EXPECT().RemoveCssClass(mock.Anything).Maybe()
27- container.EXPECT().AddCssClass(mock.Anything).Maybe()
28- container.EXPECT().SetHalign(mock.Anything).Maybe()
29- container.EXPECT().SetValign(mock.Anything).Maybe()
30- container.EXPECT().SetVisible(mock.Anything).Maybe()
31- label.EXPECT().SetText(mock.Anything).Run(func(text string) { *shown = append(*shown, text) }).Maybe()
32-
3321 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(entity.RuntimeConfigSnapshot{
3422 UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{Styling: entity.WorkspaceStylingConfig{
3523 ModeIndicatorToasterEnabled: true,
24+ ModeLegend: "off",
3625 }}},
3726 })}
38- bw := &browserWindow{id: "win-1", modeToaster: toaster}
27+ bw, view := newFakeModeToasterWindow("win-1")
3928 app.browserWindows = map[string]*browserWindow{bw.id: bw}
4029 bw.keyboardHandler = newKeyboardHandlerInVimMode(t)
4130 app.bindVimModeSequenceToaster(context.Background(), bw)
42- return app, bw, shown
31+ app.updateModeFrame(bw, input.ModeVim)
32+ return app, bw, &view.headers
4333 }
4434
4535 var visualOutcome = hintsOutcome(dto.VimPageVisual)
@@ -83,16 +73,15 @@ func TestVimPageSubModeLabelSurvivesSequenceCompletion(t *testing.T) {
8373 // restored when the interaction ends.
8474 func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
8575 app, bw, _ := newVimPageTestWindow(t)
86- frame := &modeFrame{mode: input.ModeVim, visible: true}
87- bw.modeFrame = frame
76+ toaster := bw.modeToaster
8877 wv := portmocks.NewMockWebView(t)
8978
9079 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
91- require.True(t, frame.suspended, "legend must be suspended while the page captures keys")
80+ require.True(t, toaster.suspended, "legend must be suspended while the page captures keys")
9281
9382 wv.EXPECT().ID().Return(1).Maybe()
9483 app.endVimPageInteraction(context.Background(), bw, true)
95- require.False(t, frame.suspended, "legend must be restored when the interaction ends")
84+ require.False(t, toaster.suspended, "legend must be restored when the interaction ends")
9685 require.False(t, bw.keyboardHandler.PageKeyCaptureActive())
9786 }
9887
@@ -101,8 +90,7 @@ func TestVimPageInteractionHidesLegendWhileCaptured(t *testing.T) {
10190 // suspended legend stay in place.
10291 func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
10392 app, bw, shown := newVimPageTestWindow(t)
104- frame := &modeFrame{mode: input.ModeVim, visible: true}
105- bw.modeFrame = frame
93+ toaster := bw.modeToaster
10694 wv := portmocks.NewMockWebView(t)
10795 app.captureVimPageKeys(context.Background(), bw, wv, visualOutcome)
10896 require.Equal(t, "VIM MODE · HINTS", (*shown)[len(*shown)-1])
@@ -117,16 +105,17 @@ func TestVimPageModeChangesRepaintToastWithoutReleasingCapture(t *testing.T) {
117105 require.Equal(t, want, (*shown)[len(*shown)-1])
118106 require.True(t, bw.keyboardHandler.PageKeyCaptureActive(), "mode %s must not release key capture", mode)
119107 require.NotNil(t, bw.vimPageInteractionWebView)
120- require.True(t, frame.suspended, "legend stays hidden while the page owns the keys")
108+ require.True(t, toaster.suspended, "legend stays hidden while the page owns the keys")
121109 }
122110 }
123111
124112 func TestVimPageModeChangeIgnoredWithoutInteraction(t *testing.T) {
125113 app, bw, shown := newVimPageTestWindow(t)
114+ before := len(*shown)
126115
127116 app.showVimPageMode(context.Background(), bw, dto.VimPageModeCaret)
128117
129- require.Empty(t, *shown, "a late mode report must not repaint the indicator once the interaction ended")
118+ require.Len(t, *shown, before, "a late mode report must not repaint the indicator once the interaction ended")
130119 }
131120
132121 // exitModeWithin runs fn and fails the test if it does not return in time,
M internal/ui/browser_window.go +18 -19
@@ -35,7 +35,7 @@ type browserWindow struct {
3535 tabs *entity.TabList // per-window tab list (single source of truth for tab state)
3636 mainWindow *window.MainWindow
3737 appToaster *component.Toaster
38- modeToaster *component.Toaster
38+ modeToaster *modeToaster
3939 touchpadNavIndicator *component.TouchpadNavigationIndicator
4040 modeFrame *modeFrame
4141 sessionManager *component.SessionManager
@@ -104,8 +104,11 @@ func (bw *browserWindow) clearShellState() {
104104 bw.touchpadNavIndicator.Destroy()
105105 }
106106 bw.appToaster = nil
107- bw.modeToaster = nil
108107 bw.touchpadNavIndicator = nil
108+ if bw.modeToaster != nil {
109+ bw.modeToaster.destroy()
110+ bw.modeToaster = nil
111+ }
109112 if bw.modeFrame != nil {
110113 bw.modeFrame.destroy()
111114 bw.modeFrame = nil
@@ -141,7 +144,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
141144 // no hidden-panel queries or widget row rebuilds.
142145 bw.initToasterOverlay(a)
143146 bw.initTouchpadNavigationIndicator()
144- bw.initBorderOverlay(a)
147+ bw.initBorderOverlay(ctx, a)
145148 bw.initAccentPicker(ctx, a)
146149 bw.initSessionManager(ctx, a)
147150 bw.initTabPicker(ctx, a)
@@ -160,15 +163,6 @@ func (bw *browserWindow) initToasterOverlay(a *App) {
160163 }
161164 }
162165 }
163-
164- bw.modeToaster = component.NewToaster(a.widgetFactory)
165- if bw.modeToaster != nil {
166- if widget := bw.modeToaster.Widget(); widget != nil {
167- if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
168- bw.mainWindow.AddOverlay(gtkWidget)
169- }
170- }
171- }
172166 }
173167
174168 func (bw *browserWindow) initTouchpadNavigationIndicator() {
@@ -184,12 +178,19 @@ func (bw *browserWindow) initTouchpadNavigationIndicator() {
184178 }
185179 }
186180
187-func (bw *browserWindow) initBorderOverlay(a *App) {
181+// initBorderOverlay creates the mode border and the mode toaster, which share
182+// the window's content overlay.
183+func (bw *browserWindow) initBorderOverlay(ctx context.Context, a *App) {
188184 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
189185 return
190186 }
191187
192- bw.modeFrame = newModeFrame(bw.mainWindow.ContentOverlay(), bw.mainWindow.Window())
188+ overlay := bw.mainWindow.ContentOverlay()
189+ bw.modeFrame = newModeFrame(overlay)
190+ collapsed := a.modeLegendCollapsed()
191+ bw.modeToaster = newModeToaster(overlay, bw.mainWindow.Window(), collapsed, func(next bool) {
192+ a.setModeLegendCollapsed(ctx, next)
193+ })
193194 }
194195
195196 func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
@@ -359,13 +360,11 @@ func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
359360
360361 a.releaseFloatingSessionsForTab(ctx, tab.ID)
361362 if bw := a.browserWindowForTab(tab.ID); bw != nil && bw.modeFrame != nil && bw.modeFrame.tabID == tab.ID {
362- bw.modeFrame.cancelTimers()
363- bw.modeFrame.dismissLinger()
364- bw.modeFrame.generation++
365363 bw.modeFrame.setTarget(nil, "")
366364 bw.modeFrame.tabID = ""
367- bw.modeFrame.hide()
368- a.updateModeIndicatorToaster(ctx, bw, bw.modeFrame.mode)
365+ // The legend belonged to the released workspace; the header stays for
366+ // the mode.
367+ bw.modeToaster.release()
369368 }
370369 if a.contentCoord != nil && tab.Workspace != nil {
371370 for _, pane := range tab.Workspace.AllPanes() {
M internal/ui/browser_window_test.go +1 -1
@@ -34,7 +34,7 @@ func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
3434 }
3535
3636 setShellField(t, removed, "appToaster", &component.Toaster{})
37- setShellField(t, removed, "modeToaster", &component.Toaster{})
37+ setShellField(t, removed, "modeToaster", &modeToaster{})
3838 setShellField(t, removed, "modeFrame", &modeFrame{})
3939 setShellField(t, removed, "sessionManager", &component.SessionManager{})
4040 setShellField(t, removed, "tabPicker", &component.TabPicker{})
M internal/ui/component/toaster.go +0 -31
@@ -80,9 +80,6 @@ type ToastOptions struct {
8080 TextColor string
8181 // Position determines where the toast appears on screen.
8282 Position ToastPosition
83- // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode").
84- // When set, this class is applied atomically with Show() to avoid visual flicker.
85- ModeClass string
8683 }
8784
8885 // ToastOption is a functional option for configuring toast display.
@@ -117,14 +114,6 @@ func WithPosition(pos ToastPosition) ToastOption {
117114 }
118115 }
119116
120-// WithModeClass sets a mode-specific CSS class for styling.
121-// This is applied atomically within Show() to avoid visual flicker.
122-func WithModeClass(class string) ToastOption {
123- return func(o *ToastOptions) {
124- o.ModeClass = class
125- }
126-}
127-
128117 // defaultToastOptions returns the default toast options.
129118 func defaultToastOptions() ToastOptions {
130119 return ToastOptions{
@@ -150,7 +139,6 @@ type Toaster struct {
150139 hasCustomStyle bool
151140 customBgColor string // Current custom background color
152141 customTextColor string // Current custom text color
153- customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode")
154142 customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
155143
156144 mu sync.Mutex
@@ -223,11 +211,6 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op
223211 // Apply custom colors if specified
224212 t.applyCustomStyle(options)
225213
226- // Apply mode class if specified (atomically to avoid flicker)
227- if options.ModeClass != "" {
228- t.applyModeClassInternal(options.ModeClass)
229- }
230-
231214 // Apply position
232215 t.applyPosition(options.Position)
233216
@@ -385,11 +368,6 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) {
385368 func (t *Toaster) clearCustomStyle() {
386369 if t.hasCustomStyle {
387370 t.container.RemoveCssClass("toast-custom")
388- // Remove mode-specific class if one was applied
389- if t.customModeClass != "" {
390- t.container.RemoveCssClass(t.customModeClass)
391- t.customModeClass = ""
392- }
393371 // Remove custom CSS provider if one was added
394372 if t.customCSSProvider != nil {
395373 styleCtx := t.container.GtkWidget().GetStyleContext()
@@ -407,15 +385,6 @@ func (t *Toaster) clearCustomStyle() {
407385 }
408386 }
409387
410-// applyModeClassInternal applies mode class without locking (must be called with lock held).
411-func (t *Toaster) applyModeClassInternal(modeClass string) {
412- // Remove level class and add mode class
413- t.container.RemoveCssClass(t.currentLevel.cssClass())
414- t.container.AddCssClass(modeClass)
415- t.hasCustomStyle = true
416- t.customModeClass = modeClass // Store class name for cleanup tracking
417-}
418-
419388 // startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
420389 // Must be called with lock held.
421390 func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
M internal/ui/deps.go +3 -0
@@ -130,6 +130,9 @@ func (d *Dependencies) Validate() error {
130130 if d.HandlerDeps.SaveOmniboxInitialBehavior == nil {
131131 return ErrMissingDependency("HandlerDeps.SaveOmniboxInitialBehavior")
132132 }
133+ if d.HandlerDeps.SaveModeLegendCollapsed == nil {
134+ return ErrMissingDependency("HandlerDeps.SaveModeLegendCollapsed")
135+ }
133136 // Use cases are optional - can be nil if not needed
134137 return nil
135138 }
M internal/ui/mode_frame.go +33 -481
@@ -1,106 +1,41 @@
11 package ui
22
33 import (
4- "strings"
5-
64 "github.com/bnema/dumber/internal/domain/entity"
75 "github.com/bnema/dumber/internal/ui/input"
86 "github.com/bnema/dumber/internal/ui/layout"
9- "github.com/bnema/puregotk/v4/gdk"
107 "github.com/bnema/puregotk/v4/glib"
118 "github.com/bnema/puregotk/v4/gobject"
129 "github.com/bnema/puregotk/v4/graphene"
1310 "github.com/bnema/puregotk/v4/gtk"
1411 )
1512
16-const (
17- legendColumnSpacing = 8
18- legendMaxWidth = 700
19- legendCompactWidth = 460
20- legendTwoColumnWidth = 650
21- legendInset = 16
22- legendHeaderSpace = 80
23- legendRefreshMs = 100
24-)
13+const borderRefreshMs = 100
2514
26-// modeFrame owns the per-window mode border and its non-interactive legend.
27-// GTK access is restricted to the main thread, as with the rest of the UI.
15+// modeFrame owns the per-window mode border drawn around its target pane or
16+// workspace, and the Vim pulse on it. The mode indicator and legend belong to
17+// modeToaster. GTK access is restricted to the main thread, as with the rest of
18+// the UI.
2819 type modeFrame struct {
2920 mode input.Mode
3021 tabID entity.TabID
3122 target layout.Widget
3223 frameClass string
33- root *gtk.Box
3424 border *gtk.Box
3525 borderStyle cssClassTarget // border's CSS classes; a seam for tests
3626 borderRect func() (x, y, width, height int, ok bool)
37- panel *gtk.Box
38- content *gtk.FlowBox
39- scroller *gtk.ScrolledWindow
40- heading *gtk.Label
41- rows map[string][]*gtk.Widget
42- keycaps map[string][]*gtk.Widget
43- pending string
44- visible bool
45- // suspended hides the legend while an in-page interaction (link hints,
46- // visual selection) owns the keys; the border keeps tracking its target.
47- suspended bool
48- style string
49- modeClass string
50- config entity.WorkspaceStylingConfig
51- showTimer uint
52- flashTimer uint
53- generation uint64
54- placement func() (x, y, width, height int, ok bool)
55- refreshSource uint
56- lastColumns uint
57- lastPanelWidth int
58- lastMaxHeight int
59- overlay *gtk.Overlay
60- window *gtk.ApplicationWindow
61- pressInFlight bool
62- pressIdle uint
63- positionHandlerID uint
64- pulseCycle bool
65- lingering bool
66- pointerX, pointerY float64
67- pointerInside bool
68- motionController *gtk.EventControllerMotion
69- keyController *gtk.EventControllerKey
70- clickController *gtk.GestureClick
71- motionCb func(gtk.EventControllerMotion, float64, float64)
72- enterCb func(gtk.EventControllerMotion, float64, float64)
73- leaveCb func(gtk.EventControllerMotion)
74- keyCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
75- clickCb func(gtk.GestureClick, int, float64, float64)
27+ overlay *gtk.Overlay
28+
29+ refreshSource uint
30+ positionHandlerID uint
31+ pulseCycle bool
7632 }
7733
78-func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
79- if overlay == nil || window == nil {
34+func newModeFrame(overlay *gtk.Overlay) *modeFrame {
35+ if overlay == nil {
8036 return nil
8137 }
82- f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
83- f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
84- f.root.SetHalign(gtk.AlignStartValue)
85- f.root.SetValign(gtk.AlignStartValue)
86- f.root.SetCanFocus(false)
87- f.root.SetCanTarget(false)
88- f.root.SetVisible(false)
89- f.root.AddCssClass("mode-legend-anchor")
90- f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
91- f.panel.SetCanFocus(false)
92- f.panel.SetCanTarget(false)
93- f.panel.AddCssClass("omnibox-container")
94- f.panel.AddCssClass("mode-legend-panel")
95- f.root.Append(&f.panel.Widget)
96- title := ""
97- f.heading = gtk.NewLabel(&title)
98- f.heading.SetCanTarget(false)
99- f.heading.SetCanFocus(false)
100- f.heading.SetHalign(gtk.AlignStartValue)
101- f.heading.AddCssClass("mode-legend-title")
102- f.panel.Append(&f.heading.Widget)
103- f.initLegendContent()
38+ f := &modeFrame{mode: input.ModeNormal, overlay: overlay}
10439 // The border is its own overlay: inset shadows on pane containers are
10540 // painted below web content and would be invisible.
10641 f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
@@ -112,129 +47,36 @@ func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFram
11247 overlay.AddOverlay(&f.border.Widget)
11348 overlay.SetClipOverlay(&f.border.Widget, false)
11449 overlay.SetMeasureOverlay(&f.border.Widget, false)
115- overlay.AddOverlay(&f.root.Widget)
116- overlay.SetClipOverlay(&f.root.Widget, false)
117- overlay.SetMeasureOverlay(&f.root.Widget, false)
11850 // A single positioned overlay tracks the active pane without reparenting it.
11951 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
120- placement := f.placement
121- if f.border != nil && widgetPtr == f.border.GoPointer() {
122- placement = f.borderRect
123- } else if widgetPtr != f.root.GoPointer() {
124- return false
125- }
126- if placement == nil {
52+ if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil {
12753 return false
12854 }
129- x, y, w, h, ok := placement()
55+ x, y, w, h, ok := f.borderRect()
13056 if !ok {
13157 return false
13258 }
13359 return writeOverlayAllocation(allocation, x, y, w, h)
13460 }
13561 f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
136- f.installDismissControllers()
13762 return f
13863 }
13964
140-func (f *modeFrame) installDismissControllers() {
141- overlay := f.overlay
142- window := f.window
143- f.motionController = gtk.NewEventControllerMotion()
144- f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
145- f.pointerX, f.pointerY, f.pointerInside = x, y, true
146- }
147- f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
148- f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
149- f.pointerX, f.pointerY, f.pointerInside = x, y, true
150- }
151- f.motionController.ConnectEnter(&f.enterCb)
152- f.motionController.ConnectMotion(&f.motionCb)
153- f.motionController.ConnectLeave(&f.leaveCb)
154- overlay.AddController(&f.motionController.EventController)
155- f.keyController = gtk.NewEventControllerKey()
156- f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
157- f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
158- f.dismissLinger()
159- return false
160- }
161- f.keyController.ConnectKeyPressed(&f.keyCb)
162- window.AddController(&f.keyController.EventController)
163- f.clickController = gtk.NewGestureClick()
164- f.clickController.SetButton(0)
165- f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
166- f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
167- f.dismissLinger()
168- f.pressInFlight = true
169- if f.pressIdle != 0 {
170- glib.SourceRemove(f.pressIdle)
171- }
172- cb := glib.SourceFunc(func(_ uintptr) bool {
173- f.pressInFlight = false
174- f.pressIdle = 0
175- return false
176- })
177- f.pressIdle = glib.IdleAdd(&cb, 0)
178- }
179- f.clickController.ConnectPressed(&f.clickCb)
180- window.AddController(&f.clickController.EventController)
181-}
182-
183-func (f *modeFrame) initLegendContent() {
184- f.content = gtk.NewFlowBox()
185- f.content.SetCanFocus(false)
186- f.content.SetCanTarget(false)
187- f.content.SetOrientation(gtk.OrientationHorizontalValue)
188- f.content.SetSelectionMode(gtk.SelectionNoneValue)
189- f.content.SetMinChildrenPerLine(1)
190- f.content.SetMaxChildrenPerLine(3)
191- f.content.SetColumnSpacing(legendColumnSpacing)
192- f.content.SetRowSpacing(4)
193- f.content.AddCssClass("mode-legend-content")
194- f.scroller = gtk.NewScrolledWindow()
195- f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
196- f.scroller.SetPropagateNaturalHeight(true)
197- f.scroller.SetHasFrame(false)
198- f.scroller.SetCanFocus(false)
199- f.scroller.SetCanTarget(false)
200- f.scroller.SetChild(&f.content.Widget)
201- f.panel.Append(&f.scroller.Widget)
202-}
203-
20465 func (f *modeFrame) destroy() {
20566 if f == nil {
20667 return
20768 }
208- f.cancelTimers()
209- f.dismissLinger()
21069 f.stopGeometryRefresh()
211- if f.pressIdle != 0 {
212- glib.SourceRemove(f.pressIdle)
213- f.pressIdle = 0
214- }
215- if f.overlay != nil {
216- f.overlay.RemoveController(&f.motionController.EventController)
217- }
218- if f.window != nil {
219- f.window.RemoveController(&f.keyController.EventController)
220- f.window.RemoveController(&f.clickController.EventController)
221- }
22270 f.setTarget(nil, "")
22371 if f.overlay != nil && f.positionHandlerID != 0 {
22472 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
22573 f.positionHandlerID = 0
22674 }
227- f.placement = nil
22875 f.borderRect = nil
229- if f.overlay != nil && f.root != nil {
230- f.overlay.RemoveOverlay(&f.root.Widget)
231- }
23276 if f.overlay != nil && f.border != nil {
23377 f.overlay.RemoveOverlay(&f.border.Widget)
23478 }
23579 f.overlay = nil
236- f.window = nil
237- f.root = nil
23880 f.border = nil
23981 f.borderStyle = nil
24082 }
@@ -244,6 +86,17 @@ type cssClassTarget interface {
24486 RemoveCssClass(string)
24587 }
24688
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+
247100 func (f *modeFrame) setTarget(target layout.Widget, class string) {
248101 if f == nil {
249102 return
@@ -314,167 +167,6 @@ func modeFrameClass(mode input.Mode) string {
314167 }
315168 }
316169
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-
478170 func (f *modeFrame) stopGeometryRefresh() {
479171 if f == nil || f.refreshSource == 0 {
480172 return
@@ -483,92 +175,40 @@ func (f *modeFrame) stopGeometryRefresh() {
483175 f.refreshSource = 0
484176 }
485177
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
495179 // resizes and splitter drags after the initial layout has settled.
496180 func (f *modeFrame) startGeometryRefresh() {
497181 f.stopGeometryRefresh()
498182 f.refreshGeometry()
499183 cb := glib.SourceFunc(func(_ uintptr) bool {
500- if f.target == nil || f.root == nil || f.lingering {
184+ if f.target == nil || f.border == nil {
501185 f.refreshSource = 0
502186 return false
503187 }
504188 f.refreshGeometry()
505189 return true
506190 })
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)
516192 }
517193
518-// clearPlacement hides both the border and the legend: the target is gone.
194+// clearPlacement hides the border: the target is gone.
519195 func (f *modeFrame) clearPlacement() {
520196 if f.borderRect != nil {
521197 f.borderRect = nil
522198 f.border.SetVisible(false)
523199 }
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()
535200 }
536201
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.
558203 func (f *modeFrame) refreshGeometry() {
559- if f.lingering {
560- return
561- }
562- previous := f.placement
563204 if f.target == nil || f.overlay == nil {
564205 f.clearPlacement()
565206 return
566207 }
567- overlayWidget := &f.overlay.Widget
568208 point := &graphene.Point{X: 0, Y: 0}
569209 out := &graphene.Point{}
570210 target := f.target.GtkWidget()
571- if target == nil || !target.ComputePoint(overlayWidget, point, out) {
211+ if target == nil || !target.ComputePoint(&f.overlay.Widget, point, out) {
572212 f.clearPlacement()
573213 return
574214 }
@@ -577,48 +217,7 @@ func (f *modeFrame) refreshGeometry() {
577217 f.clearPlacement()
578218 return
579219 }
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)
622221 }
623222
624223 func (f *modeFrame) placeBorder(x, y, width, height int) {
@@ -631,50 +230,3 @@ func (f *modeFrame) placeBorder(x, y, width, height int) {
631230 f.border.SetVisible(true)
632231 f.border.QueueAllocate()
633232 }
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 := &gtkModeToasterView{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) {
2828 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error {
2929 return nil
3030 },
31+ SaveModeLegendCollapsed: func(context.Context, bool) error {
32+ return nil
33+ },
3134 },
3235 }
3336
M internal/ui/theme/css.go +0 -36
@@ -1404,42 +1404,6 @@ func generateToasterCSS(p Palette) string {
14041404 background-color: alpha(var(--destructive), 0.9);
14051405 color: var(--destructive-text);
14061406 }
1407-
1408-/* Toast with custom styling (mode indicator toasters) */
1409-.toast-custom {
1410- color: #ffffff;
1411-}
1412-
1413-/* Mode-specific toast colors */
1414-.toast-pane-mode {
1415- background-color: var(--pane-mode-color);
1416- color: var(--pane-mode-text);
1417-}
1418-
1419-.toast-vim-mode {
1420- background-color: var(--vim-mode-color);
1421- color: var(--vim-mode-text);
1422-}
1423-
1424-.toast-tab-mode {
1425- background-color: var(--tab-mode-color);
1426- color: var(--tab-mode-text);
1427-}
1428-
1429-.toast-session-mode {
1430- background-color: var(--session-mode-color);
1431- color: var(--session-mode-text);
1432-}
1433-
1434-.toast-resize-mode {
1435- background-color: var(--resize-mode-color);
1436- color: var(--resize-mode-text);
1437-}
1438-
1439-.toast-prefix-mode {
1440- background-color: var(--pane-mode-color);
1441- color: var(--pane-mode-text);
1442-}
14431407 `
14441408 }
14451409
M internal/ui/theme/css_test.go +14 -0
@@ -427,3 +427,17 @@ func TestVimModeUsesOwnColor(t *testing.T) {
427427 t.Fatal("palette missing Vim color")
428428 }
429429 }
430+
431+func TestModeLegendCSSStylesToasterPillAndBand(t *testing.T) {
432+ css := generateModeLegendCSS()
433+ for _, mode := range []string{"pane", "vim", "resize", "tab", "session", "prefix"} {
434+ assert.Contains(t, css, ".mode-toaster-pill.mode-toaster-"+mode+" {", mode)
435+ assert.Contains(t, css, ".omnibox-container.mode-legend-panel.mode-legend-"+mode+" {", mode)
436+ }
437+ for _, class := range []string{".mode-toaster-chevron", ".mode-toaster-interactive", ".mode-legend-columns", ".mode-legend-column", ".mode-legend-hint"} {
438+ assert.Contains(t, css, class)
439+ }
440+ assert.NotContains(t, css, "mode-legend-title", "the legend no longer has its own heading")
441+ assert.NotContains(t, css, "mode-legend-content", "the flow box is gone")
442+ assert.NotContains(t, GenerateCSS(DefaultDarkPalette()), ".toast-vim-mode", "modes no longer reuse the generic toast")
443+}
M internal/ui/theme/mode_legend_css.go +22 -10
@@ -17,26 +17,38 @@ var modeLegendColors = []struct{ mode, color, text string }{
1717
1818 func generateModeLegendCSS() string {
1919 var sb strings.Builder
20- sb.WriteString(`/* Mode frame legend: omnibox surface, tinted with the active mode color. */
20+ sb.WriteString(`/* Mode toaster: a bottom-left pill whose legend expands above it. The legend
21+ is an omnibox surface tinted with the active mode color. */
2122 .mode-legend-anchor { background-color: transparent; }
23+.mode-toaster-pill {
24+ border-radius: 0.25em;
25+ padding: 0.375em 0.625em;
26+ font-size: 0.875em;
27+ font-weight: 600;
28+ box-shadow: 0 2px 8px alpha(black, 0.3);
29+ min-width: 2.5em;
30+}
31+.mode-toaster-chevron { margin-left: 0.5em; font-size: 0.75em; opacity: 0.85; }
32+.mode-toaster-pill.mode-toaster-interactive:hover { box-shadow: 0 2px 10px alpha(black, 0.45); }
33+.mode-toaster-pill.mode-toaster-interactive:hover .mode-toaster-chevron { opacity: 1; }
2234 .omnibox-container.mode-legend-panel {
2335 border-style: solid;
2436 border-width: 0.125em;
25- padding: 0 0 0.375em 0;
37+ padding: 0.625em 0.5em 0.375em 0.5em;
2638 min-width: 0;
2739 animation: mode-legend-appear 120ms ease-out;
2840 }
2941 .omnibox-container.mode-legend-panel.mode-legend-lingering { opacity: 0.85; }
3042 .omnibox-container.mode-legend-panel.mode-legend-no-motion { animation: none; }
31-.mode-legend-title {
43+.mode-legend-hint {
3244 font-family: var(--font-mono);
33- font-size: 0.8125em;
34- font-weight: bold;
35- padding: 0.375em 0.75em;
36- margin-bottom: 0.375em;
45+ font-size: 0.6875em;
46+ color: var(--surface-muted);
47+ margin: 0.25em 0.5em 0 0;
3748 }
38-.mode-legend-content { padding: 0 0.25em; }
39-.mode-legend-group { margin: 0 0.375em; min-width: 0; }
49+.mode-legend-columns { padding: 0 0.25em; }
50+.mode-legend-column { min-width: 0; }
51+.mode-legend-group { margin: 0 0.375em 0.5em 0.375em; min-width: 0; }
4052 .mode-legend-group-title {
4153 font-family: var(--font-mono);
4254 font-size: 0.6875em;
@@ -66,7 +78,7 @@ func generateModeLegendCSS() string {
6678 `)
6779 for _, m := range modeLegendColors {
6880 fmt.Fprintf(&sb, `.omnibox-container.mode-legend-panel.mode-legend-%[1]s { border-color: var(%[2]s); }
69-.mode-legend-%[1]s .mode-legend-title { background-color: var(%[2]s); color: var(%[3]s); }
81+.mode-toaster-pill.mode-toaster-%[1]s { background-color: var(%[2]s); color: var(%[3]s); }
7082 .mode-legend-%[1]s .mode-legend-group-title { color: var(%[2]s); }
7183 .mode-legend-%[1]s .mode-legend-keycap { border-color: alpha(var(%[2]s), 0.6); }
7284 .mode-legend-%[1]s .mode-legend-keycap.mode-legend-flash { background-color: var(%[2]s); color: var(%[3]s); }
M internal/ui/vim_mode.go +6 -44
@@ -7,7 +7,6 @@ import (
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/internal/ui/component"
1110 "github.com/bnema/dumber/internal/ui/input"
1211 )
1312
@@ -127,10 +126,8 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to
127126 // Entering Vim Mode accents the active pane; leaving removes the accent.
128127 a.handleVimModeOwnership(ctx, bw, to, from)
129128
129+ // Retarget the border and reconcile the mode toaster (indicator + legend).
130130 a.updateModeFrame(bw, to)
131-
132- // Show/hide this window's mode indicator toaster based on mode and config.
133- a.updateModeIndicatorToaster(ctx, bw, to)
134131 }
135132
136133 // transferVimModeOwnershipToPane transfers the pane-local Vim Mode accent
@@ -277,25 +274,6 @@ func (a *App) clearVimModeOwnership(ctx context.Context, bw *browserWindow) {
277274 a.vimMode.resetPulse()
278275 }
279276
280-// updateModeIndicatorToaster reconciles one window's mode toaster with its
281-// current mode and the live mode-indicator preference.
282-func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, mode input.Mode) {
283- if bw == nil || bw.modeToaster == nil {
284- return
285- }
286-
287- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled || mode == input.ModeNormal {
288- bw.modeToaster.Hide()
289- return
290- }
291-
292- bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
293- component.WithDuration(0),
294- component.WithPosition(component.ToastPositionBottomLeft),
295- component.WithModeClass(getModeToastClass(mode)),
296- )
297-}
298-
299277 // bindVimModeSequenceToaster wires pending Vim-sequence text onto this
300278 // window's modeToaster only (never appToaster / pane toasters).
301279 func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow) {
@@ -312,7 +290,7 @@ func (a *App) bindVimModeSequenceToaster(ctx context.Context, bw *browserWindow)
312290
313291 // showPendingSequence shows "VIM MODE · pending" on the per-window modeToaster
314292 // while a sequence is incomplete, and restores stable "VIM MODE" when pending
315-// clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster;
293+// clears. Mode exit is handled by handleModeChange/updateModeFrame;
316294 // silent reset under ModalState avoids stale pending callbacks.
317295 func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) {
318296 if a == nil || bw == nil {
@@ -323,15 +301,11 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
323301 // sub-mode, and the legend is suspended. Nothing to repaint.
324302 return
325303 }
326- if bw.modeFrame != nil {
327- cfg := a.runtimeConfigSnapshot().UI.Workspace
328- bw.modeFrame.setPending(pending, cfg.VimMode.Actions)
329- }
330304 if bw.modeToaster == nil {
331305 return
332306 }
333307
334- a.showVimModeToast(ctx, bw, pending)
308+ bw.modeToaster.setPending(pending)
335309 logging.FromContext(ctx).Debug().
336310 Str("window_id", bw.id).
337311 Str("pending", pending).
@@ -339,22 +313,10 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
339313 }
340314
341315 // showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
342-// per-window mode toaster, honoring the indicator toggle.
343-func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) {
316+// per-window mode toaster.
317+func (a *App) showVimModeToast(_ context.Context, bw *browserWindow, detail string) {
344318 if bw == nil || bw.modeToaster == nil {
345319 return
346320 }
347- if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
348- bw.modeToaster.Hide()
349- return
350- }
351- text := input.ModeVim.DisplayName()
352- if detail != "" {
353- text = text + " · " + detail
354- }
355- bw.modeToaster.Show(ctx, text, component.ToastInfo,
356- component.WithDuration(0),
357- component.WithPosition(component.ToastPositionBottomLeft),
358- component.WithModeClass(getModeToastClass(input.ModeVim)),
359- )
321+ bw.modeToaster.setDetail(detail)
360322 }
M internal/ui/vim_page.go +4 -4
@@ -28,8 +28,8 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
2828 }
2929 })
3030 a.showVimModeToast(ctx, bw, label)
31- if bw.modeFrame != nil {
32- bw.modeFrame.setSuspended(true)
31+ if bw.modeToaster != nil {
32+ bw.modeToaster.setSuspended(true)
3333 }
3434 logging.FromContext(ctx).Debug().
3535 Str("window_id", bw.id).
@@ -54,8 +54,8 @@ func (a *App) releaseVimPageKeys(ctx context.Context, bw *browserWindow, reason
5454 }
5555 if stillVim {
5656 a.showVimModeToast(ctx, bw, "")
57- if bw.modeFrame != nil {
58- bw.modeFrame.setSuspended(false)
57+ if bw.modeToaster != nil {
58+ bw.modeToaster.setSuspended(false)
5959 }
6060 }
6161 logging.FromContext(ctx).Debug().

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close