detached 4576e5c

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
181181 t.clearLinger()
182182 }
183183 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+ }
184192 t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions
185- t.detail, t.pending = "", ""
186- t.suspended = false
187193 t.ready = false
188194 t.view.setStyle(style)
189195 if linger {
@@ -201,6 +207,9 @@ func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig
201207 }
202208 t.groups = buildLegendGroups(mode, actions)
203209 t.view.setLegend(t.groups)
210+ if t.pending != "" {
211+ t.dimRulesOutPending()
212+ }
204213 t.view.setAnimations(cfg.ModeLegendAnimations)
205214 if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
206215 generation := t.generation
@@ -237,12 +246,18 @@ func (t *modeToaster) setPending(pending string) {
237246 }
238247 t.detail = pending
239248 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() {
240256 for _, group := range t.groups {
241257 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))
243259 }
244260 }
245- t.reconcile()
246261 }
247262
248263 // 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, actions192 t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions
185- t.detail, t.pending = "", ""
186- t.suspended = false
187 t.ready = false193 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.generation215 generation := t.generation
@@ -237,12 +246,18 @@ func (t *modeToaster) setPending(pending string) {
237 }246 }
238 t.detail = pending247 t.detail = pending
239 t.pending = pending248 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 back263 // 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
4949 func (f *fakeModeToasterView) destroy() { f.destroyed = true }
5050 func (f *fakeModeToasterView) setLegend(groups []legendGroup) {
5151 f.groups = groups
52+ f.dimmed = map[string]bool{} // new rows start undimmed
5253 f.flashable = map[string]bool{}
5354 for _, group := range groups {
5455 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 = groups51 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) {
285285 require.True(t, view.bodyShown)
286286 }
287287
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+
288326 func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) {
289327 toaster, view := newTestToaster(nil)
290328 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)

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