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 swipe, its landing slide or a transition still shows the neighbours: the
189// pulse waits for the layout to settle, then decides on it.
190func TestFocusPulseWaitsForSlide(t *testing.T) {
191 for _, tc := range []struct {
192 name string
193 slide func(*testing.T, *Core, time.Time)
194 alone bool
195 }{
196 {"workspace landing, alone", func(_ *testing.T, c *Core, now time.Time) {
197 c.cur().mon.switchMotion = newMotion(workspaceSpring(0.5, 0), now, 1)
198 }, true},
199 {"workspace drag, alone", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.switchOff = 0.3 }, true},
200 {"column drag, beside another", func(_ *testing.T, c *Core, _ time.Time) { c.cur().mon.Current().shift = 40 }, false},
201 {"rect motion, beside another", func(_ *testing.T, c *Core, now time.Time) {
202 sc := c.cur()
203 sc.rects = map[WindowID]rectMotion{1: {x: c.spring(viewSpring(40, 0), now), dx: 40}}
204 sc.rectsWS = sc.mon.Current()
205 }, false},
206 {"camera transition of a key, beside another", func(t *testing.T, c *Core, now time.Time) {
207 m := c.cur().mon
208 for id := WindowID(3); id <= 4; id++ {
209 m.AddWindow(id)
210 }
211 indicatorScene(t, c)
212 before := c.snapshot(now)
213 for range 3 {
214 c.applyAction(ActionFocusColumnLeft)
215 }
216 c.transition(before, now)
217 if !m.Current().motion.on || m.settled() {
218 t.Fatal("no camera transition after the focus moved the view")
219 }
220 }, false},
221 } {
222 t.Run(tc.name, func(t *testing.T) {
223 c, ic := pulseCore(t)
224 m := c.cur().mon
225 m.Current().FocusID(1)
226 indicatorScene(t, c)
227 tc.slide(t, c, ic.now)
228 indicatorScene(t, c)
229 ic.now = ic.now.Add(pulseSettle)
230 if c.pulseTick() || c.pulse.timerC == nil {
231 t.Fatal("pulse decided during a slide")
232 }
233 m.stopSwitch()
234 m.Current().stopSlide()
235 c.cur().stopRects()
236 if tc.alone {
237 m.Current().ToggleFullWidth()
238 }
239 indicatorScene(t, c)
240 ic.now = ic.now.Add(pulseRecheck)
241 if got := c.pulseTick(); got == tc.alone {
242 t.Fatalf("pulse started %v after landing, window alone %v", got, tc.alone)
243 }
244 })
245 }
246}
247
248// A popup grab or layer taking the keyboard stops the pulse; the window
249// getting it back is not a new focus and does not pulse again.
250func TestFocusPulseIgnoresKeyboardDetours(t *testing.T) {
251 c, ic := pulseCore(t)
252 c.cur().mon.Current().FocusID(1)
253 indicatorScene(t, c)
254 ic.now = ic.now.Add(pulseSettle)
255 c.pulseTick()
256 // The keyboard goes elsewhere (held false), then comes back.
257 c.pulseFocus(1, false)
258 if c.animating() {
259 t.Fatal("pulse kept while the keyboard is elsewhere")
260 }
261 ic.now = ic.now.Add(2 * pulseCooldown)
262 c.pulseFocus(1, true)
263 if c.pulse.timerC != nil {
264 t.Fatal("returning keyboard counts as a new focus")
265 }
266}
267
268func TestFocusPulseOff(t *testing.T) {
269 c, ic := pulseCore(t)
270 c.cfg.Focus.Animation = ports.FocusAnimationOff
271 c.cur().mon.Current().FocusID(1)
272 indicatorScene(t, c)
273 if c.pulse.timerC != nil {
274 t.Fatal("timer armed with the pulse off")
275 }
276 ic.now = ic.now.Add(pulseSettle)
277 if c.pulseTick() || c.animating() {
278 t.Fatal("pulse with focus.animation off")
279 }
280 // Turning it back on does not pulse the window already focused.
281 c.cfg.Focus.Animation = ports.FocusAnimationPulse
282 indicatorScene(t, c)
283 if c.pulse.timerC != nil {
284 t.Fatal("enabling the pulse pulses the focused window")
285 }
286}
287
288func TestFocusPulseOffWithAnimationsOff(t *testing.T) {
289 c, ic := pulseCore(t)
290 c.cfg.Animations.On = false
291 if c.pulseOn() {
292 t.Fatal("pulse on with animations off")
293 }
294 c.cur().mon.Current().FocusID(1)
295 indicatorScene(t, c)
296 if c.pulse.timerC != nil || c.pulse.id != 0 {
297 t.Fatal("pulse armed with animations off")
298 }
299 ic.now = ic.now.Add(pulseSettle)
300 if c.pulseTick() || c.animating() {
301 t.Fatal("pulse with animations off")
302 }
303}