fix(ui): keep Vim sub-mode and suspension across same-mode legend rebuilds
Reset the toaster's detail, pending sequence and suspension only when the mode changes. A config hot reload during hints or visual capture no longer unsuspends the legend or drops the sub-mode label, and pending-row dimming is reapplied to the rebuilt legend.
- commit
- 4576e5ccd11b6630d01582f3fdcc991de4585309
- author
- brice · · 2026-10-14 07:32 UTC
- parent
- 0d2fc9a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +58 -4
M internal/ui/mode_toaster.go +19 -4
| @@ -181,9 +181,15 @@ func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig | |||
| 181 | 181 | t.clearLinger() | |
| 182 | 182 | } | |
| 183 | 183 | t.generation++ | |
| 184 | + | // A rebuild within the same mode (a config hot reload during a Vim page | |
| 185 | + | // capture, say) keeps the interaction state: the sub-mode label, the pending | |
| 186 | + | // sequence and the suspension belong to what the user is doing, not to the | |
| 187 | + | // legend being rebuilt. | |
| 188 | + | if mode != t.mode { | |
| 189 | + | t.detail, t.pending = "", "" | |
| 190 | + | t.suspended = false | |
| 191 | + | } | |
| 184 | 192 | t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions | |
| 185 | - | t.detail, t.pending = "", "" | |
| 186 | - | t.suspended = false | |
| 187 | 193 | t.ready = false | |
| 188 | 194 | t.view.setStyle(style) | |
| 189 | 195 | if linger { | |
| @@ -201,6 +207,9 @@ func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig | |||
| 201 | 207 | } | |
| 202 | 208 | t.groups = buildLegendGroups(mode, actions) | |
| 203 | 209 | t.view.setLegend(t.groups) | |
| 210 | + | if t.pending != "" { | |
| 211 | + | t.dimRulesOutPending() | |
| 212 | + | } | |
| 204 | 213 | t.view.setAnimations(cfg.ModeLegendAnimations) | |
| 205 | 214 | if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 { | |
| 206 | 215 | generation := t.generation | |
| @@ -237,12 +246,18 @@ func (t *modeToaster) setPending(pending string) { | |||
| 237 | 246 | } | |
| 238 | 247 | t.detail = pending | |
| 239 | 248 | t.pending = pending | |
| 249 | + | t.dimRulesOutPending() | |
| 250 | + | t.reconcile() | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // dimRulesOutPending dims the legend rows the pending sequence rules out. A | |
| 254 | + | // rebuilt legend starts undimmed, so it is applied again after each rebuild. | |
| 255 | + | func (t *modeToaster) dimRulesOutPending() { | |
| 240 | 256 | for _, group := range t.groups { | |
| 241 | 257 | for _, row := range group.Rows { | |
| 242 | - | t.view.setActionDimmed(row.Action, !legendSequenceMatches(pending, row.Keys)) | |
| 258 | + | t.view.setActionDimmed(row.Action, !legendSequenceMatches(t.pending, row.Keys)) | |
| 243 | 259 | } | |
| 244 | 260 | } | |
| 245 | - | t.reconcile() | |
| 246 | 261 | } | |
| 247 | 262 | ||
| 248 | 263 | // setSuspended hides the legend while suspended is true and brings it back | |
| @@ -181,9 +181,15 @@ func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig | |||||
| 181 | t.clearLinger() | 181 | t.clearLinger() | ||
| 182 | } | 182 | } | ||
| 183 | t.generation++ | 183 | t.generation++ | ||
| 184 | + | // A rebuild within the same mode (a config hot reload during a Vim page | |||
| 185 | + | // capture, say) keeps the interaction state: the sub-mode label, the pending | |||
| 186 | + | // sequence and the suspension belong to what the user is doing, not to the | |||
| 187 | + | // legend being rebuilt. | |||
| 188 | + | if mode != t.mode { | |||
| 189 | + | t.detail, t.pending = "", "" | |||
| 190 | + | t.suspended = false | |||
| 191 | + | } | |||
| 184 | t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions | 192 | 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 | 193 | t.ready = false | ||
| 188 | t.view.setStyle(style) | 194 | t.view.setStyle(style) | ||
| 189 | if linger { | 195 | if linger { | ||
| @@ -201,6 +207,9 @@ func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig | |||||
| 201 | } | 207 | } | ||
| 202 | t.groups = buildLegendGroups(mode, actions) | 208 | t.groups = buildLegendGroups(mode, actions) | ||
| 203 | t.view.setLegend(t.groups) | 209 | t.view.setLegend(t.groups) | ||
| 210 | + | if t.pending != "" { | |||
| 211 | + | t.dimRulesOutPending() | |||
| 212 | + | } | |||
| 204 | t.view.setAnimations(cfg.ModeLegendAnimations) | 213 | t.view.setAnimations(cfg.ModeLegendAnimations) | ||
| 205 | if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 { | 214 | if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 { | ||
| 206 | generation := t.generation | 215 | generation := t.generation | ||
| @@ -237,12 +246,18 @@ func (t *modeToaster) setPending(pending string) { | |||||
| 237 | } | 246 | } | ||
| 238 | t.detail = pending | 247 | t.detail = pending | ||
| 239 | t.pending = pending | 248 | t.pending = pending | ||
| 249 | + | t.dimRulesOutPending() | |||
| 250 | + | t.reconcile() | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // dimRulesOutPending dims the legend rows the pending sequence rules out. A | |||
| 254 | + | // rebuilt legend starts undimmed, so it is applied again after each rebuild. | |||
| 255 | + | func (t *modeToaster) dimRulesOutPending() { | |||
| 240 | for _, group := range t.groups { | 256 | for _, group := range t.groups { | ||
| 241 | for _, row := range group.Rows { | 257 | for _, row := range group.Rows { | ||
| 242 | - | t.view.setActionDimmed(row.Action, !legendSequenceMatches(pending, row.Keys)) | 258 | + | t.view.setActionDimmed(row.Action, !legendSequenceMatches(t.pending, row.Keys)) |
| 243 | } | 259 | } | ||
| 244 | } | 260 | } | ||
| 245 | - | t.reconcile() | |||
| 246 | } | 261 | } | ||
| 247 | 262 | ||||
| 248 | // setSuspended hides the legend while suspended is true and brings it back | 263 | // setSuspended hides the legend while suspended is true and brings it back | ||
M internal/ui/mode_toaster_fake_test.go +1 -0
| @@ -49,6 +49,7 @@ func (f *fakeModeToasterView) clearFlash() { f.flashed = ma | |||
| 49 | 49 | func (f *fakeModeToasterView) destroy() { f.destroyed = true } | |
| 50 | 50 | func (f *fakeModeToasterView) setLegend(groups []legendGroup) { | |
| 51 | 51 | f.groups = groups | |
| 52 | + | f.dimmed = map[string]bool{} // new rows start undimmed | |
| 52 | 53 | f.flashable = map[string]bool{} | |
| 53 | 54 | for _, group := range groups { | |
| 54 | 55 | for _, row := range group.Rows { | |
| @@ -49,6 +49,7 @@ func (f *fakeModeToasterView) clearFlash() { f.flashed = ma | |||||
| 49 | func (f *fakeModeToasterView) destroy() { f.destroyed = true } | 49 | func (f *fakeModeToasterView) destroy() { f.destroyed = true } | ||
| 50 | func (f *fakeModeToasterView) setLegend(groups []legendGroup) { | 50 | func (f *fakeModeToasterView) setLegend(groups []legendGroup) { | ||
| 51 | f.groups = groups | 51 | f.groups = groups | ||
| 52 | + | f.dimmed = map[string]bool{} // new rows start undimmed | |||
| 52 | f.flashable = map[string]bool{} | 53 | f.flashable = map[string]bool{} | ||
| 53 | for _, group := range groups { | 54 | for _, group := range groups { | ||
| 54 | for _, row := range group.Rows { | 55 | for _, row := range group.Rows { | ||
M internal/ui/mode_toaster_test.go +38 -0
| @@ -285,6 +285,44 @@ func TestModeToasterModeChangeResetsDetailAndSuspension(t *testing.T) { | |||
| 285 | 285 | require.True(t, view.bodyShown) | |
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | + | func TestModeToasterSameModeRebuildKeepsInteractionState(t *testing.T) { | |
| 289 | + | toaster, view := newTestToaster(nil) | |
| 290 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |
| 291 | + | toaster.setDetail("HINTS") | |
| 292 | + | toaster.setSuspended(true) | |
| 293 | + | require.False(t, view.bodyShown) | |
| 294 | + | ||
| 295 | + | // A hot reload that changes the bindings rebuilds the legend mid-capture. | |
| 296 | + | changed := map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}, "cancel": {Keys: []string{"esc"}}} | |
| 297 | + | toaster.apply(input.ModeVim, legendOn("always"), "", changed) | |
| 298 | + | ||
| 299 | + | require.Equal(t, "VIM MODE · HINTS", view.header, "the sub-mode label survives the rebuild") | |
| 300 | + | require.False(t, view.bodyShown, "the legend stays suspended while the page owns the keys") | |
| 301 | + | require.True(t, toaster.suspended) | |
| 302 | + | ||
| 303 | + | toaster.setDetail("") | |
| 304 | + | toaster.setSuspended(false) | |
| 305 | + | require.True(t, view.bodyShown) | |
| 306 | + | require.Equal(t, "VIM MODE", view.header) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestModeToasterSameModeRebuildReappliesPendingDimming(t *testing.T) { | |
| 310 | + | toaster, view := newTestToaster(nil) | |
| 311 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |
| 312 | + | toaster.setPending("]") | |
| 313 | + | ||
| 314 | + | changed := map[string]entity.ActionBinding{ | |
| 315 | + | "heading-next": {Keys: []string{"]]"}}, | |
| 316 | + | "visual": {Keys: []string{"v"}}, | |
| 317 | + | } | |
| 318 | + | toaster.apply(input.ModeVim, legendOn("always"), "", changed) | |
| 319 | + | // The rebuilt legend starts undimmed, as a real view's new rows would. | |
| 320 | + | require.Equal(t, "]", toaster.pending) | |
| 321 | + | require.Equal(t, "VIM MODE · ]", view.header) | |
| 322 | + | require.False(t, view.dimmed["heading-next"]) | |
| 323 | + | require.True(t, view.dimmed["visual"], "rows the pending sequence rules out are dimmed again") | |
| 324 | + | } | |
| 325 | + | ||
| 288 | 326 | func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) { | |
| 289 | 327 | toaster, view := newTestToaster(nil) | |
| 290 | 328 | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |
| @@ -285,6 +285,44 @@ func TestModeToasterModeChangeResetsDetailAndSuspension(t *testing.T) { | |||||
| 285 | require.True(t, view.bodyShown) | 285 | require.True(t, view.bodyShown) | ||
| 286 | } | 286 | } | ||
| 287 | 287 | ||||
| 288 | + | func TestModeToasterSameModeRebuildKeepsInteractionState(t *testing.T) { | |||
| 289 | + | toaster, view := newTestToaster(nil) | |||
| 290 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 291 | + | toaster.setDetail("HINTS") | |||
| 292 | + | toaster.setSuspended(true) | |||
| 293 | + | require.False(t, view.bodyShown) | |||
| 294 | + | ||||
| 295 | + | // A hot reload that changes the bindings rebuilds the legend mid-capture. | |||
| 296 | + | changed := map[string]entity.ActionBinding{"visual": {Keys: []string{"v"}}, "cancel": {Keys: []string{"esc"}}} | |||
| 297 | + | toaster.apply(input.ModeVim, legendOn("always"), "", changed) | |||
| 298 | + | ||||
| 299 | + | require.Equal(t, "VIM MODE · HINTS", view.header, "the sub-mode label survives the rebuild") | |||
| 300 | + | require.False(t, view.bodyShown, "the legend stays suspended while the page owns the keys") | |||
| 301 | + | require.True(t, toaster.suspended) | |||
| 302 | + | ||||
| 303 | + | toaster.setDetail("") | |||
| 304 | + | toaster.setSuspended(false) | |||
| 305 | + | require.True(t, view.bodyShown) | |||
| 306 | + | require.Equal(t, "VIM MODE", view.header) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestModeToasterSameModeRebuildReappliesPendingDimming(t *testing.T) { | |||
| 310 | + | toaster, view := newTestToaster(nil) | |||
| 311 | + | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | |||
| 312 | + | toaster.setPending("]") | |||
| 313 | + | ||||
| 314 | + | changed := map[string]entity.ActionBinding{ | |||
| 315 | + | "heading-next": {Keys: []string{"]]"}}, | |||
| 316 | + | "visual": {Keys: []string{"v"}}, | |||
| 317 | + | } | |||
| 318 | + | toaster.apply(input.ModeVim, legendOn("always"), "", changed) | |||
| 319 | + | // The rebuilt legend starts undimmed, as a real view's new rows would. | |||
| 320 | + | require.Equal(t, "]", toaster.pending) | |||
| 321 | + | require.Equal(t, "VIM MODE · ]", view.header) | |||
| 322 | + | require.False(t, view.dimmed["heading-next"]) | |||
| 323 | + | require.True(t, view.dimmed["visual"], "rows the pending sequence rules out are dimmed again") | |||
| 324 | + | } | |||
| 325 | + | ||||
| 288 | func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) { | 326 | func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) { | ||
| 289 | toaster, view := newTestToaster(nil) | 327 | toaster, view := newTestToaster(nil) | ||
| 290 | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | 328 | toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions) | ||