feat/hints a471fa4

flash_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9	"github.com/bnema/neferwl/internal/ports"
10)
11
12func flashCore(t *testing.T) (*Core, *indicatorClock) {
13	t.Helper()
14	c, clock := indicatorCore(t)
15	c.cfg.Animations.On = true
16	c.cfg.Focus.Flash = true
17	c.cfg.Focus.FlashWidth = 3
18	c.cfg.Focus.FlashOpacity = 0.8
19	c.cfg.Focus.FlashDuration = 400 * time.Millisecond
20	c.cfg.Border.Active = "#abcdef"
21	for id := WindowID(1); id <= 3; id++ {
22		c.windows.mapped(ports.WindowMapped{ID: id}, clock.now.Add(-time.Minute))
23		c.cur().mon.AddWindow(id)
24	}
25	return c, clock
26}
27
28func startFlash(t *testing.T, c *Core, clock *indicatorClock, level flashLevel, id WindowID) {
29	t.Helper()
30	c.requestFlash(level, id)
31	clock.now = clock.now.Add(flashSettle)
32	if !c.flashTick() {
33		t.Fatal("flash did not start")
34	}
35}
36
37func TestFlashFadeAndOutputFrames(t *testing.T) {
38	c, clock := flashCore(t)
39	a := c.cur()
40	c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
41	b := c.screens[1]
42	startFlash(t, c, clock, flashWindow, 3)
43	first := stepScenes(t, c, a)
44	f := first["A"].Flash
45	if f == nil || f.Alpha != 0.8 || f.Color != c.cfg.Border.Active || f.Width != 3 {
46		t.Fatalf("flash %+v", f)
47	}
48	if first["B"].Flash != nil || !first["A"].Animating || !c.animating() {
49		t.Fatal("output/animation routing")
50	}
51	clock.now = clock.now.Add(200 * time.Millisecond)
52	other := stepScenes(t, c, b)
53	if other["A"].Seq != first["A"].Seq || *other["A"].Flash != *f {
54		t.Fatal("other output advanced flash")
55	}
56	half := stepScenes(t, c, a)
57	if half["A"].Flash.Alpha != 0.4 {
58		t.Fatalf("half alpha %v", half["A"].Flash.Alpha)
59	}
60	clock.now = clock.now.Add(200 * time.Millisecond)
61	end := stepScenes(t, c, a)
62	if end["A"].Flash != nil || end["A"].Animating || c.animating() {
63		t.Fatal("flash did not stop frames")
64	}
65	if end["A"].Seq == half["A"].Seq {
66		t.Fatal("end retained animating sequence")
67	}
68}
69
70func TestFlashDebounceCooldownAndReplacement(t *testing.T) {
71	c, clock := flashCore(t)
72	c.requestFlash(flashWindow, 1)
73	clock.now = clock.now.Add(100 * time.Millisecond)
74	c.requestFlash(flashWindow, 2)
75	clock.now = clock.now.Add(50 * time.Millisecond)
76	if c.flashTick() || c.flash.pending == nil {
77		t.Fatal("early stale timer started flash")
78	}
79	clock.now = clock.now.Add(100 * time.Millisecond)
80	if !c.flashTick() || c.flash.running.window != 2 {
81		t.Fatal("last request did not win")
82	}
83	c.requestFlash(flashWindow, 2)
84	if c.flash.running != nil {
85		t.Fatal("request did not replace running flash")
86	}
87	clock.now = clock.now.Add(flashSettle)
88	if c.flashTick() {
89		t.Fatal("cooldown ignored")
90	}
91	clock.now = clock.now.Add(time.Second)
92	startFlash(t, c, clock, flashWindow, 2)
93	c.requestFlash(flashColumn, 2)
94	clock.now = clock.now.Add(flashSettle)
95	if !c.flashTick() || c.flash.running.level != flashColumn {
96		t.Fatal("level not part of cooldown key")
97	}
98}
99
100func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {
101	for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
102		t.Run(string(rune('0'+level)), func(t *testing.T) {
103			c, clock := flashCore(t)
104			w := c.cur().mon.Current()
105			c.requestFlash(level, 3)
106			w.view.off = 20
107			clock.now = clock.now.Add(flashSettle)
108			if c.flashTick() || c.flash.timerC == nil {
109				t.Fatal("did not wait for layout")
110			}
111			w.view.stop()
112			w.MoveColumn(-1)
113			clock.now = clock.now.Add(flashRecheck)
114			if !c.flashTick() {
115				t.Fatal("did not start after settling")
116			}
117			want := Rect{W: 300, H: 200}
118			if level != flashMonitor {
119				for _, p := range c.cur().mon.Layout() {
120					if p.ID == 3 {
121						want = p.Rect
122					}
123				}
124			}
125			if c.flash.rect != want {
126				t.Fatalf("rect %+v want %+v", c.flash.rect, want)
127			}
128		})
129	}
130}
131
132func TestFlashSkipsIneligibleTargets(t *testing.T) {
133	for _, test := range []struct {
134		name   string
135		change func(*Core, *indicatorClock)
136	}{
137		{"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }},
138		{"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }},
139		{"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }},
140		{"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},
141		{"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},
142		{"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},
143	} {
144		t.Run(test.name, func(t *testing.T) {
145			c, clock := flashCore(t)
146			test.change(c, clock)
147			c.requestFlash(flashWindow, 3)
148			clock.now = clock.now.Add(flashSettle)
149			if c.flashTick() || c.flash.running != nil {
150				t.Fatal("ineligible flash")
151			}
152		})
153	}
154}
155
156func TestFlashStopAndColor(t *testing.T) {
157	c, clock := flashCore(t)
158	c.cfg.Focus.FlashColor = "#123456"
159	c.cfg.Animations.Slowdown = 10
160	startFlash(t, c, clock, flashWindow, 3)
161	if s := indicatorScene(t, c); s.Flash.Color != "#123456" {
162		t.Fatal("configured color ignored")
163	}
164	clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
165	if indicatorScene(t, c).Flash != nil {
166		t.Fatal("duration scaled by slowdown")
167	}
168	c.requestFlash(flashWindow, 2)
169	c.stopFlash()
170	if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil {
171		t.Fatal("stop retained state")
172	}
173}
174
175func TestFlashActionTriggers(t *testing.T) {
176	for _, test := range []struct {
177		action Action
178		level  flashLevel
179		setup  func(*Core)
180	}{
181		{ActionFocusColumnLeft, flashColumn, nil},
182		{ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
183		{ActionMoveColumnLeft, flashColumn, nil},
184		{ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
185		{ActionConsumeOrExpelLeft, flashWindow, nil},
186		{ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
187		{ActionToggleFloating, flashWindow, nil},
188		{ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
189		{ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
190		{ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
191		{ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
192		{ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }},
193		{ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }},
194		{ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }},
195	} {
196		t.Run(string(test.action), func(t *testing.T) {
197			c, clock := flashCore(t)
198			if test.setup != nil {
199				test.setup(c)
200			}
201			c.applyAction(test.action)
202			if c.flash.pending == nil || c.flash.pending.level != test.level {
203				t.Fatalf("pending %+v want level %v", c.flash.pending, test.level)
204			}
205			target := *c.flash.pending
206			clock.now = clock.now.Add(flashSettle)
207			if !c.flashTick() {
208				t.Fatal("action flash did not start")
209			}
210			var want Rect
211			for _, p := range c.cur().mon.Layout() {
212				if p.ID == target.window {
213					want = p.Rect
214				}
215			}
216			if c.flash.rect != want {
217				t.Fatalf("action rect %+v want %+v", c.flash.rect, want)
218			}
219		})
220	}
221}
222
223func TestFlashExcludedActions(t *testing.T) {
224	for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} {
225		t.Run(string(action), func(t *testing.T) {
226			c, _ := flashCore(t)
227			c.applyAction(action)
228			if c.flash.pending != nil {
229				t.Fatalf("unexpected flash %+v", c.flash.pending)
230			}
231		})
232	}
233}
234
235func TestFlashMonitorTriggers(t *testing.T) {
236	for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
237		t.Run(string(action), func(t *testing.T) {
238			c, clock := flashCore(t)
239			c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
240			a, b := c.screens[0], c.screens[1]
241			a.x, a.y = 0, 0
242			b.x, b.y = 300, 0
243			switch monitorDirection(action) {
244			case dirLeft:
245				b.x = -400
246			case dirUp:
247				b.x, b.y = 0, -300
248			case dirDown:
249				b.x, b.y = 0, 200
250			}
251			c.applyAction(action)
252			if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" {
253				t.Fatalf("destination request %+v", c.flash.pending)
254			}
255			clock.now = clock.now.Add(flashSettle)
256			if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) {
257				t.Fatalf("monitor rect %+v", c.flash.rect)
258			}
259		})
260	}
261}
262
263func TestFlashColumnUnion(t *testing.T) {
264	c, clock := flashCore(t)
265	w := c.cur().mon.Current()
266	w.ConsumeOrExpel(-1)
267	startFlash(t, c, clock, flashColumn, 3)
268	col := w.Columns[w.columnOf(3)]
269	var area int
270	for _, p := range c.cur().mon.Layout() {
271		for _, id := range col.Windows {
272			if id == p.ID {
273				area += p.Rect.W * p.Rect.H
274			}
275		}
276	}
277	if c.flash.rect.W*c.flash.rect.H != area {
278		t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area)
279	}
280}
281
282func TestFlashGestureLanding(t *testing.T) {
283	c, _ := flashCore(t)
284	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
285	c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond})
286	c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond})
287	if c.flash.pending == nil || c.flash.pending.level != flashColumn {
288		t.Fatalf("landing request %+v", c.flash.pending)
289	}
290	c.stopFlash()
291	c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond})
292	c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond})
293	c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond})
294	if c.flash.pending != nil {
295		t.Fatal("cancelled gesture flashed")
296	}
297}
298
299func TestFlashLockClearsPendingAndRunning(t *testing.T) {
300	for _, running := range []bool{false, true} {
301		c, clock := flashCore(t)
302		state := ports.SecurityState{}
303		gate := portsmocks.NewMockSessionSecurity(t)
304		gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
305		c.opts.Security = gate
306		if running {
307			startFlash(t, c, clock, flashWindow, 3)
308		} else {
309			c.requestFlash(flashWindow, 3)
310		}
311		state = ports.SecurityState{Generation: 1, Protected: true}
312		if !c.syncSecurity() {
313			t.Fatal("security did not change")
314		}
315		if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil {
316			t.Fatal("lock retained flash")
317		}
318		state = ports.SecurityState{Generation: 2}
319		c.syncSecurity()
320		if indicatorScene(t, c).Flash != nil || c.flash.pending != nil {
321			t.Fatal("unlock requested flash")
322		}
323	}
324}
325
326func TestFlashOverviewClickAndKeyboard(t *testing.T) {
327	for _, click := range []bool{true, false} {
328		c, _ := flashCore(t)
329		c.cur().mon.ToggleOverview()
330		scene := indicatorScene(t, c)
331		if click {
332			var rect Rect
333			for _, p := range scene.Windows {
334				if p.ID == 2 {
335					rect = p.Rect
336				}
337			}
338			c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
339			picked, err := c.overviewClick(context.Background())
340			if err != nil || !picked {
341				t.Fatalf("click %v %v", picked, err)
342			}
343		} else {
344			if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil {
345				t.Fatal(err)
346			}
347		}
348		if c.flash.pending == nil || c.flash.pending.level != flashWindow {
349			t.Fatalf("overview request %+v", c.flash.pending)
350		}
351	}
352}
353
354func TestFlashTileDrop(t *testing.T) {
355	c, _ := flashCore(t)
356	scene := indicatorScene(t, c)
357	var rect Rect
358	for _, p := range scene.Windows {
359		if p.ID == 2 {
360			rect = p.Rect
361		}
362	}
363	c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
364	c.drag = &dragState{id: 3}
365	if err := c.endDrag(context.Background()); err != nil {
366		t.Fatal(err)
367	}
368	if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 {
369		t.Fatalf("drop request %+v", c.flash.pending)
370	}
371}
372
373func TestFlashNoImplicitFocusTriggers(t *testing.T) {
374	c, _ := flashCore(t)
375	ctx := context.Background()
376	indicatorScene(t, c)
377	if c.flash.pending != nil {
378		t.Fatal("startup publication flashed")
379	}
380	for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} {
381		if _, stop := c.clientEvent(ctx, ev); stop {
382			t.Fatalf("event %T stopped", ev)
383		}
384		if c.flash.pending != nil {
385			t.Fatalf("event %T flashed", ev)
386		}
387	}
388	indicatorScene(t, c)
389	if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil {
390		t.Fatal(err)
391	}
392	if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
393		t.Fatal(err)
394	}
395	if c.flash.pending != nil {
396		t.Fatal("pointer focus flashed")
397	}
398}
399
400func TestFlashCancelsStaleRunningTargets(t *testing.T) {
401	for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
402		for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
403			t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
404				c, clock := flashCore(t)
405				a := c.cur()
406				c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
407				b := c.screens[1]
408				startFlash(t, c, clock, level, 3)
409				if indicatorScene(t, c).Flash == nil {
410					t.Fatal("setup: no running flash")
411				}
412				switch change {
413				case "overview":
414					a.mon.ToggleOverview()
415				case "output gone":
416					c.screens = []*screen{b}
417					c.focusScreen = 0
418				case "fullscreen":
419					a.mon.ToggleFullscreen()
420				case "unmapped":
421					c.windows.drop(3)
422				case "hidden":
423					w := a.mon.Current()
424					w.ToggleWindowStash()
425					w.ToggleStashVisible()
426				case "other output":
427					a.mon.RemoveWindow(3)
428					b.mon.AddWindow(3)
429				case "workspace changed":
430					a.mon.Focus(a.mon.Active + 1)
431				}
432				// Cancellation must not wait for the target output's next flip.
433				scenes := stepScenes(t, c, b)
434				shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
435				if shouldCancel {
436					if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
437						t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
438					}
439				} else if scenes["A"].Flash == nil || !c.animating() {
440					t.Fatal("monitor flash cancelled for a window change")
441				}
442			})
443		}
444	}
445}
446
447func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
448	c, clock := flashCore(t)
449	startFlash(t, c, clock, flashWindow, 3)
450	first := c.flash.start
451	startFlash(t, c, clock, flashWindow, 2)
452	c.requestFlash(flashWindow, 3)
453	clock.now = clock.now.Add(flashSettle)
454	if c.flashTick() || c.flash.running != nil {
455		t.Fatal("A flashed again after B within cooldown")
456	}
457	clock.now = first.Add(flashCooldown)
458	startFlash(t, c, clock, flashWindow, 3)
459	if c.flash.running.window != 3 {
460		t.Fatal("A did not flash after cooldown")
461	}
462	// Inserting after all previous starts expire prunes old target keys.
463	clock.now = clock.now.Add(flashCooldown)
464	startFlash(t, c, clock, flashMonitor, 0)
465	if len(c.flash.lastStarts) != 1 {
466		t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
467	}
468}
469
470// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
471func BenchmarkFlashSceneBuild(b *testing.B) {
472	var cfg ports.Config
473	cfg.Layout.MaxColumns = 2
474	cfg.Keyboard.CmdKey = "super"
475	cfg.Animations.On = true
476	cfg.Focus.Flash = true
477	cfg.Focus.FlashOpacity = 0.8
478	cfg.Focus.FlashDuration = time.Hour
479	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
480	if err != nil {
481		b.Fatal(err)
482	}
483	c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
484	c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
485	c.cur().mon.AddWindow(1)
486	c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
487	c.flash.start = time.Now()
488	c.flash.rect = Rect{W: 1000, H: 600}
489	c.refreshShown()
490	ctx := context.Background()
491	b.ReportAllocs()
492	b.ResetTimer()
493	for range b.N {
494		c.refreshShown()
495		c.advanceFlash(nil)
496		scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
497		if err != nil {
498			b.Fatal(err)
499		}
500		c.cur().last = scene
501	}
502}
503
504func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
505	for _, tc := range []struct {
506		name     string
507		overflow Overflow
508		action   Action
509	}{
510		{"vertical", OverflowScroll, ActionFocusWindowDown},
511		{"horizontal", OverflowCascade, ActionFocusColumnRight},
512	} {
513		t.Run(tc.name, func(t *testing.T) {
514			c, _ := flashCore(t)
515			m := c.cur().mon
516			m.SetOverflow(tc.overflow)
517			before := m.Current()
518			m.Focus(1)
519			m.AddWindow(4)
520			m.Focus(0)
521			c.applyAction(tc.action)
522			if m.Current() == before {
523				t.Fatal("setup: edge did not switch workspace")
524			}
525			if id, _ := m.Focused(); id != 4 {
526				t.Fatalf("did not land on occupied workspace: %d", id)
527			}
528			if c.flash.pending != nil || c.flash.running != nil {
529				t.Fatal("workspace switch flashed")
530			}
531		})
532	}
533}
534
535func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
536	c, clock := flashCore(t)
537	c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
538	if c.flash.pending != nil {
539		t.Fatal("mapping requested flash")
540	}
541	c.applyAction(ActionToggleFloating)
542	if c.flash.pending == nil || c.flash.pending.window != 4 {
543		t.Fatal("explicit action did not request flash")
544	}
545	c.stopAnimations()
546	clock.now = clock.now.Add(flashSettle)
547	if !c.flashTick() {
548		t.Fatal("freshly mapped target rejected")
549	}
550}
551
552func TestFlashPendingTimerStopped(t *testing.T) {
553	for _, reason := range []string{"replacement", "security", "run exit"} {
554		t.Run(reason, func(t *testing.T) {
555			c, clock := flashCore(t)
556			timer := portsmocks.NewMockTimer(t)
557			timer.EXPECT().C().Return(make(chan time.Time)).Once()
558			timer.EXPECT().Stop().Return(true).Times(1)
559			strictClock := portsmocks.NewMockClock(t)
560			strictClock.EXPECT().Now().Return(clock.now).Maybe()
561			strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
562			c.opts.Clock = strictClock
563			c.requestFlash(flashWindow, 3)
564			// Only the pending flash timer belongs to the strict clock.
565			c.opts.Clock = nil
566			switch reason {
567			case "replacement":
568				c.requestFlash(flashWindow, 2)
569				c.stopFlash()
570			case "security":
571				gate := portsmocks.NewMockSessionSecurity(t)
572				gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
573				c.opts.Security = gate
574				c.syncSecurity()
575			case "run exit":
576				ctx, cancel := context.WithCancel(context.Background())
577				cancel()
578				if err := c.Run(ctx); err != nil {
579					t.Fatal(err)
580				}
581			}
582		})
583	}
584}
585
586func TestFlashUnchangedSampleIsReused(t *testing.T) {
587	c, clock := flashCore(t)
588	startFlash(t, c, clock, flashWindow, 3)
589	first := indicatorScene(t, c).Flash
590	if first == nil {
591		t.Fatal("missing flash")
592	}
593	if next := indicatorScene(t, c).Flash; next != first {
594		t.Fatal("unchanged immutable sample not reused")
595	}
596	clock.now = clock.now.Add(time.Millisecond)
597	if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
598		t.Fatal("changed sample mutated prior scene")
599	}
600}
601
602func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
603	for _, tc := range []struct {
604		name     string
605		action   Action
606		occupied bool
607	}{
608		{"column focus to empty", ActionFocusColumnRight, false},
609		{"column focus to occupied", ActionFocusColumnRight, true},
610		{"monitor focus to empty", ActionFocusMonitorRight, false},
611		{"column move to empty", ActionMoveColumnRight, false},
612		{"column move to occupied", ActionMoveColumnRight, true},
613	} {
614		t.Run(tc.name, func(t *testing.T) {
615			c, clock := flashCore(t)
616			a := c.cur()
617			c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
618			b := c.screens[1]
619			if tc.occupied {
620				c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
621				b.mon.AddWindow(4)
622			}
623			c.applyAction(tc.action)
624			if c.cur() != b {
625				t.Fatal("action did not cross screens")
626			}
627			if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
628				t.Fatalf("destination request %+v", c.flash.pending)
629			}
630			clock.now = clock.now.Add(flashSettle)
631			if !c.flashTick() {
632				t.Fatal("destination flash did not start")
633			}
634			scenes := stepScenes(t, c, b)
635			if scenes["A"].Flash != nil {
636				t.Fatal("source output flashed")
637			}
638			f := scenes["B"].Flash
639			if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
640				t.Fatalf("destination scene flash %+v", f)
641			}
642			if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
643				t.Fatal("setup: destination is not empty")
644			}
645			clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
646			scenes = stepScenes(t, c, b)
647			if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
648				t.Fatal("destination flash did not end")
649			}
650		})
651	}
652}
653
654func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
655	c, clock := flashCore(t)
656	sc := c.cur()
657	c.cur().mon.Current().FocusID(1)
658	c.applyAction(ActionFocusColumnRight)
659	if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
660		t.Fatalf("same-screen request %+v", c.flash.pending)
661	}
662	clock.now = clock.now.Add(flashSettle)
663	if !c.flashTick() {
664		t.Fatal("column flash did not start")
665	}
666	s := indicatorScene(t, c)
667	for _, w := range s.Windows {
668		if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
669			t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
670		}
671	}
672}

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 25

Tags 10

↑↓ moveenter openesc close