1package ui
2
3import (
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
14var 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
21func legendOn(mode string) entity.WorkspaceStylingConfig {
22 return entity.WorkspaceStylingConfig{ModeIndicatorToasterEnabled: true, ModeLegend: mode, ModeLegendAnimations: true}
23}
24
25func newTestToaster(onToggle func(bool)) (*modeToaster, *fakeModeToasterView) {
26 view := newFakeModeToasterView()
27 return newModeToasterWithView(view, onToggle), view
28}
29
30func 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
37func 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
56func 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
68func 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
78func 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
92func 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
105func 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
115func 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
126func 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
142func 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
152func 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
164func 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
177func 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
191func 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
207func 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
218func 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
233func 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
249func 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
259func 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
275func 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
288func 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
309func 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
326func TestModeToasterFlashLightsKeycapsAndClears(t *testing.T) {
327 toaster, view := newTestToaster(nil)
328 toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
329
330 toaster.flash(input.Action("vim-scroll-down"))
331 require.True(t, view.flashed["vim-scroll-down"])
332
333 // Underscore action names map onto dashed legend rows.
334 toaster.flash(input.Action("heading_next"))
335 require.True(t, view.flashed["heading-next"])
336
337 toaster.flash(input.Action("unknown-action"))
338 require.True(t, view.flashed["heading-next"], "an action without keycaps leaves the flash alone")
339
340 waitFor(t, func() bool { return len(view.flashed) == 0 })
341}
342
343func TestModeToasterFlashNeedsVisibleLegendAndAnimations(t *testing.T) {
344 toaster, view := newTestToaster(nil)
345 cfg := legendOn("always")
346 cfg.ModeLegendAnimations = false
347 toaster.apply(input.ModeVim, cfg, "", testVimActions)
348 toaster.flash(input.Action("vim-scroll-down"))
349 require.Empty(t, view.flashed)
350
351 toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
352 toaster.setCollapsed(true)
353 toaster.flash(input.Action("vim-scroll-down"))
354 require.Empty(t, view.flashed, "collapsed legend: nothing to flash")
355}
356
357func TestModeToasterLingersWhenPointerIsOverLegend(t *testing.T) {
358 toaster, view := newTestToaster(nil)
359 cfg := legendOn("always")
360 cfg.ModeLegendLinger = true
361 toaster.apply(input.ModeVim, cfg, "", testVimActions)
362 view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
363 toaster.pointerMoved(50, 50)
364
365 toaster.apply(input.ModeNormal, cfg, "", nil)
366
367 require.True(t, toaster.lingering)
368 require.True(t, view.lingering)
369 require.True(t, view.bodyShown, "the legend stays on screen, read-only")
370 require.False(t, view.pillShown, "the mode ended: the header goes")
371 require.True(t, toaster.hasLegend() || toaster.lingering)
372
373 toaster.keyPressed()
374 require.False(t, toaster.lingering)
375 require.False(t, view.bodyShown)
376}
377
378func TestModeToasterDoesNotLingerWhenPointerElsewhereOrPressInFlight(t *testing.T) {
379 cfg := legendOn("always")
380 cfg.ModeLegendLinger = true
381
382 toaster, view := newTestToaster(nil)
383 toaster.apply(input.ModeVim, cfg, "", testVimActions)
384 view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
385 toaster.pointerMoved(900, 900)
386 toaster.apply(input.ModeNormal, cfg, "", nil)
387 require.False(t, toaster.lingering)
388
389 toaster, view = newTestToaster(nil)
390 toaster.apply(input.ModeVim, cfg, "", testVimActions)
391 view.rect = func() (int, int, int, int, bool) { return 10, 10, 400, 200, true }
392 toaster.pointerMoved(50, 50)
393 toaster.pressStarted() // the exit was caused by a click
394 toaster.apply(input.ModeNormal, cfg, "", nil)
395 require.False(t, toaster.lingering)
396}
397
398func TestModeToasterLingerEndsOnClickAndTabSwitch(t *testing.T) {
399 cfg := legendOn("always")
400 cfg.ModeLegendLinger = true
401 toaster, view := newTestToaster(nil)
402 toaster.apply(input.ModeVim, cfg, "", testVimActions)
403 view.rect = func() (int, int, int, int, bool) { return 0, 0, 500, 500, true }
404 toaster.pointerMoved(5, 5)
405 toaster.apply(input.ModeNormal, cfg, "", nil)
406 require.True(t, toaster.lingering)
407
408 toaster.pressStarted()
409 require.False(t, toaster.lingering)
410 pumpMainContext() // the press is over; a later exit may linger again
411
412 toaster.apply(input.ModeVim, cfg, "", testVimActions)
413 toaster.pointerMoved(5, 5)
414 toaster.apply(input.ModeNormal, cfg, "", nil)
415 require.True(t, toaster.lingering)
416 toaster.dismissLinger()
417 require.False(t, toaster.lingering)
418}
419
420func TestModeToasterReleaseDropsLegendKeepsPill(t *testing.T) {
421 toaster, view := newTestToaster(nil)
422 cfg := legendOn("delay")
423 cfg.ModeLegendDelayMs = 40
424 toaster.apply(input.ModeVim, cfg, "", testVimActions)
425
426 toaster.release()
427 time.Sleep(90 * time.Millisecond)
428 pumpMainContext()
429
430 require.True(t, view.pillShown, "tab teardown keeps the mode header")
431 require.False(t, view.bodyShown, "the pending show for the released workspace never fires")
432}
433
434func TestModeToasterNilAndDestroyedAreSafe(t *testing.T) {
435 var nilToaster *modeToaster
436 nilToaster.destroy()
437 nilToaster.release()
438 nilToaster.dismissLinger()
439 nilToaster.setSuspended(true)
440 nilToaster.setCollapsed(true)
441 nilToaster.setPending("x")
442 nilToaster.setDetail("x")
443 nilToaster.flash(input.Action("x"))
444 nilToaster.apply(input.ModeVim, legendOn("always"), "", nil)
445
446 toaster, view := newTestToaster(nil)
447 toaster.destroy()
448 require.True(t, view.destroyed)
449 toaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
450 toaster.setPending("x")
451 toaster.pillClicked()
452}
453
454func TestModeLegendPreferenceClickWinsUntilSettled(t *testing.T) {
455 var p modeLegendPreference
456 require.False(t, p.current(false))
457 require.True(t, p.current(true), "no click yet: the configuration decides")
458
459 require.True(t, p.click(true), "first click starts the writer")
460 require.True(t, p.current(false), "the click wins while the config still says otherwise")
461
462 p.configReloaded(false) // a reload from before the save landed
463 require.True(t, p.current(false), "a stale reload does not undo the click")
464
465 next, ok := p.next()
466 require.True(t, ok)
467 require.True(t, next)
468 _, ok = p.next()
469 require.False(t, ok, "writer drained")
470
471 p.configReloaded(true) // the save's own reload
472 require.False(t, p.current(false), "settled: the configuration is the truth again")
473 require.True(t, p.current(true))
474}
475
476func TestModeLegendPreferenceReloadsDuringSavesDoNotUndoLastClick(t *testing.T) {
477 var p modeLegendPreference
478 require.True(t, p.click(true))
479 first, ok := p.next() // the writer takes the first value
480 require.True(t, ok)
481 require.True(t, first)
482 require.False(t, p.click(false), "the writer is still active")
483
484 p.configReloaded(false) // matches the latest click, but the save is still pending
485 p.configReloaded(true) // the first save's own reload, arriving late
486 require.False(t, p.current(true), "the latest click survives intermediate reloads")
487
488 second, ok := p.next()
489 require.True(t, ok)
490 require.False(t, second)
491 require.False(t, p.current(true), "still the latest click until the writer drains")
492 _, ok = p.next()
493 require.False(t, ok)
494 require.False(t, p.current(true), "drained: reloads have not been received yet")
495
496 p.configReloaded(false) // first reload after the drain
497 require.False(t, p.current(false))
498 require.True(t, p.current(true), "the configuration decides again")
499}
500
501func TestModeLegendPreferenceReloadAfterDrain(t *testing.T) {
502 tests := []struct {
503 name string
504 reloaded bool
505 configured bool
506 }{
507 {"own save reloads", true, true},
508 {"external edit", false, false},
509 {"failed save leaves the file unchanged", false, false},
510 }
511 for _, tt := range tests {
512 t.Run(tt.name, func(t *testing.T) {
513 var p modeLegendPreference
514 require.True(t, p.click(true))
515 _, _ = p.next()
516 _, _ = p.next()
517 p.configReloaded(tt.reloaded)
518 require.Equal(t, tt.configured, p.current(tt.configured))
519 require.Equal(t, !tt.configured, p.current(!tt.configured))
520 })
521 }
522}
523
524func TestModeLegendPreferenceCoalescesClicksAndKeepsLast(t *testing.T) {
525 var p modeLegendPreference
526 require.True(t, p.click(true))
527 require.False(t, p.click(false), "a running writer picks the new value up")
528 require.False(t, p.click(true))
529
530 next, ok := p.next()
531 require.True(t, ok)
532 require.True(t, next, "the file ends on the last click")
533 _, ok = p.next()
534 require.False(t, ok)
535
536 require.True(t, p.click(false), "after draining, a new click starts a new writer")
537 _, _ = p.next()
538 _, _ = p.next()
539}
540
541func TestModeLegendPreferenceExternalEditWinsWhenNoSaveInFlight(t *testing.T) {
542 var p modeLegendPreference
543 require.True(t, p.click(true))
544 _, _ = p.next()
545 _, _ = p.next() // save finished (and possibly failed)
546
547 p.configReloaded(false) // the file still says false
548 require.False(t, p.current(false))
549}
550
551func TestSetModeLegendCollapsedUpdatesEveryWindowAndSavesAsync(t *testing.T) {
552 saved := make(chan bool, 4)
553 var mu sync.Mutex
554 app := &App{}
555 app.deps = &Dependencies{}
556 app.deps.HandlerDeps.SaveModeLegendCollapsed = func(_ context.Context, collapsed bool) error {
557 mu.Lock()
558 defer mu.Unlock()
559 saved <- collapsed
560 return nil
561 }
562 bw1, view1 := newFakeModeToasterWindow("win-1")
563 bw2, view2 := newFakeModeToasterWindow("win-2")
564 app.browserWindows = map[string]*browserWindow{bw1.id: bw1, bw2.id: bw2}
565 bw1.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
566 bw2.modeToaster.apply(input.ModePane, legendOn("always"), "", map[string]entity.ActionBinding{"split-right": {Keys: []string{"r"}}})
567
568 app.setModeLegendCollapsed(context.Background(), true)
569
570 require.False(t, view1.bodyShown)
571 require.False(t, view2.bodyShown, "the toggle applies immediately in every window")
572 require.True(t, view1.pillShown)
573 require.True(t, view2.pillShown)
574 select {
575 case got := <-saved:
576 require.True(t, got)
577 case <-time.After(2 * time.Second):
578 t.Fatal("collapsed preference was not saved")
579 }
580 require.True(t, app.modeLegendCollapsed())
581}
582
583func TestSetModeLegendCollapsedLogsSaveFailureAndKeepsState(t *testing.T) {
584 done := make(chan struct{}, 1)
585 app := &App{deps: &Dependencies{}}
586 app.deps.HandlerDeps.SaveModeLegendCollapsed = func(context.Context, bool) error {
587 defer func() { done <- struct{}{} }()
588 return context.DeadlineExceeded
589 }
590 bw, view := newFakeModeToasterWindow("win-1")
591 app.browserWindows = map[string]*browserWindow{bw.id: bw}
592 bw.modeToaster.apply(input.ModeVim, legendOn("always"), "", testVimActions)
593
594 app.setModeLegendCollapsed(context.Background(), true)
595 <-done
596
597 require.False(t, view.bodyShown, "a failed save does not revert the visible state")
598}
599
600func TestUpdateModeFrameUsesSharedCollapsedState(t *testing.T) {
601 snapshot := entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{
602 Styling: legendOn("always"),
603 VimMode: entity.VimModeConfig{Actions: testVimActions},
604 }}}
605 app := &App{runtimeConfig: runtimeConfigStateFromSnapshotForTest(snapshot)}
606 require.True(t, app.modeLegend.click(true))
607 bw, view := newFakeModeToasterWindow("win-1")
608
609 app.updateModeFrame(bw, input.ModeVim)
610
611 require.True(t, view.pillShown)
612 require.False(t, view.bodyShown, "a window entering a mode honors the shared collapsed state")
613 _, _ = app.modeLegend.next()
614 _, _ = app.modeLegend.next()
615}
616
617func waitFor(t *testing.T, cond func() bool) {
618 t.Helper()
619 deadline := time.Now().Add(2 * time.Second)
620 for time.Now().Before(deadline) {
621 pumpMainContext()
622 if cond() {
623 return
624 }
625 time.Sleep(5 * time.Millisecond)
626 }
627 t.Fatal("condition not reached in time")
628}