detached f2d166b

position_hint_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"testing"
6	"testing/synctest"
7	"time"
8
9	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10	"github.com/bnema/neferwl/internal/ports"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13)
14
15func TestPositionHintChangesAndTiming(t *testing.T) {
16	c, clock := indicatorCore(t)
17	c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true
18	a := c.cur()
19	a.mon.Current().Overflow = OverflowCascade
20	a.mon.Current().MaxColumns = 1
21	a.mon.AddWindow(1)
22	a.mon.AddWindow(2)
23	c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
24	b := c.screens[1]
25	a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1
26	a.mon.Current().Focus = 1
27	c.updatePositionHints(nil)
28	require.False(t, a.positionHint.live)
29	a.mon.Current().Focus = 0
30	c.updatePositionHints(nil)
31	require.True(t, a.positionHint.sample.Shown())
32	require.False(t, b.positionHint.live)
33	start := a.positionHint.start
34	clock.now = clock.now.Add(100 * time.Millisecond)
35	c.updatePositionHints(nil)
36	require.Equal(t, start, a.positionHint.start, "no-op restarted timer")
37	a.mon.Current().Focus = 1
38	c.updatePositionHints(nil)
39	require.Equal(t, clock.now, a.positionHint.start)
40	clock.now = clock.now.Add(positionHintHold)
41	c.updatePositionHints(nil)
42	require.True(t, c.positionHintFading(a), "hold boundary must start frame scheduling")
43	clock.now = clock.now.Add(positionHintFade / 2)
44	c.updatePositionHints(nil)
45	require.Equal(t, float32(0.5), a.positionHint.sample.Opacity)
46	saved := a.positionHint.sample
47	c.focusScreen = 1
48	c.updatePositionHints(b)
49	require.Equal(t, saved, a.positionHint.sample)
50	require.True(t, b.positionHint.live)
51	clock.now = clock.now.Add(positionHintFade / 2)
52	c.updatePositionHints(a)
53	require.False(t, a.positionHint.live)
54	require.True(t, b.positionHint.live)
55}
56
57func TestPositionHintWorkspaceAndSuppression(t *testing.T) {
58	for _, mode := range []string{"overview", "fullscreen", "off", "no-animation"} {
59		t.Run(mode, func(t *testing.T) {
60			c, clock := indicatorCore(t)
61			c.cfg.Focus.PositionHint = true
62			sc := c.cur()
63			sc.mon.AddWindow(1)
64			c.updatePositionHints(nil)
65			sc.mon.show(sc.mon.Workspaces[1])
66			c.updatePositionHints(nil)
67			require.True(t, sc.positionHint.live)
68			switch mode {
69			case "overview":
70				sc.mon.ov.open = true
71			case "fullscreen":
72				sc.mon.Current().Overflow = OverflowFixed
73				sc.mon.Current().fullscreen = 1
74			case "off":
75				c.cfg.Focus.PositionHint = false
76			default:
77				clock.now = clock.now.Add(positionHintHold)
78			}
79			c.updatePositionHints(nil)
80			require.False(t, sc.positionHint.sample.Shown())
81			require.Nil(t, c.positionHintTimer.timerC)
82		})
83	}
84}
85
86func TestPositionHintGrid(t *testing.T) {
87	c, _ := indicatorCore(t)
88	m := c.cur().mon
89	w := m.Current()
90	w.Overflow, w.MaxColumns = OverflowCascade, 1
91	m.AddWindow(1)
92	m.AddWindow(2)
93	h := positionHintGrid(m)
94	require.False(t, h.Vertical)
95	require.Equal(t, uint8(2), h.Counts[0])
96	require.Equal(t, uint8(1), h.Counts[1], "empty noncascade workspace")
97	require.Equal(t, uint8(1), h.ActiveRow)
98	m.show(m.Workspaces[1])
99	h = positionHintGrid(m)
100	require.True(t, h.Vertical, "current workspace decides axis")
101	require.Equal(t, uint8(1), h.Columns)
102	require.Equal(t, uint8(2), h.Counts[0])
103	require.Equal(t, uint8(1), h.ActiveRow)
104}
105
106func TestPositionHintAllocations(t *testing.T) {
107	// Real clock avoids measuring generated mock bookkeeping, as publishRig does.
108	c := publishRig(t, 1)
109	c.cfg.Focus.PositionHint = true
110	sc := c.cur()
111	w := sc.mon.Current()
112	w.Overflow, w.MaxColumns = OverflowCascade, 1
113	c.updatePositionHints(nil)
114	defer c.stopPositionHintTimer()
115	for name, run := range map[string]func(){
116		"trigger": func() { w.Focus = (w.Focus + 1) % 2; c.updatePositionHints(nil) },
117		"fade": func() {
118			sc.positionHint.live = true
119			sc.positionHint.start = c.now().Add(-positionHintHold - 50*time.Millisecond)
120			c.updatePositionHints(nil)
121		},
122		"inactive": func() { sc.positionHint.live = false; c.updatePositionHints(nil) },
123	} {
124		t.Run(name, func(t *testing.T) { run(); require.Zero(t, testing.AllocsPerRun(100, run)) })
125	}
126}
127
128func TestPositionHintMoveWorkspaceDestination(t *testing.T) {
129	c, _ := indicatorCore(t)
130	c.cfg.Focus.PositionHint = true
131	c.cur().mon.AddWindow(1)
132	c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
133	c.updatePositionHints(nil)
134	source := c.cur()
135	moved := source.mon.Current()
136	c.moveWorkspace(dirRight)
137	c.updatePositionHints(nil)
138	require.Same(t, moved, c.cur().mon.Current())
139	require.False(t, source.positionHint.live)
140	require.True(t, c.cur().positionHint.sample.Shown())
141}
142
143func TestPositionHintCapsAndProtection(t *testing.T) {
144	c, _ := indicatorCore(t)
145	c.cfg.Focus.PositionHint = true
146	m := c.cur().mon
147	for range 20 {
148		m.Workspaces = append(m.Workspaces, m.newWorkspace())
149	}
150	m.Active = 20
151	h := positionHintGrid(m)
152	require.Equal(t, uint8(16), h.Counts[0])
153	require.Equal(t, uint8(15), h.ActiveRow)
154	m.Current().Overflow = OverflowCascade
155	h = positionHintGrid(m)
156	require.Equal(t, uint8(16), h.Columns)
157	require.Equal(t, uint8(15), h.ActiveCol)
158	c.updatePositionHints(nil)
159	c.cur().positionHint.live = true
160	c.security.Protected = true
161	c.updatePositionHints(nil)
162	require.False(t, c.cur().positionHint.live)
163}
164
165func TestPositionHintIncludesNamedEmptyAndMixedOverflow(t *testing.T) {
166	c, _ := indicatorCore(t)
167	m := c.cur().mon
168	w := m.Current()
169	w.Overflow, w.MaxColumns = OverflowCascade, 1
170	m.AddWindow(1)
171	m.AddWindow(2)
172	named := m.newWorkspace()
173	named.Name, named.Overflow = "hidden", OverflowScroll
174	named.Columns = []Column{{Windows: []WindowID{3}}, {Windows: []WindowID{4}, BandStart: true}}
175	m.hidden = append(m.hidden, named)
176	empty := m.newWorkspace()
177	empty.Name = "empty"
178	m.hidden = append(m.hidden, empty)
179	h := positionHintGrid(m)
180	require.Equal(t, uint8(4), h.Columns)
181	require.Equal(t, [16]uint8{2, 1, 1, 1}, h.Counts)
182	require.Equal(t, uint8(0), h.ActiveCol)
183}
184
185func TestPositionHintOverviewCommittedBaseline(t *testing.T) {
186	for _, cancel := range []bool{false, true} {
187		t.Run(map[bool]string{false: "accept", true: "cancel"}[cancel], func(t *testing.T) {
188			c, _ := indicatorCore(t)
189			c.cfg.Focus.PositionHint = true
190			m := c.cur().mon
191			m.AddWindow(1)
192			origin := m.Current()
193			other := m.Workspaces[1]
194			other.AddWindow(2)
195			require.False(t, indicatorScene(t, c).PositionHint.Shown())
196			m.ToggleOverview()
197			require.False(t, indicatorScene(t, c).PositionHint.Shown())
198			m.showOverview(other)
199			require.False(t, indicatorScene(t, c).PositionHint.Shown())
200			require.Same(t, origin, c.cur().positionHint.workspace)
201			m.showOverview(origin)
202			require.False(t, indicatorScene(t, c).PositionHint.Shown())
203			m.showOverview(other)
204			require.False(t, indicatorScene(t, c).PositionHint.Shown())
205			if cancel {
206				m.CancelOverview()
207			} else {
208				m.ToggleOverview()
209			}
210			require.Equal(t, !cancel, indicatorScene(t, c).PositionHint.Shown())
211		})
212	}
213}
214
215func TestPositionHintScrolledAwayFullscreen(t *testing.T) {
216	c, _ := indicatorCore(t)
217	c.cfg.Focus.PositionHint = true
218	c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
219	a := c.screens[0]
220	w := a.mon.Current()
221	w.Overflow = OverflowScroll
222	a.mon.AddWindow(1)
223	a.mon.AddWindow(2)
224	w.fullscreen = 1
225	w.View = w.columnX(1)
226	require.NotZero(t, w.fullscreen)
227	require.Zero(t, w.cover())
228	c.focusScreen = 1
229	c.updatePositionHints(nil)
230	c.focusScreen = 0
231	c.updatePositionHints(nil)
232	require.True(t, a.positionHint.sample.Shown())
233}
234
235func TestPositionHintStartupHotplugReload(t *testing.T) {
236	c, _ := indicatorCore(t)
237	c.cfg.Focus.PositionHint = true
238	require.False(t, indicatorScene(t, c).PositionHint.Shown())
239	c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
240	for _, s := range stepScenes(t, c, nil) {
241		require.False(t, s.PositionHint.Shown())
242	}
243	require.NoError(t, c.apply(c.cfg))
244	for _, s := range stepScenes(t, c, nil) {
245		require.False(t, s.PositionHint.Shown())
246	}
247	c.focusScreen = 1
248	require.True(t, stepScenes(t, c, nil)["B"].PositionHint.Shown())
249}
250
251func TestPositionHintMaxGridTransitionAllocations(t *testing.T) {
252	c := publishRig(t, 1)
253	c.cfg.Focus.PositionHint = true
254	m := c.cur().mon
255	for len(m.Workspaces) < 16 {
256		m.Workspaces = append(m.Workspaces, m.newWorkspace())
257	}
258	for _, w := range m.Workspaces {
259		w.Overflow, w.MaxColumns = OverflowCascade, 1
260		w.Columns = make([]Column, 16)
261		for i := range w.Columns {
262			w.Columns[i] = Column{Windows: []WindowID{WindowID(i + 100)}, BandStart: true}
263		}
264	}
265	c.updatePositionHints(nil)
266	defer c.stopPositionHintTimer()
267	run := func() { m.Active = (m.Active + 1) % 16; c.updatePositionHints(nil) }
268	run()
269	require.Equal(t, [16]uint8{16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, c.cur().positionHint.sample.Counts)
270	require.Zero(t, testing.AllocsPerRun(100, run))
271}
272
273func TestPositionHintRunTimerLifecycle(t *testing.T) {
274	synctest.Test(t, func(t *testing.T) {
275		c := publishRig(t, 2)
276		c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true
277		c.ch.Commands = nil
278		now := time.Unix(1000, 0)
279		clock := portsmocks.NewMockClock(t)
280		clock.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
281		timer := portsmocks.NewMockTimer(t)
282		tick := make(chan time.Time, 1)
283		timer.EXPECT().C().Return((<-chan time.Time)(tick)).Maybe()
284		timer.EXPECT().Reset(500 * time.Millisecond).Return(true).Once()
285		timer.EXPECT().Reset(100 * time.Millisecond).Return(true).Once()
286		timer.EXPECT().Stop().Return(true).Once()
287		timer.EXPECT().Reset(positionHintHold).Return(true).Once()
288		clock.EXPECT().NewTimer(positionHintHold).Return(timer).Once()
289		// The second timer is the existing animation fallback, not another hint timer.
290		frame := portsmocks.NewMockTimer(t)
291		frame.EXPECT().C().Return(make(chan time.Time)).Maybe()
292		frame.EXPECT().Stop().Return(true).Maybe()
293		frame.EXPECT().Reset(mock.Anything).Return(true).Maybe()
294		clock.EXPECT().NewTimer(mock.Anything).Return(frame).Maybe()
295		c.opts.Clock = clock
296		a, b := c.screens[0], c.screens[1]
297		a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1
298		a.mon.Current().Focus = 1
299		c.updatePositionHints(nil)
300		a.mon.Current().Focus = 0
301		c.updatePositionHints(nil)
302		now = now.Add(100 * time.Millisecond)
303		c.focusScreen = 1
304		c.updatePositionHints(nil)
305		require.Same(t, timer, c.positionHintTimer.timer)
306		frames := make(chan ports.OutputFrame)
307		c.ch.Frames = frames
308		ctx, cancel := context.WithCancel(context.Background())
309		done := make(chan error, 1)
310		go func() { done <- c.Run(ctx) }()
311		synctest.Wait()
312		now = now.Add(500 * time.Millisecond)
313		tick <- now
314		synctest.Wait()
315		require.True(t, a.positionHint.fading)
316		require.False(t, b.positionHint.fading)
317		// B still holds for 100ms; the same timer selects its deadline.
318		now = now.Add(100 * time.Millisecond)
319		tick <- now
320		synctest.Wait()
321		require.True(t, b.positionHint.fading)
322		require.Nil(t, c.positionHintTimer.timerC)
323		now = now.Add(positionHintFade)
324		frames <- ports.OutputFrame{Output: "A"}
325		synctest.Wait()
326		frames <- ports.OutputFrame{Output: "B"}
327		synctest.Wait()
328		require.False(t, a.positionHint.sample.Shown())
329		require.False(t, b.positionHint.sample.Shown())
330		require.False(t, c.animating())
331		require.Nil(t, c.positionHintTimer.timerC)
332		// Owner is durably blocked: disabling a newly restarted hint stops
333		// the retained timer instead of leaving an idle wakeup armed.
334		c.focusScreen = 0
335		c.updatePositionHints(nil)
336		require.NotNil(t, c.positionHintTimer.timerC)
337		c.cfg.Focus.PositionHint = false
338		c.updatePositionHints(nil)
339		require.Nil(t, c.positionHintTimer.timerC)
340		cancel()
341		synctest.Wait()
342		require.NoError(t, <-done)
343	})
344}

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