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 c.flash.pending != nil {
393		t.Fatal("hover focus flashed")
394	}
395}
396
397func TestFlashCancelsStaleRunningTargets(t *testing.T) {
398	for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
399		for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
400			t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
401				c, clock := flashCore(t)
402				a := c.cur()
403				c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
404				b := c.screens[1]
405				startFlash(t, c, clock, level, 3)
406				if indicatorScene(t, c).Flash == nil {
407					t.Fatal("setup: no running flash")
408				}
409				switch change {
410				case "overview":
411					a.mon.ToggleOverview()
412				case "output gone":
413					c.screens = []*screen{b}
414					c.focusScreen = 0
415				case "fullscreen":
416					a.mon.ToggleFullscreen()
417				case "unmapped":
418					c.windows.drop(3)
419				case "hidden":
420					w := a.mon.Current()
421					w.ToggleWindowStash()
422					w.ToggleStashVisible()
423				case "other output":
424					a.mon.RemoveWindow(3)
425					b.mon.AddWindow(3)
426				case "workspace changed":
427					a.mon.Focus(a.mon.Active + 1)
428				}
429				// Cancellation must not wait for the target output's next flip.
430				scenes := stepScenes(t, c, b)
431				shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
432				if shouldCancel {
433					if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
434						t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
435					}
436				} else if scenes["A"].Flash == nil || !c.animating() {
437					t.Fatal("monitor flash cancelled for a window change")
438				}
439			})
440		}
441	}
442}
443
444func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
445	c, clock := flashCore(t)
446	startFlash(t, c, clock, flashWindow, 3)
447	first := c.flash.start
448	startFlash(t, c, clock, flashWindow, 2)
449	c.requestFlash(flashWindow, 3)
450	clock.now = clock.now.Add(flashSettle)
451	if c.flashTick() || c.flash.running != nil {
452		t.Fatal("A flashed again after B within cooldown")
453	}
454	clock.now = first.Add(flashCooldown)
455	startFlash(t, c, clock, flashWindow, 3)
456	if c.flash.running.window != 3 {
457		t.Fatal("A did not flash after cooldown")
458	}
459	// Inserting after all previous starts expire prunes old target keys.
460	clock.now = clock.now.Add(flashCooldown)
461	startFlash(t, c, clock, flashMonitor, 0)
462	if len(c.flash.lastStarts) != 1 {
463		t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
464	}
465}
466
467// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
468func BenchmarkFlashSceneBuild(b *testing.B) {
469	var cfg ports.Config
470	cfg.Layout.MaxColumns = 2
471	cfg.Keyboard.CmdKey = "super"
472	cfg.Animations.On = true
473	cfg.Focus.Flash = true
474	cfg.Focus.FlashOpacity = 0.8
475	cfg.Focus.FlashDuration = time.Hour
476	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
477	if err != nil {
478		b.Fatal(err)
479	}
480	c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
481	c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
482	c.cur().mon.AddWindow(1)
483	c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
484	c.flash.start = time.Now()
485	c.flash.rect = Rect{W: 1000, H: 600}
486	c.refreshShown()
487	ctx := context.Background()
488	b.ReportAllocs()
489	b.ResetTimer()
490	for range b.N {
491		c.refreshShown()
492		c.advanceFlash(nil)
493		scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
494		if err != nil {
495			b.Fatal(err)
496		}
497		c.cur().last = scene
498	}
499}
500
501func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
502	for _, tc := range []struct {
503		name     string
504		overflow Overflow
505		action   Action
506	}{
507		{"vertical", OverflowScroll, ActionFocusWindowDown},
508		{"horizontal", OverflowCascade, ActionFocusColumnRight},
509	} {
510		t.Run(tc.name, func(t *testing.T) {
511			c, _ := flashCore(t)
512			m := c.cur().mon
513			m.SetOverflow(tc.overflow)
514			before := m.Current()
515			m.Focus(1)
516			m.AddWindow(4)
517			m.Focus(0)
518			c.applyAction(tc.action)
519			if m.Current() == before {
520				t.Fatal("setup: edge did not switch workspace")
521			}
522			if id, _ := m.Focused(); id != 4 {
523				t.Fatalf("did not land on occupied workspace: %d", id)
524			}
525			if c.flash.pending != nil || c.flash.running != nil {
526				t.Fatal("workspace switch flashed")
527			}
528		})
529	}
530}
531
532func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
533	c, clock := flashCore(t)
534	c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
535	if c.flash.pending != nil {
536		t.Fatal("mapping requested flash")
537	}
538	c.applyAction(ActionToggleFloating)
539	if c.flash.pending == nil || c.flash.pending.window != 4 {
540		t.Fatal("explicit action did not request flash")
541	}
542	c.stopAnimations()
543	clock.now = clock.now.Add(flashSettle)
544	if !c.flashTick() {
545		t.Fatal("freshly mapped target rejected")
546	}
547}
548
549func TestFlashPendingTimerStopped(t *testing.T) {
550	for _, reason := range []string{"replacement", "security", "run exit"} {
551		t.Run(reason, func(t *testing.T) {
552			c, clock := flashCore(t)
553			timer := portsmocks.NewMockTimer(t)
554			timer.EXPECT().C().Return(make(chan time.Time)).Once()
555			timer.EXPECT().Stop().Return(true).Times(1)
556			strictClock := portsmocks.NewMockClock(t)
557			strictClock.EXPECT().Now().Return(clock.now).Maybe()
558			strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
559			c.opts.Clock = strictClock
560			c.requestFlash(flashWindow, 3)
561			// Only the pending flash timer belongs to the strict clock.
562			c.opts.Clock = nil
563			switch reason {
564			case "replacement":
565				c.requestFlash(flashWindow, 2)
566				c.stopFlash()
567			case "security":
568				gate := portsmocks.NewMockSessionSecurity(t)
569				gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
570				c.opts.Security = gate
571				c.syncSecurity()
572			case "run exit":
573				ctx, cancel := context.WithCancel(context.Background())
574				cancel()
575				if err := c.Run(ctx); err != nil {
576					t.Fatal(err)
577				}
578			}
579		})
580	}
581}
582
583func TestFlashUnchangedSampleIsReused(t *testing.T) {
584	c, clock := flashCore(t)
585	startFlash(t, c, clock, flashWindow, 3)
586	first := indicatorScene(t, c).Flash
587	if first == nil {
588		t.Fatal("missing flash")
589	}
590	if next := indicatorScene(t, c).Flash; next != first {
591		t.Fatal("unchanged immutable sample not reused")
592	}
593	clock.now = clock.now.Add(time.Millisecond)
594	if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
595		t.Fatal("changed sample mutated prior scene")
596	}
597}
598
599func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
600	for _, tc := range []struct {
601		name     string
602		action   Action
603		occupied bool
604	}{
605		{"column focus to empty", ActionFocusColumnRight, false},
606		{"column focus to occupied", ActionFocusColumnRight, true},
607		{"monitor focus to empty", ActionFocusMonitorRight, false},
608		{"column move to empty", ActionMoveColumnRight, false},
609		{"column move to occupied", ActionMoveColumnRight, true},
610	} {
611		t.Run(tc.name, func(t *testing.T) {
612			c, clock := flashCore(t)
613			a := c.cur()
614			c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
615			b := c.screens[1]
616			if tc.occupied {
617				c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
618				b.mon.AddWindow(4)
619			}
620			c.applyAction(tc.action)
621			if c.cur() != b {
622				t.Fatal("action did not cross screens")
623			}
624			if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
625				t.Fatalf("destination request %+v", c.flash.pending)
626			}
627			clock.now = clock.now.Add(flashSettle)
628			if !c.flashTick() {
629				t.Fatal("destination flash did not start")
630			}
631			scenes := stepScenes(t, c, b)
632			if scenes["A"].Flash != nil {
633				t.Fatal("source output flashed")
634			}
635			f := scenes["B"].Flash
636			if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
637				t.Fatalf("destination scene flash %+v", f)
638			}
639			if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
640				t.Fatal("setup: destination is not empty")
641			}
642			clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
643			scenes = stepScenes(t, c, b)
644			if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
645				t.Fatal("destination flash did not end")
646			}
647		})
648	}
649}
650
651func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
652	c, clock := flashCore(t)
653	sc := c.cur()
654	c.cur().mon.Current().FocusID(1)
655	c.applyAction(ActionFocusColumnRight)
656	if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
657		t.Fatalf("same-screen request %+v", c.flash.pending)
658	}
659	clock.now = clock.now.Add(flashSettle)
660	if !c.flashTick() {
661		t.Fatal("column flash did not start")
662	}
663	s := indicatorScene(t, c)
664	for _, w := range s.Windows {
665		if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
666			t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
667		}
668	}
669}
670
671func TestFlashCancelsOnGeometryChange(t *testing.T) {
672	for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
673		for _, change := range []string{"resize", "new window"} {
674			t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
675				c, clock := flashCore(t)
676				startFlash(t, c, clock, level, 3)
677				before := indicatorScene(t, c)
678				if before.Flash == nil {
679					t.Fatal("setup: no flash")
680				}
681				if level == flashMonitor {
682					c.cur().mon.SetOutput(400, 300)
683				} else if change == "resize" {
684					c.cur().mon.Current().Apply(ActionCycleColumnWidth)
685				} else {
686					c.cur().mon.AddWindow(4)
687					c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
688				}
689				after := indicatorScene(t, c)
690				if after.Flash != nil || c.flash.running != nil {
691					t.Fatal("stale geometry retained")
692				}
693				if before.Flash.Rect != c.flash.published.Rect {
694					t.Fatal("old flash sample mutated")
695				}
696			})
697		}
698	}
699}
700
701func TestFlashGeometryRevalidationAllocations(t *testing.T) {
702	for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
703		t.Run(string(rune('0'+level)), func(t *testing.T) {
704			c, clock := flashCore(t)
705			startFlash(t, c, clock, level, 3)
706			// Measure production work, not the generated clock mock's call log.
707			c.opts.Clock = nil
708			c.flash.start = time.Now()
709			c.cfg.Focus.FlashDuration = time.Hour
710			c.advanceFlash(nil) // warm owner-owned layout scratch
711			if n := testing.AllocsPerRun(100, func() { c.advanceFlash(nil) }); n != 0 {
712				t.Fatalf("flash geometry validation allocates %v times per frame", n)
713			}
714		})
715	}
716}
717
718func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
719	for _, test := range []struct {
720		name   string
721		gaps   int
722		float  bool
723		level  flashLevel
724		radius int
725	}{
726		{"tile with gaps", 12, false, flashWindow, 11},
727		{"column with gaps", 12, false, flashColumn, 11},
728		{"tile without gaps", 0, false, flashWindow, 0},
729		{"float", 0, true, flashWindow, 11},
730		{"monitor", 12, false, flashMonitor, 0},
731	} {
732		t.Run(test.name, func(t *testing.T) {
733			c, clock := flashCore(t)
734			c.cfg.Border.Width, c.cfg.Border.Radius = 3, 14
735			w := c.cur().mon.Current()
736			w.Gaps = test.gaps
737			if test.float {
738				c.applyAction(ActionToggleFloating)
739				c.stopFlash()
740				c.stopAnimations()
741			}
742			id := WindowID(3)
743			if f, ok := w.Focused(); ok && test.float {
744				id = f
745			}
746			startFlash(t, c, clock, test.level, id)
747			want := Rect{W: 300, H: 200}
748			if test.level != flashMonitor {
749				for _, p := range c.cur().mon.Layout() {
750					if p.ID == id {
751						want = p.Rect.Inset(p.Inset, 3)
752						if test.level == flashColumn {
753							want = c.flash.rect
754						}
755					}
756				}
757			}
758			if c.flash.rect != want || c.flash.radius != test.radius {
759				t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
760			}
761			if f := c.flashFor(c.cur()); f == nil || f.Radius != test.radius {
762				t.Fatalf("published %+v", f)
763			}
764			if test.level == flashWindow && test.gaps > 0 {
765				for _, p := range c.cur().mon.Layout() {
766					if p.ID == id && (p.Inset != ports.SideAll || c.flash.rect.W != p.Rect.W-6 || c.flash.rect.H != p.Rect.H-6) {
767						t.Fatalf("tile %+v not inset by 3 on all sides: %+v", p, c.flash.rect)
768					}
769				}
770			}
771		})
772	}
773}
774
775func TestFlashClickFocus(t *testing.T) {
776	for _, test := range []struct {
777		name    string
778		target  WindowID
779		enabled bool
780		want    bool
781	}{
782		{"different window", 1, true, true},
783		{"already focused", 3, true, false},
784		{"disabled", 1, false, false},
785	} {
786		t.Run(test.name, func(t *testing.T) {
787			c, clock := flashCore(t)
788			c.cfg.Focus.Flash = test.enabled
789			indicatorScene(t, c)
790			if c.keyboardFocus() != 3 {
791				t.Fatal("setup: expected window 3 focused")
792			}
793			c.pointer = test.target
794			if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
795				t.Fatal(err)
796			}
797			if c.keyboardFocus() != test.target {
798				t.Fatalf("focus = %d, want %d", c.keyboardFocus(), test.target)
799			}
800			if (c.flash.pending != nil) != test.want {
801				t.Fatalf("flash request = %+v, want flash %v", c.flash.pending, test.want)
802			}
803			// Let the click's viewport transition settle before the flash.
804			clock.now = clock.now.Add(time.Second)
805			stepScenes(t, c, c.cur())
806			if c.flashTick() != test.want {
807				t.Fatalf("flash start: want %v", test.want)
808			}
809			if test.want && (c.flash.running.window != test.target || c.flash.running.level != flashWindow || c.flashFor(c.cur()) == nil) {
810				t.Fatalf("wrong running flash: %+v", c.flash.running)
811			}
812		})
813	}
814}
815
816func TestFlashClickModalFocus(t *testing.T) {
817	for _, unchanged := range []bool{false, true} {
818		t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) {
819			c, _ := flashCore(t)
820			w := c.cur().mon.Current()
821			c.windows.mapped(ports.WindowMapped{ID: 4}, c.now())
822			w.AddDialog(4, 1, 100, 80)
823			w.SetModal(4, true)
824			if !unchanged {
825				w.Click(2)
826			}
827			indicatorScene(t, c)
828			c.pointer = 1
829			if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
830				t.Fatal(err)
831			}
832			if c.keyboardFocus() != 4 {
833				t.Fatalf("focus = %d, want modal 4", c.keyboardFocus())
834			}
835			if unchanged {
836				if c.flash.pending != nil {
837					t.Fatalf("unchanged modal flashed: %+v", c.flash.pending)
838				}
839			} else if c.flash.pending == nil || c.flash.pending.window != 4 {
840				t.Fatalf("want modal flash, got %+v", c.flash.pending)
841			}
842		})
843	}
844}
845
846func TestFlashModifierClickDrag(t *testing.T) {
847	for _, resize := range []bool{false, true} {
848		for _, mode := range []string{"changed", "unchanged", "disabled"} {
849			t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) {
850				c, clock := flashCore(t)
851				w := c.cur().mon.Current()
852				w.Click(1)
853				if resize {
854					w.Apply(ActionToggleFloating)
855				}
856				if mode != "unchanged" {
857					w.Click(3)
858				}
859				c.cfg.Focus.Flash = mode != "disabled"
860				indicatorScene(t, c)
861				c.pointer = 1
862				c.mods = c.cmdMod
863				button := uint32(btnLeft)
864				if resize {
865					button = btnRight
866				}
867				if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil {
868					t.Fatal(err)
869				}
870				if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize {
871					t.Fatalf("drag not started: %+v", c.drag)
872				}
873				want := mode == "changed"
874				if (c.flash.pending != nil) != want {
875					t.Fatalf("flash = %+v, want %v", c.flash.pending, want)
876				}
877				clock.now = clock.now.Add(time.Second)
878				stepScenes(t, c, c.cur())
879				if c.flashTick() != want {
880					t.Fatalf("flash start: want %v", want)
881				}
882			})
883		}
884	}
885}

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