main 5d6fa26

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} {
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			for _, p := range c.cur().mon.Layout() {
119				if p.ID == 3 {
120					want = p.Rect
121				}
122			}
123
124			if c.flash.rect != want {
125				t.Fatalf("rect %+v want %+v", c.flash.rect, want)
126			}
127		})
128	}
129}
130
131func TestFlashSkipsIneligibleTargets(t *testing.T) {
132	for _, test := range []struct {
133		name   string
134		change func(*Core, *indicatorClock)
135	}{
136		{"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }},
137		{"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }},
138		{"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }},
139		{"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},
140		{"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},
141		{"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},
142	} {
143		t.Run(test.name, func(t *testing.T) {
144			c, clock := flashCore(t)
145			test.change(c, clock)
146			c.requestFlash(flashWindow, 3)
147			clock.now = clock.now.Add(flashSettle)
148			if c.flashTick() || c.flash.running != nil {
149				t.Fatal("ineligible flash")
150			}
151		})
152	}
153}
154
155func TestFlashStopAndColor(t *testing.T) {
156	c, clock := flashCore(t)
157	c.cfg.Focus.FlashColor = "#123456"
158	c.cfg.Animations.Slowdown = 10
159	startFlash(t, c, clock, flashWindow, 3)
160	if s := indicatorScene(t, c); s.Flash.Color != "#123456" {
161		t.Fatal("configured color ignored")
162	}
163	clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
164	if indicatorScene(t, c).Flash != nil {
165		t.Fatal("duration scaled by slowdown")
166	}
167	c.requestFlash(flashWindow, 2)
168	c.stopFlash()
169	if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil {
170		t.Fatal("stop retained state")
171	}
172}
173
174func TestFlashActionTriggers(t *testing.T) {
175	for _, test := range []struct {
176		action Action
177		level  flashLevel
178		setup  func(*Core)
179	}{
180		{ActionFocusColumnLeft, flashColumn, nil},
181		{ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
182		{ActionMoveColumnLeft, flashColumn, nil},
183		{ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
184		{ActionConsumeOrExpelLeft, flashWindow, nil},
185		{ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
186		{ActionToggleFloating, flashWindow, nil},
187		{ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
188		{ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
189		{ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
190		{ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
191		{ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }},
192		{ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }},
193		{ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }},
194	} {
195		t.Run(string(test.action), func(t *testing.T) {
196			c, clock := flashCore(t)
197			if test.setup != nil {
198				test.setup(c)
199			}
200			c.applyAction(test.action)
201			if c.flash.pending == nil || c.flash.pending.level != test.level {
202				t.Fatalf("pending %+v want level %v", c.flash.pending, test.level)
203			}
204			target := *c.flash.pending
205			clock.now = clock.now.Add(flashSettle)
206			if !c.flashTick() {
207				t.Fatal("action flash did not start")
208			}
209			var want Rect
210			for _, p := range c.cur().mon.Layout() {
211				if p.ID == target.window {
212					want = p.Rect
213				}
214			}
215			if c.flash.rect != want {
216				t.Fatalf("action rect %+v want %+v", c.flash.rect, want)
217			}
218		})
219	}
220}
221
222func TestFlashExcludedActions(t *testing.T) {
223	for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} {
224		t.Run(string(action), func(t *testing.T) {
225			c, _ := flashCore(t)
226			c.applyAction(action)
227			if c.flash.pending != nil {
228				t.Fatalf("unexpected flash %+v", c.flash.pending)
229			}
230		})
231	}
232}
233
234func TestFlashCrossOutputActionTriggers(t *testing.T) {
235	for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
236		t.Run(string(action), func(t *testing.T) {
237			c, clock := flashCore(t)
238			c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
239			a, b := c.screens[0], c.screens[1]
240			a.x, a.y = 0, 0
241			b.x, b.y = 300, 0
242			switch monitorDirection(action) {
243			case dirLeft:
244				b.x = -400
245			case dirUp:
246				b.x, b.y = 0, -300
247			case dirDown:
248				b.x, b.y = 0, 200
249			}
250			c.applyAction(action)
251			id, _ := b.mon.Focused()
252			if id == 0 {
253				if c.flash.pending != nil {
254					t.Fatalf("empty destination request %+v", c.flash.pending)
255				}
256				return
257			}
258			if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
259				t.Fatalf("destination request %+v", c.flash.pending)
260			}
261			clock.now = clock.now.Add(flashSettle)
262			if !c.flashTick() {
263				t.Fatal("destination window flash did not start")
264			}
265			want, _, ok := c.flashRect(*c.flash.running, b)
266			if !ok || c.flash.rect != want {
267				t.Fatalf("window rect %+v want %+v", c.flash.rect, want)
268			}
269		})
270	}
271}
272
273func TestFlashColumnUnion(t *testing.T) {
274	c, clock := flashCore(t)
275	w := c.cur().mon.Current()
276	w.ConsumeOrExpel(-1)
277	startFlash(t, c, clock, flashColumn, 3)
278	col := w.Columns[w.columnOf(3)]
279	var area int
280	for _, p := range c.cur().mon.Layout() {
281		for _, id := range col.Windows {
282			if id == p.ID {
283				area += p.Rect.W * p.Rect.H
284			}
285		}
286	}
287	if c.flash.rect.W*c.flash.rect.H != area {
288		t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area)
289	}
290}
291
292func TestFlashGestureLanding(t *testing.T) {
293	c, _ := flashCore(t)
294	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
295	c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond})
296	c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond})
297	if c.flash.pending == nil || c.flash.pending.level != flashColumn {
298		t.Fatalf("landing request %+v", c.flash.pending)
299	}
300	c.stopFlash()
301	c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond})
302	c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond})
303	c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond})
304	if c.flash.pending != nil {
305		t.Fatal("cancelled gesture flashed")
306	}
307}
308
309func TestFlashLockClearsPendingAndRunning(t *testing.T) {
310	for _, running := range []bool{false, true} {
311		c, clock := flashCore(t)
312		state := ports.SecurityState{}
313		gate := portsmocks.NewMockSessionSecurity(t)
314		gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315		c.opts.Security = gate
316		if running {
317			startFlash(t, c, clock, flashWindow, 3)
318		} else {
319			c.requestFlash(flashWindow, 3)
320		}
321		state = ports.SecurityState{Generation: 1, Protected: true}
322		if !c.syncSecurity() {
323			t.Fatal("security did not change")
324		}
325		if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil {
326			t.Fatal("lock retained flash")
327		}
328		state = ports.SecurityState{Generation: 2}
329		c.syncSecurity()
330		if indicatorScene(t, c).Flash != nil || c.flash.pending != nil {
331			t.Fatal("unlock requested flash")
332		}
333	}
334}
335
336func TestFlashOverviewClickAndKeyboard(t *testing.T) {
337	for _, click := range []bool{true, false} {
338		c, _ := flashCore(t)
339		c.cur().mon.ToggleOverview()
340		scene := indicatorScene(t, c)
341		if click {
342			var rect Rect
343			for _, p := range scene.Windows {
344				if p.ID == 2 {
345					rect = p.Rect
346				}
347			}
348			c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
349			picked, err := c.overviewClick(context.Background())
350			if err != nil || !picked {
351				t.Fatalf("click %v %v", picked, err)
352			}
353		} else {
354			if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil {
355				t.Fatal(err)
356			}
357		}
358		if c.flash.pending == nil || c.flash.pending.level != flashWindow {
359			t.Fatalf("overview request %+v", c.flash.pending)
360		}
361	}
362}
363
364func TestFlashTileDrop(t *testing.T) {
365	c, _ := flashCore(t)
366	scene := indicatorScene(t, c)
367	var rect Rect
368	for _, p := range scene.Windows {
369		if p.ID == 2 {
370			rect = p.Rect
371		}
372	}
373	c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
374	c.drag = &dragState{id: 3}
375	if err := c.endDrag(context.Background()); err != nil {
376		t.Fatal(err)
377	}
378	if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 {
379		t.Fatalf("drop request %+v", c.flash.pending)
380	}
381}
382
383func TestFlashNoImplicitFocusTriggers(t *testing.T) {
384	c, _ := flashCore(t)
385	ctx := context.Background()
386	indicatorScene(t, c)
387	if c.flash.pending != nil {
388		t.Fatal("startup publication flashed")
389	}
390	for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} {
391		if _, stop := c.clientEvent(ctx, ev); stop {
392			t.Fatalf("event %T stopped", ev)
393		}
394		if c.flash.pending != nil {
395			t.Fatalf("event %T flashed", ev)
396		}
397	}
398	indicatorScene(t, c)
399	if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil {
400		t.Fatal(err)
401	}
402	if c.flash.pending != nil {
403		t.Fatal("hover focus flashed")
404	}
405}
406
407func TestFlashCancelsStaleRunningTargets(t *testing.T) {
408	for _, level := range []flashLevel{flashWindow, flashColumn} {
409		for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
410			t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
411				c, clock := flashCore(t)
412				a := c.cur()
413				c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
414				b := c.screens[1]
415				startFlash(t, c, clock, level, 3)
416				if indicatorScene(t, c).Flash == nil {
417					t.Fatal("setup: no running flash")
418				}
419				switch change {
420				case "overview":
421					a.mon.ToggleOverview()
422				case "output gone":
423					c.screens = []*screen{b}
424					c.focusScreen = 0
425				case "fullscreen":
426					a.mon.ToggleFullscreen()
427				case "unmapped":
428					c.windows.drop(3)
429				case "hidden":
430					w := a.mon.Current()
431					w.ToggleWindowStash()
432					w.ToggleStashVisible()
433				case "other output":
434					a.mon.RemoveWindow(3)
435					b.mon.AddWindow(3)
436				case "workspace changed":
437					a.mon.Focus(a.mon.Active + 1)
438				}
439				// Cancellation must not wait for the target output's next flip.
440				scenes := stepScenes(t, c, b)
441				if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
442					t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
443				}
444			})
445		}
446	}
447}
448
449func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
450	c, clock := flashCore(t)
451	startFlash(t, c, clock, flashWindow, 3)
452	first := c.flash.start
453	startFlash(t, c, clock, flashWindow, 2)
454	c.requestFlash(flashWindow, 3)
455	clock.now = clock.now.Add(flashSettle)
456	if c.flashTick() || c.flash.running != nil {
457		t.Fatal("A flashed again after B within cooldown")
458	}
459	clock.now = first.Add(flashCooldown)
460	startFlash(t, c, clock, flashWindow, 3)
461	if c.flash.running.window != 3 {
462		t.Fatal("A did not flash after cooldown")
463	}
464	// Inserting after all previous starts expire prunes old target keys.
465	clock.now = clock.now.Add(flashCooldown)
466	startFlash(t, c, clock, flashWindow, 3)
467	if len(c.flash.lastStarts) != 1 {
468		t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
469	}
470}
471
472// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
473func BenchmarkFlashSceneBuild(b *testing.B) {
474	var cfg ports.Config
475	cfg.Layout.MaxColumns = 2
476	cfg.Keyboard.CmdKey = "super"
477	cfg.Animations.On = true
478	cfg.Focus.Flash = true
479	cfg.Focus.FlashOpacity = 0.8
480	cfg.Focus.FlashDuration = time.Hour
481	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
482	if err != nil {
483		b.Fatal(err)
484	}
485	c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
486	c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
487	c.cur().mon.AddWindow(1)
488	c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
489	c.flash.start = time.Now()
490	c.flash.rect = Rect{W: 1000, H: 600}
491	c.refreshShown()
492	ctx := context.Background()
493	b.ReportAllocs()
494	b.ResetTimer()
495	for range b.N {
496		c.refreshShown()
497		c.advanceFlash(nil)
498		scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
499		if err != nil {
500			b.Fatal(err)
501		}
502		c.cur().last = scene
503	}
504}
505
506func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
507	for _, tc := range []struct {
508		name     string
509		overflow Overflow
510		action   Action
511	}{
512		{"vertical", OverflowScroll, ActionFocusWindowDown},
513		{"horizontal", OverflowCascade, ActionFocusColumnRight},
514	} {
515		t.Run(tc.name, func(t *testing.T) {
516			c, _ := flashCore(t)
517			m := c.cur().mon
518			m.SetOverflow(tc.overflow)
519			before := m.Current()
520			m.Focus(1)
521			m.AddWindow(4)
522			m.Focus(0)
523			c.applyAction(tc.action)
524			if m.Current() == before {
525				t.Fatal("setup: edge did not switch workspace")
526			}
527			if id, _ := m.Focused(); id != 4 {
528				t.Fatalf("did not land on occupied workspace: %d", id)
529			}
530			if c.flash.pending != nil || c.flash.running != nil {
531				t.Fatal("workspace switch flashed")
532			}
533		})
534	}
535}
536
537func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
538	c, clock := flashCore(t)
539	c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
540	if c.flash.pending != nil {
541		t.Fatal("mapping requested flash")
542	}
543	c.applyAction(ActionToggleFloating)
544	if c.flash.pending == nil || c.flash.pending.window != 4 {
545		t.Fatal("explicit action did not request flash")
546	}
547	c.stopAnimations()
548	clock.now = clock.now.Add(flashSettle)
549	if !c.flashTick() {
550		t.Fatal("freshly mapped target rejected")
551	}
552}
553
554func TestFlashPendingTimerStopped(t *testing.T) {
555	for _, reason := range []string{"replacement", "security", "run exit"} {
556		t.Run(reason, func(t *testing.T) {
557			c, clock := flashCore(t)
558			timer := portsmocks.NewMockTimer(t)
559			timer.EXPECT().C().Return(make(chan time.Time)).Once()
560			timer.EXPECT().Stop().Return(true).Times(1)
561			strictClock := portsmocks.NewMockClock(t)
562			strictClock.EXPECT().Now().Return(clock.now).Maybe()
563			strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
564			c.opts.Clock = strictClock
565			c.requestFlash(flashWindow, 3)
566			// Only the pending flash timer belongs to the strict clock.
567			c.opts.Clock = nil
568			switch reason {
569			case "replacement":
570				c.requestFlash(flashWindow, 2)
571				c.stopFlash()
572			case "security":
573				gate := portsmocks.NewMockSessionSecurity(t)
574				gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
575				c.opts.Security = gate
576				c.syncSecurity()
577			case "run exit":
578				ctx, cancel := context.WithCancel(context.Background())
579				cancel()
580				if err := c.Run(ctx); err != nil {
581					t.Fatal(err)
582				}
583			}
584		})
585	}
586}
587
588func TestFlashUnchangedSampleIsReused(t *testing.T) {
589	c, clock := flashCore(t)
590	startFlash(t, c, clock, flashWindow, 3)
591	first := indicatorScene(t, c).Flash
592	if first == nil {
593		t.Fatal("missing flash")
594	}
595	if next := indicatorScene(t, c).Flash; next != first {
596		t.Fatal("unchanged immutable sample not reused")
597	}
598	clock.now = clock.now.Add(time.Millisecond)
599	if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
600		t.Fatal("changed sample mutated prior scene")
601	}
602}
603
604func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
605	for _, tc := range []struct {
606		name     string
607		action   Action
608		occupied bool
609		want     WindowID
610	}{
611		{"column focus to empty", ActionFocusColumnRight, false, 0},
612		{"column focus to occupied", ActionFocusColumnRight, true, 4},
613		{"monitor focus to empty", ActionFocusMonitorRight, false, 0},
614		{"column move to empty", ActionMoveColumnRight, false, 3},
615		{"column move to occupied", ActionMoveColumnRight, true, 3},
616	} {
617		t.Run(tc.name, func(t *testing.T) {
618			c, clock := flashCore(t)
619			a := c.cur()
620			c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
621			b := c.screens[1]
622			if tc.occupied {
623				c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
624				b.mon.AddWindow(4)
625			}
626			c.applyAction(tc.action)
627			if c.cur() != b {
628				t.Fatal("action did not cross screens")
629			}
630			id, _ := b.mon.Focused()
631			if id != tc.want {
632				t.Fatalf("focus %d want %d", id, tc.want)
633			}
634			if tc.want == 0 {
635				if c.flash.pending != nil {
636					t.Fatalf("empty destination request %+v", c.flash.pending)
637				}
638				clock.now = clock.now.Add(flashSettle)
639				if c.flashTick() || stepScenes(t, c, b)["B"].Flash != nil {
640					t.Fatal("empty destination flashed")
641				}
642				return
643			}
644			if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
645				t.Fatalf("destination request %+v", c.flash.pending)
646			}
647			clock.now = clock.now.Add(flashSettle)
648			if !c.flashTick() {
649				t.Fatal("destination flash did not start")
650			}
651			scenes := stepScenes(t, c, b)
652			if scenes["A"].Flash != nil {
653				t.Fatal("source output flashed")
654			}
655			f := scenes["B"].Flash
656			want, _, ok := c.flashRect(*c.flash.running, b)
657			if f == nil || !ok || f.Rect != want || !scenes["B"].Animating {
658				t.Fatalf("destination scene flash %+v", f)
659			}
660			if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
661				t.Fatal("setup: destination is not empty")
662			}
663			clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
664			scenes = stepScenes(t, c, b)
665			if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
666				t.Fatal("destination flash did not end")
667			}
668		})
669	}
670}
671
672func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
673	c, clock := flashCore(t)
674	sc := c.cur()
675	c.cur().mon.Current().FocusID(1)
676	c.applyAction(ActionFocusColumnRight)
677	if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
678		t.Fatalf("same-screen request %+v", c.flash.pending)
679	}
680	clock.now = clock.now.Add(flashSettle)
681	if !c.flashTick() {
682		t.Fatal("column flash did not start")
683	}
684	s := indicatorScene(t, c)
685	for _, w := range s.Windows {
686		if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
687			t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
688		}
689	}
690}
691
692func TestFlashCancelsOnGeometryChange(t *testing.T) {
693	for _, level := range []flashLevel{flashWindow, flashColumn} {
694		for _, change := range []string{"resize", "new window"} {
695			t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
696				c, clock := flashCore(t)
697				startFlash(t, c, clock, level, 3)
698				before := indicatorScene(t, c)
699				if before.Flash == nil {
700					t.Fatal("setup: no flash")
701				}
702				if change == "resize" {
703					c.cur().mon.Current().Apply(ActionCycleColumnWidth)
704				} else {
705					c.cur().mon.AddWindow(4)
706					c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
707				}
708				after := indicatorScene(t, c)
709				if after.Flash != nil || c.flash.running != nil {
710					t.Fatal("stale geometry retained")
711				}
712				if before.Flash.Rect != c.flash.published.Rect {
713					t.Fatal("old flash sample mutated")
714				}
715			})
716		}
717	}
718}
719
720func TestFlashGeometryRevalidationAllocations(t *testing.T) {
721	for _, level := range []flashLevel{flashWindow, flashColumn} {
722		t.Run(string(rune('0'+level)), func(t *testing.T) {
723			c, clock := flashCore(t)
724			startFlash(t, c, clock, level, 3)
725			// Measure production work, not the generated clock mock's call log.
726			c.opts.Clock = nil
727			c.flash.start = time.Now()
728			c.cfg.Focus.FlashDuration = time.Hour
729			c.advanceFlash(nil) // warm owner-owned layout scratch
730			if n := testing.AllocsPerRun(100, func() { c.advanceFlash(nil) }); n != 0 {
731				t.Fatalf("flash geometry validation allocates %v times per frame", n)
732			}
733		})
734	}
735}
736
737func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
738	for _, test := range []struct {
739		name   string
740		gaps   int
741		float  bool
742		level  flashLevel
743		radius int
744	}{
745		{"tile with gaps", 12, false, flashWindow, 11},
746		{"column with gaps", 12, false, flashColumn, 11},
747		{"tile without gaps", 0, false, flashWindow, 0},
748		{"float", 0, true, flashWindow, 11},
749	} {
750		t.Run(test.name, func(t *testing.T) {
751			c, clock := flashCore(t)
752			c.cfg.Border.Width, c.cfg.Border.Radius = 3, 14
753			w := c.cur().mon.Current()
754			w.Gaps = test.gaps
755			if test.float {
756				c.applyAction(ActionToggleFloating)
757				c.stopFlash()
758				c.stopAnimations()
759			}
760			id := WindowID(3)
761			if f, ok := w.Focused(); ok && test.float {
762				id = f
763			}
764			startFlash(t, c, clock, test.level, id)
765			want := Rect{W: 300, H: 200}
766			for _, p := range c.cur().mon.Layout() {
767				if p.ID == id {
768					want = p.Rect.Inset(p.Inset, 3)
769					if test.level == flashColumn {
770						want = c.flash.rect
771					}
772				}
773			}
774
775			if c.flash.rect != want || c.flash.radius != test.radius {
776				t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
777			}
778			if f := c.flashFor(c.cur()); f == nil || f.Radius != test.radius {
779				t.Fatalf("published %+v", f)
780			}
781			if test.level == flashWindow && test.gaps > 0 {
782				for _, p := range c.cur().mon.Layout() {
783					if p.ID == id && (p.Inset != ports.SideAll || c.flash.rect.W != p.Rect.W-6 || c.flash.rect.H != p.Rect.H-6) {
784						t.Fatalf("tile %+v not inset by 3 on all sides: %+v", p, c.flash.rect)
785					}
786				}
787			}
788		})
789	}
790}
791
792func TestFlashClickFocus(t *testing.T) {
793	for _, test := range []struct {
794		name    string
795		target  WindowID
796		enabled bool
797		want    bool
798	}{
799		{"different window", 1, true, true},
800		{"already focused", 3, true, false},
801		{"disabled", 1, false, false},
802	} {
803		t.Run(test.name, func(t *testing.T) {
804			c, clock := flashCore(t)
805			c.cfg.Focus.Flash = test.enabled
806			indicatorScene(t, c)
807			if c.keyboardFocus() != 3 {
808				t.Fatal("setup: expected window 3 focused")
809			}
810			c.pointer = test.target
811			if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
812				t.Fatal(err)
813			}
814			if c.keyboardFocus() != test.target {
815				t.Fatalf("focus = %d, want %d", c.keyboardFocus(), test.target)
816			}
817			if (c.flash.pending != nil) != test.want {
818				t.Fatalf("flash request = %+v, want flash %v", c.flash.pending, test.want)
819			}
820			// Let the click's viewport transition settle before the flash.
821			clock.now = clock.now.Add(time.Second)
822			stepScenes(t, c, c.cur())
823			if c.flashTick() != test.want {
824				t.Fatalf("flash start: want %v", test.want)
825			}
826			if test.want && (c.flash.running.window != test.target || c.flash.running.level != flashWindow || c.flashFor(c.cur()) == nil) {
827				t.Fatalf("wrong running flash: %+v", c.flash.running)
828			}
829		})
830	}
831}
832
833func TestFlashClickModalFocus(t *testing.T) {
834	for _, unchanged := range []bool{false, true} {
835		t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) {
836			c, _ := flashCore(t)
837			w := c.cur().mon.Current()
838			c.windows.mapped(ports.WindowMapped{ID: 4}, c.now())
839			w.AddDialog(4, 1, 100, 80)
840			w.SetModal(4, true)
841			if !unchanged {
842				w.Click(2)
843			}
844			indicatorScene(t, c)
845			c.pointer = 1
846			if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
847				t.Fatal(err)
848			}
849			if c.keyboardFocus() != 4 {
850				t.Fatalf("focus = %d, want modal 4", c.keyboardFocus())
851			}
852			if unchanged {
853				if c.flash.pending != nil {
854					t.Fatalf("unchanged modal flashed: %+v", c.flash.pending)
855				}
856			} else if c.flash.pending == nil || c.flash.pending.window != 4 {
857				t.Fatalf("want modal flash, got %+v", c.flash.pending)
858			}
859		})
860	}
861}
862
863func TestFlashModifierClickDrag(t *testing.T) {
864	for _, resize := range []bool{false, true} {
865		for _, mode := range []string{"changed", "unchanged", "disabled"} {
866			t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) {
867				c, clock := flashCore(t)
868				w := c.cur().mon.Current()
869				w.Click(1)
870				if resize {
871					w.Apply(ActionToggleFloating)
872				}
873				if mode != "unchanged" {
874					w.Click(3)
875				}
876				c.cfg.Focus.Flash = mode != "disabled"
877				indicatorScene(t, c)
878				c.pointer = 1
879				c.mods = c.cmdMod
880				button := uint32(btnLeft)
881				if resize {
882					button = btnRight
883				}
884				if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil {
885					t.Fatal(err)
886				}
887				if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize {
888					t.Fatalf("drag not started: %+v", c.drag)
889				}
890				want := mode == "changed"
891				if (c.flash.pending != nil) != want {
892					t.Fatalf("flash = %+v, want %v", c.flash.pending, want)
893				}
894				clock.now = clock.now.Add(time.Second)
895				stepScenes(t, c, c.cur())
896				if c.flashTick() != want {
897					t.Fatalf("flash start: want %v", want)
898				}
899			})
900		}
901	}
902}

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 23

Tags 10

↑↓ moveenter openesc close