detached e357010

fix(bar): reset pointer state on surface recreation, axis stop and script restart

commit
e357010b852643435b56c9dc7e7bc0141db4257a
author
brice · · 2026-10-09 04:00 UTC
parent
487baa8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +190 -2
M internal/bar/bar.go +21 -2
@@ -506,12 +506,20 @@ func (b *Bar) createSurface(h int32) error {
506506 }
507507 b.surf, b.sid = surf, surf.ID()
508508 b.mappedW = 0
509- b.input.reset() // the new surface is click-through
509+ b.resetInput()
510510 b.fresh = true
511511 b.configured, b.canPresent = false, false
512512 return nil
513513 }
514514
515+// resetInput forgets the input state of the surface being replaced: the new
516+// surface starts click-through, and the module the pointer was over is told it
517+// left, since no leave will come from a surface that is gone.
518+func (b *Bar) resetInput() {
519+ b.input.reset()
520+ b.ptr.leave()
521+}
522+
515523 // step reparses changed modules and draws when something is ready.
516524 func (b *Bar) step() error {
517525 if b.lay != nil && b.lay.Update() {
@@ -533,6 +541,9 @@ func (b *Bar) draw() error {
533541 b.Stats.NoSlot++ // Released retries
534542 return nil
535543 }
544+ // The spans come from the row just drawn, and the input region follows
545+ // them only when the draw succeeded: between a layout change and the next
546+ // successful draw, the compositor may still route input by the older spans.
536547 b.syncInputRegion()
537548 if err = b.present(); err != nil {
538549 return err
@@ -842,6 +853,7 @@ func (b *Bar) syncInputRegion() {
842853 }
843854 if err := b.surf.SetInputRegion(rects); err != nil {
844855 b.log.Warn("cannot set the input region", "err", err)
856+ b.input.reset() // the compositor kept the old one: the next draw tries again
845857 }
846858 }
847859
@@ -851,7 +863,12 @@ func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
851863 return
852864 }
853865 _, _, scale := b.surf.Size()
854- col := int(math.Floor(ev.X * scale / float64(b.face.CellW)))
866+ b.handlePointer(ev, scale, b.face.CellW)
867+}
868+
869+// handlePointer is Pointer for an event over the bar surface.
870+func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW int) {
871+ col := cellColumn(ev.X, scale, cellW)
855872 switch ev.Kind {
856873 case neferclient.PointerEnter, neferclient.PointerMotion:
857874 b.ptr.motion(b.lay, col)
@@ -867,6 +884,8 @@ func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
867884 delta = ev.DX
868885 }
869886 b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)
887+ case neferclient.PointerAxisStop:
888+ b.ptr.wheel.stop(ev.Axis)
870889 }
871890 }
872891
@@ -506,12 +506,20 @@ func (b *Bar) createSurface(h int32) error {
506 }506 }
507 b.surf, b.sid = surf, surf.ID()507 b.surf, b.sid = surf, surf.ID()
508 b.mappedW = 0508 b.mappedW = 0
509- b.input.reset() // the new surface is click-through509+ b.resetInput()
510 b.fresh = true510 b.fresh = true
511 b.configured, b.canPresent = false, false511 b.configured, b.canPresent = false, false
512 return nil512 return nil
513 }513 }
514 514
515+// resetInput forgets the input state of the surface being replaced: the new
516+// surface starts click-through, and the module the pointer was over is told it
517+// left, since no leave will come from a surface that is gone.
518+func (b *Bar) resetInput() {
519+ b.input.reset()
520+ b.ptr.leave()
521+}
522+
515 // step reparses changed modules and draws when something is ready.523 // step reparses changed modules and draws when something is ready.
516 func (b *Bar) step() error {524 func (b *Bar) step() error {
517 if b.lay != nil && b.lay.Update() {525 if b.lay != nil && b.lay.Update() {
@@ -533,6 +541,9 @@ func (b *Bar) draw() error {
533 b.Stats.NoSlot++ // Released retries541 b.Stats.NoSlot++ // Released retries
534 return nil542 return nil
535 }543 }
544+ // The spans come from the row just drawn, and the input region follows
545+ // them only when the draw succeeded: between a layout change and the next
546+ // successful draw, the compositor may still route input by the older spans.
536 b.syncInputRegion()547 b.syncInputRegion()
537 if err = b.present(); err != nil {548 if err = b.present(); err != nil {
538 return err549 return err
@@ -842,6 +853,7 @@ func (b *Bar) syncInputRegion() {
842 }853 }
843 if err := b.surf.SetInputRegion(rects); err != nil {854 if err := b.surf.SetInputRegion(rects); err != nil {
844 b.log.Warn("cannot set the input region", "err", err)855 b.log.Warn("cannot set the input region", "err", err)
856+ b.input.reset() // the compositor kept the old one: the next draw tries again
845 }857 }
846 }858 }
847 859
@@ -851,7 +863,12 @@ func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
851 return863 return
852 }864 }
853 _, _, scale := b.surf.Size()865 _, _, scale := b.surf.Size()
854- col := int(math.Floor(ev.X * scale / float64(b.face.CellW)))866+ b.handlePointer(ev, scale, b.face.CellW)
867+}
868+
869+// handlePointer is Pointer for an event over the bar surface.
870+func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW int) {
871+ col := cellColumn(ev.X, scale, cellW)
855 switch ev.Kind {872 switch ev.Kind {
856 case neferclient.PointerEnter, neferclient.PointerMotion:873 case neferclient.PointerEnter, neferclient.PointerMotion:
857 b.ptr.motion(b.lay, col)874 b.ptr.motion(b.lay, col)
@@ -867,6 +884,8 @@ func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
867 delta = ev.DX884 delta = ev.DX
868 }885 }
869 b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)886 b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)
887+ case neferclient.PointerAxisStop:
888+ b.ptr.wheel.stop(ev.Axis)
870 }889 }
871 }890 }
872 891
M internal/bar/input.go +27 -0
@@ -34,6 +34,7 @@ type press struct {
3434 type pointerState struct {
3535 hover *module.Module // the interactive module under the pointer
3636 hoverOff int // its cell column
37+ hoverGen uint64 // hover.Gen() when the hover line was sent
3738 token uint32 // last click token
3839 press press
3940 wheel wheelAcc
@@ -52,8 +53,18 @@ func appendInt(b []byte, v int) []byte {
5253 return strconv.AppendInt(b, int64(v), 10)
5354 }
5455
56+// forgetRestarted drops the hover of a module whose script restarted: the new
57+// process was never told about it, so it gets no leave, and the next motion
58+// sends it a hover.
59+func (p *pointerState) forgetRestarted() {
60+ if p.hover != nil && p.hover.Gen() != p.hoverGen {
61+ p.hover, p.hoverOff = nil, 0
62+ }
63+}
64+
5565 // motion handles Enter and Motion at cell column col of the bar row.
5666 func (p *pointerState) motion(lay *layout.Layout, col int) {
67+ p.forgetRestarted()
5768 m, off := interactiveAt(lay, col)
5869 if m == p.hover && (m == nil || off == p.hoverOff) {
5970 return
@@ -63,12 +74,14 @@ func (p *pointerState) motion(lay *layout.Layout, col int) {
6374 }
6475 p.hover, p.hoverOff = m, off
6576 if m != nil {
77+ p.hoverGen = m.Gen()
6678 p.send(m, appendInt(p.line("hover"), off))
6779 }
6880 }
6981
7082 // leave handles the pointer leaving the bar.
7183 func (p *pointerState) leave() {
84+ p.forgetRestarted()
7285 if p.hover != nil {
7386 p.send(p.hover, p.line("leave"))
7487 }
@@ -152,6 +165,20 @@ type wheelAcc struct {
152165
153166 func (a *wheelAcc) reset() { *a = wheelAcc{} }
154167
168+// stop ends a touchpad scroll on axis: the pixels left over do not carry into
169+// the next gesture.
170+func (a *wheelAcc) stop(axis uint32) {
171+ if axis <= 1 {
172+ a.px[axis] = 0
173+ }
174+}
175+
176+// cellColumn is the cell column under logical x on a surface of the given
177+// scale, for cells cellW physical pixels wide.
178+func cellColumn(x, scale float64, cellW int) int {
179+ return int(math.Floor(x * scale / float64(cellW)))
180+}
181+
155182 // add accumulates one event and returns the whole steps it completed:
156183 // positive for down or right, negative for up or left.
157184 func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
@@ -34,6 +34,7 @@ type press struct {
34 type pointerState struct {34 type pointerState struct {
35 hover *module.Module // the interactive module under the pointer35 hover *module.Module // the interactive module under the pointer
36 hoverOff int // its cell column36 hoverOff int // its cell column
37+ hoverGen uint64 // hover.Gen() when the hover line was sent
37 token uint32 // last click token38 token uint32 // last click token
38 press press39 press press
39 wheel wheelAcc40 wheel wheelAcc
@@ -52,8 +53,18 @@ func appendInt(b []byte, v int) []byte {
52 return strconv.AppendInt(b, int64(v), 10)53 return strconv.AppendInt(b, int64(v), 10)
53 }54 }
54 55
56+// forgetRestarted drops the hover of a module whose script restarted: the new
57+// process was never told about it, so it gets no leave, and the next motion
58+// sends it a hover.
59+func (p *pointerState) forgetRestarted() {
60+ if p.hover != nil && p.hover.Gen() != p.hoverGen {
61+ p.hover, p.hoverOff = nil, 0
62+ }
63+}
64+
55 // motion handles Enter and Motion at cell column col of the bar row.65 // motion handles Enter and Motion at cell column col of the bar row.
56 func (p *pointerState) motion(lay *layout.Layout, col int) {66 func (p *pointerState) motion(lay *layout.Layout, col int) {
67+ p.forgetRestarted()
57 m, off := interactiveAt(lay, col)68 m, off := interactiveAt(lay, col)
58 if m == p.hover && (m == nil || off == p.hoverOff) {69 if m == p.hover && (m == nil || off == p.hoverOff) {
59 return70 return
@@ -63,12 +74,14 @@ func (p *pointerState) motion(lay *layout.Layout, col int) {
63 }74 }
64 p.hover, p.hoverOff = m, off75 p.hover, p.hoverOff = m, off
65 if m != nil {76 if m != nil {
77+ p.hoverGen = m.Gen()
66 p.send(m, appendInt(p.line("hover"), off))78 p.send(m, appendInt(p.line("hover"), off))
67 }79 }
68 }80 }
69 81
70 // leave handles the pointer leaving the bar.82 // leave handles the pointer leaving the bar.
71 func (p *pointerState) leave() {83 func (p *pointerState) leave() {
84+ p.forgetRestarted()
72 if p.hover != nil {85 if p.hover != nil {
73 p.send(p.hover, p.line("leave"))86 p.send(p.hover, p.line("leave"))
74 }87 }
@@ -152,6 +165,20 @@ type wheelAcc struct {
152 165
153 func (a *wheelAcc) reset() { *a = wheelAcc{} }166 func (a *wheelAcc) reset() { *a = wheelAcc{} }
154 167
168+// stop ends a touchpad scroll on axis: the pixels left over do not carry into
169+// the next gesture.
170+func (a *wheelAcc) stop(axis uint32) {
171+ if axis <= 1 {
172+ a.px[axis] = 0
173+ }
174+}
175+
176+// cellColumn is the cell column under logical x on a surface of the given
177+// scale, for cells cellW physical pixels wide.
178+func cellColumn(x, scale float64, cellW int) int {
179+ return int(math.Floor(x * scale / float64(cellW)))
180+}
181+
155 // add accumulates one event and returns the whole steps it completed:182 // add accumulates one event and returns the whole steps it completed:
156 // positive for down or right, negative for up or left.183 // positive for down or right, negative for up or left.
157 func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {184 func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
M internal/bar/input_test.go +139 -0
@@ -200,3 +200,142 @@ func TestPointerAllocs(t *testing.T) {
200200 t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201201 }
202202 }
203+
204+func TestRecreatingTheSurfaceResetsPointerState(t *testing.T) {
205+ r := newInputRig(t)
206+ b := &Bar{lay: r.lay}
207+ b.ptr.motion(r.lay, 2)
208+ b.ptr.wheel.add(neferclient.AxisVertical, 120, 0)
209+ b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20})
210+ drain(r.a)
211+
212+ b.resetInput()
213+
214+ if got := drain(r.a); !slices.Equal(got, []string{"leave"}) {
215+ t.Fatalf("lines = %q, want one leave", got)
216+ }
217+ if b.ptr.hover != nil || b.ptr.wheel != (wheelAcc{}) {
218+ t.Fatalf("pointer state kept: hover %v, wheel %+v", b.ptr.hover, b.ptr.wheel)
219+ }
220+ if _, changed := b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20}); !changed {
221+ t.Fatal("the new surface needs its input region again")
222+ }
223+}
224+
225+// A failed SetInputRegion resets the tracking, so the next draw tries again.
226+func TestFailedInputRegionIsRetried(t *testing.T) {
227+ r := newInputRig(t)
228+ var in inputRegion
229+ key := inputKey{cellW: 10, scale: 1, height: 20}
230+ if _, changed := in.update(r.lay, key); !changed {
231+ t.Fatal("first update must change")
232+ }
233+ in.reset() // what syncInputRegion does when the request fails
234+ if _, changed := in.update(r.lay, key); !changed {
235+ t.Fatal("after a failure the same region must be sent again")
236+ }
237+}
238+
239+func TestAxisStopClearsTheTouchpadRemainder(t *testing.T) {
240+ r := newInputRig(t)
241+ b := &Bar{lay: r.lay, sid: 1}
242+ axis := func(kind neferclient.PointerKind, ax uint32, dy float64) {
243+ b.handlePointer(&neferclient.PointerEvent{Surface: 1, Kind: kind, X: 1, Axis: ax, DY: dy}, 1, 10)
244+ }
245+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // 10 of 15 px: no step yet
246+ axis(neferclient.PointerAxisStop, neferclient.AxisVertical, 0)
247+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // would complete a step if 10 were kept
248+ if got := drain(r.a); len(got) != 0 {
249+ t.Fatalf("lines = %q; the remainder crossed a gesture", got)
250+ }
251+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
252+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
253+ t.Fatalf("lines = %q, want one step", got)
254+ }
255+ // Stopping the other axis leaves this one alone.
256+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
257+ axis(neferclient.PointerAxisStop, neferclient.AxisHorizontal, 0)
258+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 5)
259+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
260+ t.Fatalf("lines = %q; the other axis's stop cleared this one", got)
261+ }
262+ var a wheelAcc
263+ a.stop(7) // unknown axis: no panic
264+}
265+
266+func TestRestartedScriptGetsNoLeaveAndAHoverAgain(t *testing.T) {
267+ r := newInputRig(t)
268+ var p pointerState
269+ p.motion(r.lay, 1)
270+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1"}) {
271+ t.Fatalf("lines = %q", got)
272+ }
273+ // The script restarts: the new process knows nothing of the hover.
274+ module.SetGenForTest(r.a, 7)
275+ p.motion(r.lay, 5) // moves off the module: the new process gets no leave
276+ if got := drain(r.a); len(got) != 0 {
277+ t.Fatalf("lines = %q, want no leave for the new process", got)
278+ }
279+ // The same move onto the module again sends it a hover.
280+ p.motion(r.lay, 1)
281+ module.SetGenForTest(r.a, 8)
282+ p.motion(r.lay, 1) // still over it, but the script restarted: hover again
283+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1", "hover 1"}) {
284+ t.Fatalf("lines = %q, want a fresh hover after the restart", got)
285+ }
286+
287+ // leave after a restart sends nothing to the new process.
288+ p.motion(r.lay, 2)
289+ drain(r.a)
290+ module.SetGenForTest(r.a, 9)
291+ p.leave()
292+ if got := drain(r.a); len(got) != 0 {
293+ t.Fatalf("lines = %q, want none", got)
294+ }
295+ if p.hover != nil {
296+ t.Fatal("hover not cleared")
297+ }
298+
299+ // Without a restart nothing changes.
300+ p.motion(r.lay, 2)
301+ p.motion(r.lay, 6)
302+ if got := drain(r.a); !slices.Equal(got, []string{"hover 2", "leave"}) {
303+ t.Fatalf("lines = %q", got)
304+ }
305+ if got := testing.AllocsPerRun(100, func() { p.motion(r.lay, 1); p.motion(r.lay, 6) }); got > 0 && !racecheck.Enabled {
306+ t.Errorf("motion allocates %.1f", got)
307+ }
308+}
309+
310+func TestCellColumnAtFractionalScale(t *testing.T) {
311+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
312+ for _, c := range []struct {
313+ x float64
314+ want int
315+ }{{0, 0}, {8.1, 0}, {8.125, 1}, {16.24, 1}, {16.25, 2}, {40.6, 4}, {40.625, 5}, {-0.1, -1}} {
316+ if got := cellColumn(c.x, 1.6, 13); got != c.want {
317+ t.Errorf("cellColumn(%v) = %d, want %d", c.x, got, c.want)
318+ }
319+ }
320+}
321+
322+func TestPointerFiltersSurfaceAndUsesFractionalScale(t *testing.T) {
323+ r := newInputRig(t) // a: columns 0-3, b: 4-7
324+ b := &Bar{lay: r.lay, sid: 5}
325+ // Another surface (a popup): ignored before the bar's size is even read.
326+ b.Pointer(&neferclient.PointerEvent{Surface: 9, Kind: neferclient.PointerMotion, X: 1})
327+ if got := drain(r.a); len(got) != 0 || b.ptr.hover != nil {
328+ t.Fatalf("a foreign surface moved the hover: %q", got)
329+ }
330+ // Logical x 30 at scale 1.6 and 13 px cells is column 3 (physical px 48).
331+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 30}, 1.6, 13)
332+ // Logical x 33 is column 4: the non-interactive module, so a leave.
333+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 33}, 1.6, 13)
334+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 8.2}, 1.6, 13)
335+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: true, X: 8.2}, 1.6, 13)
336+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: false, X: 8.2}, 1.6, 13)
337+ want := []string{"hover 3", "leave", "hover 1", "click right 1 1"}
338+ if got := drain(r.a); !slices.Equal(got, want) {
339+ t.Fatalf("lines = %q, want %q", got, want)
340+ }
341+}
@@ -200,3 +200,142 @@ func TestPointerAllocs(t *testing.T) {
200 t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)200 t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201 }201 }
202 }202 }
203+
204+func TestRecreatingTheSurfaceResetsPointerState(t *testing.T) {
205+ r := newInputRig(t)
206+ b := &Bar{lay: r.lay}
207+ b.ptr.motion(r.lay, 2)
208+ b.ptr.wheel.add(neferclient.AxisVertical, 120, 0)
209+ b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20})
210+ drain(r.a)
211+
212+ b.resetInput()
213+
214+ if got := drain(r.a); !slices.Equal(got, []string{"leave"}) {
215+ t.Fatalf("lines = %q, want one leave", got)
216+ }
217+ if b.ptr.hover != nil || b.ptr.wheel != (wheelAcc{}) {
218+ t.Fatalf("pointer state kept: hover %v, wheel %+v", b.ptr.hover, b.ptr.wheel)
219+ }
220+ if _, changed := b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20}); !changed {
221+ t.Fatal("the new surface needs its input region again")
222+ }
223+}
224+
225+// A failed SetInputRegion resets the tracking, so the next draw tries again.
226+func TestFailedInputRegionIsRetried(t *testing.T) {
227+ r := newInputRig(t)
228+ var in inputRegion
229+ key := inputKey{cellW: 10, scale: 1, height: 20}
230+ if _, changed := in.update(r.lay, key); !changed {
231+ t.Fatal("first update must change")
232+ }
233+ in.reset() // what syncInputRegion does when the request fails
234+ if _, changed := in.update(r.lay, key); !changed {
235+ t.Fatal("after a failure the same region must be sent again")
236+ }
237+}
238+
239+func TestAxisStopClearsTheTouchpadRemainder(t *testing.T) {
240+ r := newInputRig(t)
241+ b := &Bar{lay: r.lay, sid: 1}
242+ axis := func(kind neferclient.PointerKind, ax uint32, dy float64) {
243+ b.handlePointer(&neferclient.PointerEvent{Surface: 1, Kind: kind, X: 1, Axis: ax, DY: dy}, 1, 10)
244+ }
245+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // 10 of 15 px: no step yet
246+ axis(neferclient.PointerAxisStop, neferclient.AxisVertical, 0)
247+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // would complete a step if 10 were kept
248+ if got := drain(r.a); len(got) != 0 {
249+ t.Fatalf("lines = %q; the remainder crossed a gesture", got)
250+ }
251+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
252+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
253+ t.Fatalf("lines = %q, want one step", got)
254+ }
255+ // Stopping the other axis leaves this one alone.
256+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
257+ axis(neferclient.PointerAxisStop, neferclient.AxisHorizontal, 0)
258+ axis(neferclient.PointerAxis, neferclient.AxisVertical, 5)
259+ if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
260+ t.Fatalf("lines = %q; the other axis's stop cleared this one", got)
261+ }
262+ var a wheelAcc
263+ a.stop(7) // unknown axis: no panic
264+}
265+
266+func TestRestartedScriptGetsNoLeaveAndAHoverAgain(t *testing.T) {
267+ r := newInputRig(t)
268+ var p pointerState
269+ p.motion(r.lay, 1)
270+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1"}) {
271+ t.Fatalf("lines = %q", got)
272+ }
273+ // The script restarts: the new process knows nothing of the hover.
274+ module.SetGenForTest(r.a, 7)
275+ p.motion(r.lay, 5) // moves off the module: the new process gets no leave
276+ if got := drain(r.a); len(got) != 0 {
277+ t.Fatalf("lines = %q, want no leave for the new process", got)
278+ }
279+ // The same move onto the module again sends it a hover.
280+ p.motion(r.lay, 1)
281+ module.SetGenForTest(r.a, 8)
282+ p.motion(r.lay, 1) // still over it, but the script restarted: hover again
283+ if got := drain(r.a); !slices.Equal(got, []string{"hover 1", "hover 1"}) {
284+ t.Fatalf("lines = %q, want a fresh hover after the restart", got)
285+ }
286+
287+ // leave after a restart sends nothing to the new process.
288+ p.motion(r.lay, 2)
289+ drain(r.a)
290+ module.SetGenForTest(r.a, 9)
291+ p.leave()
292+ if got := drain(r.a); len(got) != 0 {
293+ t.Fatalf("lines = %q, want none", got)
294+ }
295+ if p.hover != nil {
296+ t.Fatal("hover not cleared")
297+ }
298+
299+ // Without a restart nothing changes.
300+ p.motion(r.lay, 2)
301+ p.motion(r.lay, 6)
302+ if got := drain(r.a); !slices.Equal(got, []string{"hover 2", "leave"}) {
303+ t.Fatalf("lines = %q", got)
304+ }
305+ if got := testing.AllocsPerRun(100, func() { p.motion(r.lay, 1); p.motion(r.lay, 6) }); got > 0 && !racecheck.Enabled {
306+ t.Errorf("motion allocates %.1f", got)
307+ }
308+}
309+
310+func TestCellColumnAtFractionalScale(t *testing.T) {
311+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
312+ for _, c := range []struct {
313+ x float64
314+ want int
315+ }{{0, 0}, {8.1, 0}, {8.125, 1}, {16.24, 1}, {16.25, 2}, {40.6, 4}, {40.625, 5}, {-0.1, -1}} {
316+ if got := cellColumn(c.x, 1.6, 13); got != c.want {
317+ t.Errorf("cellColumn(%v) = %d, want %d", c.x, got, c.want)
318+ }
319+ }
320+}
321+
322+func TestPointerFiltersSurfaceAndUsesFractionalScale(t *testing.T) {
323+ r := newInputRig(t) // a: columns 0-3, b: 4-7
324+ b := &Bar{lay: r.lay, sid: 5}
325+ // Another surface (a popup): ignored before the bar's size is even read.
326+ b.Pointer(&neferclient.PointerEvent{Surface: 9, Kind: neferclient.PointerMotion, X: 1})
327+ if got := drain(r.a); len(got) != 0 || b.ptr.hover != nil {
328+ t.Fatalf("a foreign surface moved the hover: %q", got)
329+ }
330+ // Logical x 30 at scale 1.6 and 13 px cells is column 3 (physical px 48).
331+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 30}, 1.6, 13)
332+ // Logical x 33 is column 4: the non-interactive module, so a leave.
333+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 33}, 1.6, 13)
334+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 8.2}, 1.6, 13)
335+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: true, X: 8.2}, 1.6, 13)
336+ b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: false, X: 8.2}, 1.6, 13)
337+ want := []string{"hover 3", "leave", "hover 1", "click right 1 1"}
338+ if got := drain(r.a); !slices.Equal(got, want) {
339+ t.Fatalf("lines = %q, want %q", got, want)
340+ }
341+}
M internal/module/module.go +3 -0
@@ -308,6 +308,9 @@ func (w *logWriter) Write(p []byte) (int, error) {
308308 return len(p), nil
309309 }
310310
311+// SetGenForTest makes the module look as if its script was restarted.
312+func SetGenForTest(m *Module, gen uint64) { m.gen.Store(gen) }
313+
311314 // DrainForTest empties the queue of Send lines and returns them without their
312315 // newlines.
313316 func DrainForTest(m *Module) []string {
@@ -308,6 +308,9 @@ func (w *logWriter) Write(p []byte) (int, error) {
308 return len(p), nil308 return len(p), nil
309 }309 }
310 310
311+// SetGenForTest makes the module look as if its script was restarted.
312+func SetGenForTest(m *Module, gen uint64) { m.gen.Store(gen) }
313+
311 // DrainForTest empties the queue of Send lines and returns them without their314 // DrainForTest empties the queue of Send lines and returns them without their
312 // newlines.315 // newlines.
313 func DrainForTest(m *Module) []string {316 func DrainForTest(m *Module) []string {

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 5

Tags 4

↑↓ moveenter openesc close