1package core
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/neferwl/internal/ports"
8)
9
10// pulseCore is indicatorCore with the focus pulse on and two tiled windows,
11// 2 focused, its pulse settled and over.
12func pulseCore(t *testing.T) (*Core, *indicatorClock) {
13 t.Helper()
14 c, ic := indicatorCore(t)
15 c.cfg.Animations.On = true
16 c.cfg.Focus.Animation = ports.FocusAnimationPulse
17 c.cfg.Focus.Strength = 0.05
18 c.cur().mon.AddWindow(1)
19 c.cur().mon.AddWindow(2)
20 indicatorScene(t, c)
21 ic.now = ic.now.Add(pulseSettle)
22 c.pulseTick()
23 ic.now = ic.now.Add(pulseCooldown)
24 indicatorScene(t, c)
25 return c, ic
26}
27
28func pulseOf(s ports.Scene, id WindowID) float64 {
29 for _, w := range s.Windows {
30 if w.ID == id {
31 return w.FocusEffect
32 }
33 }
34 return -1
35}
36
37func TestFocusPulseAfterSettle(t *testing.T) {
38 c, ic := pulseCore(t)
39 c.cur().mon.Current().FocusID(1)
40 if s := indicatorScene(t, c); pulseOf(s, 1) != 0 {
41 t.Fatalf("pulse before settle: %v", pulseOf(s, 1))
42 }
43 ic.now = ic.now.Add(pulseSettle)
44 if !c.pulseTick() {
45 t.Fatal("no pulse after settle")
46 }
47 if !c.animating() {
48 t.Fatal("a running pulse asks for no frames")
49 }
50 ic.now = ic.now.Add(pulseRise)
51 s := indicatorScene(t, c)
52 if p := pulseOf(s, 1); p < 0.05*0.99 || p > 0.05 {
53 t.Fatalf("peak %v, want the configured 0.05", p)
54 }
55 if pulseOf(s, 2) != 0 {
56 t.Fatal("unfocused window pulses")
57 }
58 ic.now = ic.now.Add(pulseFall)
59 if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || c.animating() {
60 t.Fatalf("pulse left after its end: %v", pulseOf(s, 1))
61 }
62}
63
64// Switching faster than pulseSettle never pulses: only the last focus counts.
65func TestFocusPulseDebounced(t *testing.T) {
66 c, ic := pulseCore(t)
67 for range 4 {
68 c.cur().mon.Current().FocusID(1)
69 indicatorScene(t, c)
70 ic.now = ic.now.Add(pulseSettle / 3)
71 c.cur().mon.Current().FocusID(2)
72 indicatorScene(t, c)
73 ic.now = ic.now.Add(pulseSettle / 3)
74 }
75 // A stale timer of an earlier focus fires: the focus is too recent.
76 if c.pulseTick() {
77 t.Fatal("pulse while switching")
78 }
79 if c.pulse.timerC == nil {
80 t.Fatal("no timer for the last focus")
81 }
82 ic.now = ic.now.Add(pulseSettle)
83 // Window 2 pulsed in pulseCore over a second ago: it may pulse again.
84 if !c.pulseTick() || c.pulse.id != 2 {
85 t.Fatalf("last focus did not pulse: %d", c.pulse.id)
86 }
87}
88
89// A focus change stops the running pulse; coming back within the cooldown
90// does not pulse the same window again.
91func TestFocusPulseStopsAndCoolsDown(t *testing.T) {
92 c, ic := pulseCore(t)
93 c.cur().mon.Current().FocusID(1)
94 indicatorScene(t, c)
95 ic.now = ic.now.Add(pulseSettle)
96 c.pulseTick()
97 ic.now = ic.now.Add(pulseRise / 2)
98 c.cur().mon.Current().FocusID(2)
99 if s := indicatorScene(t, c); pulseOf(s, 1) != 0 || pulseOf(s, 2) != 0 {
100 t.Fatal("pulse survived a focus change")
101 }
102 c.cur().mon.Current().FocusID(1)
103 indicatorScene(t, c)
104 ic.now = ic.now.Add(pulseSettle)
105 if c.pulseTick() {
106 t.Fatal("same window pulsed within the cooldown")
107 }
108}
109
110// A window focused as it maps does not pulse; focusing it again later does.
111func TestFocusPulseSkipsNewWindow(t *testing.T) {
112 c, ic := pulseCore(t)
113 c.windows.mapped(ports.WindowMapped{ID: 3}, ic.now)
114 c.cur().mon.AddWindow(3)
115 indicatorScene(t, c)
116 if c.pulse.timerC != nil {
117 t.Fatal("opening a window arms the pulse")
118 }
119 ic.now = ic.now.Add(pulseSettle)
120 if c.pulseTick() || c.animating() {
121 t.Fatal("a new window pulses")
122 }
123 c.cur().mon.Current().FocusID(1)
124 indicatorScene(t, c)
125 c.cur().mon.Current().FocusID(3)
126 indicatorScene(t, c)
127 ic.now = ic.now.Add(pulseSettle)
128 if !c.pulseTick() || c.pulse.id != 3 {
129 t.Fatal("an existing window did not pulse")
130 }
131}
132
133// A window that mapped earlier without the focus pulses when focused.
134func TestFocusPulseWindowMappedEarlier(t *testing.T) {
135 c, ic := pulseCore(t)
136 c.windows.mapped(ports.WindowMapped{ID: 1}, ic.now.Add(-time.Minute))
137 c.cur().mon.Current().FocusID(1)
138 indicatorScene(t, c)
139 ic.now = ic.now.Add(pulseSettle)
140 if !c.pulseTick() || c.pulse.id != 1 {
141 t.Fatal("a window mapped earlier did not pulse")
142 }
143}
144
145func TestFocusPulseSkipsFullscreen(t *testing.T) {
146 c, ic := pulseCore(t)
147 c.cur().mon.Current().FocusID(1)
148 c.cur().mon.ToggleFullscreen()
149 indicatorScene(t, c)
150 ic.now = ic.now.Add(pulseSettle)
151 if c.pulseTick() || c.animating() {
152 t.Fatal("fullscreen window asks for pulse frames")
153 }
154 // Fullscreen entered mid-pulse ends it.
155 c.cur().mon.ToggleFullscreen()
156 c.cur().mon.Current().FocusID(2)
157 indicatorScene(t, c)
158 ic.now = ic.now.Add(pulseSettle)
159 if !c.pulseTick() {
160 t.Fatal("no pulse on the tiled window")
161 }
162 c.cur().mon.ToggleFullscreen()
163 if s := indicatorScene(t, c); pulseOf(s, 2) != 0 || c.animating() {
164 t.Fatal("pulse survived fullscreen")
165 }
166}
167
168// A window alone on screen (a maximized column here) needs no pulse; once
169// another window shows beside it, focusing it pulses again.
170func TestFocusPulseSkipsAloneOnScreen(t *testing.T) {
171 c, ic := pulseCore(t)
172 c.cur().mon.Current().FocusID(1)
173 c.cur().mon.Current().ToggleFullWidth()
174 indicatorScene(t, c)
175 ic.now = ic.now.Add(pulseSettle)
176 if c.pulseTick() || c.animating() {
177 t.Fatal("a window alone on screen pulses")
178 }
179 c.cur().mon.Current().ToggleFullWidth()
180 c.cur().mon.Current().FocusID(2)
181 indicatorScene(t, c)
182 ic.now = ic.now.Add(pulseSettle)
183 if !c.pulseTick() || c.pulse.id != 2 {
184 t.Fatal("a window beside another did not pulse")
185 }
186}
187
188// A cascade window alone in its band above a bottom panel is alone on
189// screen: the next band, under the panel, does not count.
190func TestFocusPulseSkipsAloneAbovePanel(t *testing.T) {
191 c, ic := pulseCore(t)
192 m := c.cur().mon
193 m.SetOverflow(OverflowCascade)
194 m.SetMaxColumns(1)
195 m.SetUsable(Rect{W: 300, H: 180})
196 m.Current().FocusID(1)
197 indicatorScene(t, c)
198 ic.now = ic.now.Add(pulseSettle)
199 if c.pulseTick() || c.animating() {
200 t.Fatal("a window alone in its band above a panel pulses")
201 }
202}
203
204// A swipe, its landing slide or a transition still shows the neighbours: the
205// pulse waits for the layout to settle, then decides on it.
206func TestFocusPulseWaitsForSlide(t *testing.T) {
207 for _, tc := range []struct {
208 name string
209 slide func(*testing.T, *Core, time.Time)
210 alone bool
211 }{
212 {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
213 c.cur().mon.switchView.motion = newMotion(workspaceSpring(0.5, 0), now, 1)
214 }, true},
215 {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchView.off = 0.3 }, true},
216 {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().view.off = 40 }, false},
217 {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
218 sc := c.cur()
219 sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
220 sc.rectsWS = sc.mon.Current()
221 }, false},
222 {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
223 m := c.cur().mon
224 for id := WindowID(3); id <= 4; id++ {
225 m.AddWindow(id)
226 }
227 indicatorScene(t, c)
228 before := c.snapshot(now)
229 for range 3 {
230 c.applyAction(ActionFocusColumnLeft)
231 }
232 c.transition(before, now)
233 if !m.Current().view.motion.on || m.settled() {
234 t.Fatal("no camera transition after the focus moved the view")
235 }
236 }, false},
237 } {
238 t.Run(tc.name, func(t *testing.T) {
239 c, ic := pulseCore(t)
240 m := c.cur().mon
241 m.Current().FocusID(1)
242 indicatorScene(t, c)
243 tc.slide(t, c, ic.now)
244 indicatorScene(t, c)
245 ic.now = ic.now.Add(pulseSettle)
246 if c.pulseTick() || c.pulse.timerC == nil {
247 t.Fatal("pulse decided during a slide")
248 }
249 m.stopSwitch()
250 m.Current().view.stop()
251 c.cur().stopRects()
252 if tc.alone {
253 m.Current().ToggleFullWidth()
254 }
255 indicatorScene(t, c)
256 ic.now = ic.now.Add(pulseRecheck)
257 if got := c.pulseTick(); got == tc.alone {
258 t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
259 }
260 })
261 }
262}
263
264// A popup grab or layer taking the keyboard stops the pulse; the window
265// getting it back is not a new focus and does not pulse again.
266func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
267 c, ic := pulseCore(t)
268 c.cur().mon.Current().FocusID(1)
269 indicatorScene(t, c)
270 ic.now = ic.now.Add(pulseSettle)
271 c.pulseTick()
272 // The keyboard goes elsewhere (held false), then comes back.
273 c.pulseFocus(1, false)
274 if c.animating() {
275 t.Fatal("pulse kept while the keyboard is elsewhere")
276 }
277 ic.now = ic.now.Add(2 * pulseCooldown)
278 c.pulseFocus(1, true)
279 if c.pulse.timerC != nil {
280 t.Fatal("returning keyboard counts as a new focus")
281 }
282}
283
284func TestFocusPulseOff(t *testing.T) {
285 c, ic := pulseCore(t)
286 c.cfg.Focus.Animation = ports.FocusAnimationOff
287 c.cur().mon.Current().FocusID(1)
288 indicatorScene(t, c)
289 if c.pulse.timerC != nil {
290 t.Fatal("timer armed with the pulse off")
291 }
292 ic.now = ic.now.Add(pulseSettle)
293 if c.pulseTick() || c.animating() {
294 t.Fatal("pulse with focus.animation off")
295 }
296 // Turning it back on does not pulse the window already focused.
297 c.cfg.Focus.Animation = ports.FocusAnimationPulse
298 indicatorScene(t, c)
299 if c.pulse.timerC != nil {
300 t.Fatal("enabling the pulse pulses the focused window")
301 }
302}
303
304func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
305 c, ic := pulseCore(t)
306 c.cfg.Animations.On = false
307 if c.pulseOn() {
308 t.Fatal("pulse on with animations off")
309 }
310 c.cur().mon.Current().FocusID(1)
311 indicatorScene(t, c)
312 if c.pulse.timerC != nil || c.pulse.id != 0 {
313 t.Fatal("pulse armed with animations off")
314 }
315 ic.now = ic.now.Add(pulseSettle)
316 if c.pulseTick() || c.animating() {
317 t.Fatal("pulse with animations off")
318 }
319}