detached cdd88db

feat(bar): send pointer events to interactive modules and limit input to their cells

commit
cdd88dbbc74baf751e00363f2c3492de68bc8e06
author
brice · · 2026-10-09 03:36 UTC
parent
6692ae1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +496 -4
M internal/bar/bar.go +51 -4
@@ -115,9 +115,13 @@ type Bar struct {
115115 // mappedW is the logical width the current layer surface was first drawn at.
116116 // It resets to 0 when the surface is recreated.
117117 mappedW int32
118- Stats Stats
119- fatal error
120- closed bool
118+
119+ ptr pointerState // pointer events turned into module lines
120+ input inputRegion
121+
122+ Stats Stats
123+ fatal error
124+ closed bool
121125 }
122126
123127 // New builds a bar from a validated config. cfgPath is the file the config came
@@ -311,6 +315,7 @@ func (b *Bar) syncModules(want []config.Module) {
311315 ctx, cancel := context.WithCancel(b.runCtx)
312316 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
313317 m.Env = b.env
318+ m.Interactive = w.Interactive
314319 m.Start(ctx)
315320 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
316321 }
@@ -324,6 +329,9 @@ func (b *Bar) syncModules(want []config.Module) {
324329 for _, r := range next {
325330 b.mods = append(b.mods, r.m)
326331 }
332+ if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
333+ b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
334+ }
327335 if b.lay != nil {
328336 b.lay.SetModules(b.mods)
329337 }
@@ -498,6 +506,7 @@ func (b *Bar) createSurface(h int32) error {
498506 }
499507 b.surf, b.sid = surf, surf.ID()
500508 b.mappedW = 0
509+ b.input.reset() // the new surface is click-through
501510 b.fresh = true
502511 b.configured, b.canPresent = false, false
503512 return nil
@@ -524,6 +533,7 @@ func (b *Bar) draw() error {
524533 b.Stats.NoSlot++ // Released retries
525534 return nil
526535 }
536+ b.syncInputRegion()
527537 if err = b.present(); err != nil {
528538 return err
529539 }
@@ -822,6 +832,44 @@ func (b *Bar) FDReady(id uint64) {
822832 }
823833 }
824834
835+// syncInputRegion makes the part of the bar that takes pointer input the cells
836+// of the interactive modules; the rest stays click-through.
837+func (b *Bar) syncInputRegion() {
838+ _, h, scale := b.surf.Size()
839+ rects, changed := b.input.update(b.lay, inputKey{cellW: b.face.CellW, scale: scale, height: h})
840+ if !changed {
841+ return
842+ }
843+ if err := b.surf.SetInputRegion(rects); err != nil {
844+ b.log.Warn("cannot set the input region", "err", err)
845+ }
846+}
847+
848+// Pointer turns pointer events over the bar into lines for interactive modules.
849+func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
850+ if ev.Surface != b.sid || b.lay == nil || b.face == nil {
851+ return
852+ }
853+ _, _, scale := b.surf.Size()
854+ col := int(math.Floor(ev.X * scale / float64(b.face.CellW)))
855+ switch ev.Kind {
856+ case neferclient.PointerEnter, neferclient.PointerMotion:
857+ b.ptr.motion(b.lay, col)
858+ case neferclient.PointerLeave:
859+ b.ptr.leave()
860+ case neferclient.PointerButton:
861+ if ev.Pressed {
862+ b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial
863+ }
864+ case neferclient.PointerAxis:
865+ delta := ev.DY
866+ if ev.Axis == neferclient.AxisHorizontal {
867+ delta = ev.DX
868+ }
869+ b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)
870+ }
871+}
872+
825873 // Error logs non-fatal connection errors.
826874 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
827875
@@ -830,7 +878,6 @@ func (b *Bar) OutputAdded(*neferclient.Output) {}
830878 func (b *Bar) OutputRemoved(uint32) {}
831879 func (b *Bar) Locked() {}
832880 func (b *Bar) LockFinished() {}
833-func (b *Bar) Pointer(*neferclient.PointerEvent) {}
834881 func (b *Bar) Key(*neferclient.KeyEvent) {}
835882 func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
836883 func (b *Bar) SecretChanged(int) {}
@@ -115,9 +115,13 @@ type Bar struct {
115 // mappedW is the logical width the current layer surface was first drawn at.115 // mappedW is the logical width the current layer surface was first drawn at.
116 // It resets to 0 when the surface is recreated.116 // It resets to 0 when the surface is recreated.
117 mappedW int32117 mappedW int32
118- Stats Stats118+
119- fatal error119+ ptr pointerState // pointer events turned into module lines
120- closed bool120+ input inputRegion
121+
122+ Stats Stats
123+ fatal error
124+ closed bool
121 }125 }
122 126
123 // New builds a bar from a validated config. cfgPath is the file the config came127 // New builds a bar from a validated config. cfgPath is the file the config came
@@ -311,6 +315,7 @@ func (b *Bar) syncModules(want []config.Module) {
311 ctx, cancel := context.WithCancel(b.runCtx)315 ctx, cancel := context.WithCancel(b.runCtx)
312 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)316 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
313 m.Env = b.env317 m.Env = b.env
318+ m.Interactive = w.Interactive
314 m.Start(ctx)319 m.Start(ctx)
315 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})320 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
316 }321 }
@@ -324,6 +329,9 @@ func (b *Bar) syncModules(want []config.Module) {
324 for _, r := range next {329 for _, r := range next {
325 b.mods = append(b.mods, r.m)330 b.mods = append(b.mods, r.m)
326 }331 }
332+ if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
333+ b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
334+ }
327 if b.lay != nil {335 if b.lay != nil {
328 b.lay.SetModules(b.mods)336 b.lay.SetModules(b.mods)
329 }337 }
@@ -498,6 +506,7 @@ func (b *Bar) createSurface(h int32) error {
498 }506 }
499 b.surf, b.sid = surf, surf.ID()507 b.surf, b.sid = surf, surf.ID()
500 b.mappedW = 0508 b.mappedW = 0
509+ b.input.reset() // the new surface is click-through
501 b.fresh = true510 b.fresh = true
502 b.configured, b.canPresent = false, false511 b.configured, b.canPresent = false, false
503 return nil512 return nil
@@ -524,6 +533,7 @@ func (b *Bar) draw() error {
524 b.Stats.NoSlot++ // Released retries533 b.Stats.NoSlot++ // Released retries
525 return nil534 return nil
526 }535 }
536+ b.syncInputRegion()
527 if err = b.present(); err != nil {537 if err = b.present(); err != nil {
528 return err538 return err
529 }539 }
@@ -822,6 +832,44 @@ func (b *Bar) FDReady(id uint64) {
822 }832 }
823 }833 }
824 834
835+// syncInputRegion makes the part of the bar that takes pointer input the cells
836+// of the interactive modules; the rest stays click-through.
837+func (b *Bar) syncInputRegion() {
838+ _, h, scale := b.surf.Size()
839+ rects, changed := b.input.update(b.lay, inputKey{cellW: b.face.CellW, scale: scale, height: h})
840+ if !changed {
841+ return
842+ }
843+ if err := b.surf.SetInputRegion(rects); err != nil {
844+ b.log.Warn("cannot set the input region", "err", err)
845+ }
846+}
847+
848+// Pointer turns pointer events over the bar into lines for interactive modules.
849+func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
850+ if ev.Surface != b.sid || b.lay == nil || b.face == nil {
851+ return
852+ }
853+ _, _, scale := b.surf.Size()
854+ col := int(math.Floor(ev.X * scale / float64(b.face.CellW)))
855+ switch ev.Kind {
856+ case neferclient.PointerEnter, neferclient.PointerMotion:
857+ b.ptr.motion(b.lay, col)
858+ case neferclient.PointerLeave:
859+ b.ptr.leave()
860+ case neferclient.PointerButton:
861+ if ev.Pressed {
862+ b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial
863+ }
864+ case neferclient.PointerAxis:
865+ delta := ev.DY
866+ if ev.Axis == neferclient.AxisHorizontal {
867+ delta = ev.DX
868+ }
869+ b.ptr.axis(b.lay, col, ev.Axis, ev.Value120, delta)
870+ }
871+}
872+
825 // Error logs non-fatal connection errors.873 // Error logs non-fatal connection errors.
826 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }874 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
827 875
@@ -830,7 +878,6 @@ func (b *Bar) OutputAdded(*neferclient.Output) {}
830 func (b *Bar) OutputRemoved(uint32) {}878 func (b *Bar) OutputRemoved(uint32) {}
831 func (b *Bar) Locked() {}879 func (b *Bar) Locked() {}
832 func (b *Bar) LockFinished() {}880 func (b *Bar) LockFinished() {}
833-func (b *Bar) Pointer(*neferclient.PointerEvent) {}
834 func (b *Bar) Key(*neferclient.KeyEvent) {}881 func (b *Bar) Key(*neferclient.KeyEvent) {}
835 func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}882 func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
836 func (b *Bar) SecretChanged(int) {}883 func (b *Bar) SecretChanged(int) {}
A internal/bar/input.go +229 -0
@@ -0,0 +1,229 @@
1+package bar
2+
3+import (
4+ "math"
5+ "slices"
6+ "strconv"
7+ "time"
8+
9+ "github.com/bnema/neferclient"
10+
11+ "github.com/bnema/neferbar/internal/layout"
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+// Evdev button codes.
16+const (
17+ btnLeft = 0x110
18+ btnRight = 0x111
19+ btnMiddle = 0x112
20+)
21+
22+// pxPerStep is how far a continuous scroll (touchpad) moves for one step.
23+const pxPerStep = 15
24+
25+// press is the latest button press sent to a module.
26+type press struct {
27+ token uint32 // the number the module echoes to refer to this press
28+ serial uint32 // the pointer serial of the press, for popup grabs
29+ at time.Time
30+}
31+
32+// pointerState turns pointer events over the bar into the lines of the module
33+// protocol. It owns no Wayland object: the bar loop feeds it columns.
34+type pointerState struct {
35+ hover *module.Module // the interactive module under the pointer
36+ hoverOff int // its cell column
37+ token uint32 // last click token
38+ press press
39+ wheel wheelAcc
40+ buf [64]byte // the line being built
41+}
42+
43+// send hands a line built in p.buf to m. A full queue drops the line: the
44+// script is not reading and pointer events are not worth waiting for.
45+func (p *pointerState) send(m *module.Module, line []byte) { m.Send(line) }
46+
47+// line starts a line in p.buf.
48+func (p *pointerState) line(verb string) []byte { return append(p.buf[:0], verb...) }
49+
50+func appendInt(b []byte, v int) []byte {
51+ b = append(b, ' ')
52+ return strconv.AppendInt(b, int64(v), 10)
53+}
54+
55+// motion handles Enter and Motion at cell column col of the bar row.
56+func (p *pointerState) motion(lay *layout.Layout, col int) {
57+ m, off := interactiveAt(lay, col)
58+ if m == p.hover && (m == nil || off == p.hoverOff) {
59+ return
60+ }
61+ if p.hover != nil && m != p.hover {
62+ p.send(p.hover, p.line("leave"))
63+ }
64+ p.hover, p.hoverOff = m, off
65+ if m != nil {
66+ p.send(m, appendInt(p.line("hover"), off))
67+ }
68+}
69+
70+// leave handles the pointer leaving the bar.
71+func (p *pointerState) leave() {
72+ if p.hover != nil {
73+ p.send(p.hover, p.line("leave"))
74+ }
75+ p.hover, p.hoverOff = nil, 0
76+ p.wheel.reset()
77+}
78+
79+// button handles a button press at column col. serial is the press serial.
80+func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial uint32) {
81+ var name string
82+ switch code {
83+ case btnLeft:
84+ name = "left"
85+ case btnMiddle:
86+ name = "middle"
87+ case btnRight:
88+ name = "right"
89+ default:
90+ return
91+ }
92+ m, off := interactiveAt(lay, col)
93+ if m == nil {
94+ return
95+ }
96+ p.token++
97+ p.press = press{token: p.token, serial: serial, at: time.Now()}
98+ b := append(p.line("click "), name...)
99+ b = appendInt(b, off)
100+ b = append(b, ' ')
101+ b = strconv.AppendUint(b, uint64(p.token), 10)
102+ p.send(m, b)
103+}
104+
105+// axis handles one scroll event at column col.
106+func (p *pointerState) axis(lay *layout.Layout, col int, axis uint32, value120 int32, delta float64) {
107+ steps := p.wheel.add(axis, value120, delta)
108+ if steps == 0 {
109+ return
110+ }
111+ m, off := interactiveAt(lay, col)
112+ if m == nil {
113+ return
114+ }
115+ var dir string
116+ switch {
117+ case axis == neferclient.AxisVertical && steps > 0:
118+ dir = "down"
119+ case axis == neferclient.AxisVertical:
120+ dir = "up"
121+ case steps > 0:
122+ dir = "right"
123+ default:
124+ dir = "left"
125+ }
126+ if steps < 0 {
127+ steps = -steps
128+ }
129+ b := append(p.line("scroll "), dir...)
130+ b = appendInt(b, int(steps))
131+ b = appendInt(b, off)
132+ p.send(m, b)
133+}
134+
135+// interactiveAt is the interactive module at column col, and the column inside it.
136+func interactiveAt(lay *layout.Layout, col int) (*module.Module, int) {
137+ m, off, ok := lay.At(col)
138+ if !ok || !m.Interactive {
139+ return nil, 0
140+ }
141+ return m, off
142+}
143+
144+// wheelAcc counts scroll steps. A wheel reports notches in 1/120 units; a
145+// touchpad reports pixels. A compositor may send both for a wheel, so once a
146+// 1/120 value was seen the pixels are ignored until the pointer leaves.
147+type wheelAcc struct {
148+ wheel bool
149+ v120 [2]int32
150+ px [2]float64
151+}
152+
153+func (a *wheelAcc) reset() { *a = wheelAcc{} }
154+
155+// add accumulates one event and returns the whole steps it completed:
156+// positive for down or right, negative for up or left.
157+func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
158+ if axis > 1 {
159+ return 0
160+ }
161+ if value120 != 0 {
162+ a.wheel = true
163+ a.v120[axis] += value120
164+ steps := a.v120[axis] / 120
165+ a.v120[axis] -= steps * 120
166+ return steps
167+ }
168+ if a.wheel {
169+ return 0
170+ }
171+ a.px[axis] += delta
172+ steps := int32(a.px[axis] / pxPerStep)
173+ a.px[axis] -= float64(steps) * pxPerStep
174+ return steps
175+}
176+
177+// inputRect is the logical-pixel rectangle of a span of cells: rounded
178+// outward, full surface height.
179+func inputRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
180+ const eps = 1e-9
181+ x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
182+ x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
183+ return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
184+}
185+
186+// inputKey is everything but the spans that the input rectangles depend on.
187+type inputKey struct {
188+ cellW int
189+ scale float64
190+ height int32
191+}
192+
193+// inputRegion tracks the spans the compositor was last told about.
194+type inputRegion struct {
195+ spans, last []layout.Span
196+ rects []neferclient.Rect
197+ key inputKey
198+}
199+
200+// reset forgets what the compositor knows: a new surface starts click-through.
201+func (r *inputRegion) reset() {
202+ r.last = r.last[:0]
203+ r.key = inputKey{}
204+}
205+
206+// update collects the spans of the interactive modules and reports whether the
207+// input rectangles differ from the last ones; if so rects holds the new ones
208+// (non-nil, possibly empty). It allocates only while the slices grow.
209+func (r *inputRegion) update(lay *layout.Layout, key inputKey) (rects []neferclient.Rect, changed bool) {
210+ r.spans = lay.Spans(r.spans[:0])
211+ r.spans = slices.DeleteFunc(r.spans, func(s layout.Span) bool { return !s.M.Interactive })
212+ if key == r.key && slices.Equal(r.spans, r.last) {
213+ return nil, false
214+ }
215+ if len(r.spans) == 0 && len(r.last) == 0 {
216+ r.key = key
217+ return nil, false
218+ }
219+ r.rects = r.rects[:0]
220+ for _, s := range r.spans {
221+ r.rects = append(r.rects, inputRect(s.Start, s.Width, key.cellW, key.scale, key.height))
222+ }
223+ if r.rects == nil {
224+ r.rects = []neferclient.Rect{}
225+ }
226+ r.last = append(r.last[:0], r.spans...)
227+ r.key = key
228+ return r.rects, true
229+}
@@ -0,0 +1,229 @@
1+package bar
2+
3+import (
4+ "math"
5+ "slices"
6+ "strconv"
7+ "time"
8+
9+ "github.com/bnema/neferclient"
10+
11+ "github.com/bnema/neferbar/internal/layout"
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+// Evdev button codes.
16+const (
17+ btnLeft = 0x110
18+ btnRight = 0x111
19+ btnMiddle = 0x112
20+)
21+
22+// pxPerStep is how far a continuous scroll (touchpad) moves for one step.
23+const pxPerStep = 15
24+
25+// press is the latest button press sent to a module.
26+type press struct {
27+ token uint32 // the number the module echoes to refer to this press
28+ serial uint32 // the pointer serial of the press, for popup grabs
29+ at time.Time
30+}
31+
32+// pointerState turns pointer events over the bar into the lines of the module
33+// protocol. It owns no Wayland object: the bar loop feeds it columns.
34+type pointerState struct {
35+ hover *module.Module // the interactive module under the pointer
36+ hoverOff int // its cell column
37+ token uint32 // last click token
38+ press press
39+ wheel wheelAcc
40+ buf [64]byte // the line being built
41+}
42+
43+// send hands a line built in p.buf to m. A full queue drops the line: the
44+// script is not reading and pointer events are not worth waiting for.
45+func (p *pointerState) send(m *module.Module, line []byte) { m.Send(line) }
46+
47+// line starts a line in p.buf.
48+func (p *pointerState) line(verb string) []byte { return append(p.buf[:0], verb...) }
49+
50+func appendInt(b []byte, v int) []byte {
51+ b = append(b, ' ')
52+ return strconv.AppendInt(b, int64(v), 10)
53+}
54+
55+// motion handles Enter and Motion at cell column col of the bar row.
56+func (p *pointerState) motion(lay *layout.Layout, col int) {
57+ m, off := interactiveAt(lay, col)
58+ if m == p.hover && (m == nil || off == p.hoverOff) {
59+ return
60+ }
61+ if p.hover != nil && m != p.hover {
62+ p.send(p.hover, p.line("leave"))
63+ }
64+ p.hover, p.hoverOff = m, off
65+ if m != nil {
66+ p.send(m, appendInt(p.line("hover"), off))
67+ }
68+}
69+
70+// leave handles the pointer leaving the bar.
71+func (p *pointerState) leave() {
72+ if p.hover != nil {
73+ p.send(p.hover, p.line("leave"))
74+ }
75+ p.hover, p.hoverOff = nil, 0
76+ p.wheel.reset()
77+}
78+
79+// button handles a button press at column col. serial is the press serial.
80+func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial uint32) {
81+ var name string
82+ switch code {
83+ case btnLeft:
84+ name = "left"
85+ case btnMiddle:
86+ name = "middle"
87+ case btnRight:
88+ name = "right"
89+ default:
90+ return
91+ }
92+ m, off := interactiveAt(lay, col)
93+ if m == nil {
94+ return
95+ }
96+ p.token++
97+ p.press = press{token: p.token, serial: serial, at: time.Now()}
98+ b := append(p.line("click "), name...)
99+ b = appendInt(b, off)
100+ b = append(b, ' ')
101+ b = strconv.AppendUint(b, uint64(p.token), 10)
102+ p.send(m, b)
103+}
104+
105+// axis handles one scroll event at column col.
106+func (p *pointerState) axis(lay *layout.Layout, col int, axis uint32, value120 int32, delta float64) {
107+ steps := p.wheel.add(axis, value120, delta)
108+ if steps == 0 {
109+ return
110+ }
111+ m, off := interactiveAt(lay, col)
112+ if m == nil {
113+ return
114+ }
115+ var dir string
116+ switch {
117+ case axis == neferclient.AxisVertical && steps > 0:
118+ dir = "down"
119+ case axis == neferclient.AxisVertical:
120+ dir = "up"
121+ case steps > 0:
122+ dir = "right"
123+ default:
124+ dir = "left"
125+ }
126+ if steps < 0 {
127+ steps = -steps
128+ }
129+ b := append(p.line("scroll "), dir...)
130+ b = appendInt(b, int(steps))
131+ b = appendInt(b, off)
132+ p.send(m, b)
133+}
134+
135+// interactiveAt is the interactive module at column col, and the column inside it.
136+func interactiveAt(lay *layout.Layout, col int) (*module.Module, int) {
137+ m, off, ok := lay.At(col)
138+ if !ok || !m.Interactive {
139+ return nil, 0
140+ }
141+ return m, off
142+}
143+
144+// wheelAcc counts scroll steps. A wheel reports notches in 1/120 units; a
145+// touchpad reports pixels. A compositor may send both for a wheel, so once a
146+// 1/120 value was seen the pixels are ignored until the pointer leaves.
147+type wheelAcc struct {
148+ wheel bool
149+ v120 [2]int32
150+ px [2]float64
151+}
152+
153+func (a *wheelAcc) reset() { *a = wheelAcc{} }
154+
155+// add accumulates one event and returns the whole steps it completed:
156+// positive for down or right, negative for up or left.
157+func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
158+ if axis > 1 {
159+ return 0
160+ }
161+ if value120 != 0 {
162+ a.wheel = true
163+ a.v120[axis] += value120
164+ steps := a.v120[axis] / 120
165+ a.v120[axis] -= steps * 120
166+ return steps
167+ }
168+ if a.wheel {
169+ return 0
170+ }
171+ a.px[axis] += delta
172+ steps := int32(a.px[axis] / pxPerStep)
173+ a.px[axis] -= float64(steps) * pxPerStep
174+ return steps
175+}
176+
177+// inputRect is the logical-pixel rectangle of a span of cells: rounded
178+// outward, full surface height.
179+func inputRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
180+ const eps = 1e-9
181+ x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
182+ x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
183+ return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
184+}
185+
186+// inputKey is everything but the spans that the input rectangles depend on.
187+type inputKey struct {
188+ cellW int
189+ scale float64
190+ height int32
191+}
192+
193+// inputRegion tracks the spans the compositor was last told about.
194+type inputRegion struct {
195+ spans, last []layout.Span
196+ rects []neferclient.Rect
197+ key inputKey
198+}
199+
200+// reset forgets what the compositor knows: a new surface starts click-through.
201+func (r *inputRegion) reset() {
202+ r.last = r.last[:0]
203+ r.key = inputKey{}
204+}
205+
206+// update collects the spans of the interactive modules and reports whether the
207+// input rectangles differ from the last ones; if so rects holds the new ones
208+// (non-nil, possibly empty). It allocates only while the slices grow.
209+func (r *inputRegion) update(lay *layout.Layout, key inputKey) (rects []neferclient.Rect, changed bool) {
210+ r.spans = lay.Spans(r.spans[:0])
211+ r.spans = slices.DeleteFunc(r.spans, func(s layout.Span) bool { return !s.M.Interactive })
212+ if key == r.key && slices.Equal(r.spans, r.last) {
213+ return nil, false
214+ }
215+ if len(r.spans) == 0 && len(r.last) == 0 {
216+ r.key = key
217+ return nil, false
218+ }
219+ r.rects = r.rects[:0]
220+ for _, s := range r.spans {
221+ r.rects = append(r.rects, inputRect(s.Start, s.Width, key.cellW, key.scale, key.height))
222+ }
223+ if r.rects == nil {
224+ r.rects = []neferclient.Rect{}
225+ }
226+ r.last = append(r.last[:0], r.spans...)
227+ r.key = key
228+ return r.rects, true
229+}
A internal/bar/input_test.go +202 -0
@@ -0,0 +1,202 @@
1+package bar
2+
3+import (
4+ "log/slog"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferclient"
9+
10+ "github.com/bnema/neferbar/internal/layout"
11+ "github.com/bnema/neferbar/internal/module"
12+ "github.com/bnema/neferbar/internal/racecheck"
13+)
14+
15+// inputRig is a 30-column layout: "a" (interactive) at columns 0-3, "b" (not)
16+// at 4-7, both in the left zone.
17+type inputRig struct {
18+ lay *layout.Layout
19+ a, b *module.Module
20+}
21+
22+func newInputRig(t testing.TB) *inputRig {
23+ t.Helper()
24+ wake := make(chan struct{}, 1)
25+ log := slog.New(slog.DiscardHandler)
26+ a := module.New("a", module.Left, "true", wake, log)
27+ b := module.New("b", module.Left, "true", wake, log)
28+ a.Interactive = true
29+ lay := layout.New(30, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, [16][3]uint8{}, []*module.Module{a, b})
30+ module.PublishForTest(a, []byte("AAAA"))
31+ module.PublishForTest(b, []byte("BBBB"))
32+ lay.Update()
33+ lay.Compose()
34+ return &inputRig{lay: lay, a: a, b: b}
35+}
36+
37+// drain returns the lines queued on m and empties its queue.
38+func drain(m *module.Module) []string {
39+ return module.DrainForTest(m)
40+}
41+
42+func TestHoverAndLeave(t *testing.T) {
43+ r := newInputRig(t)
44+ var p pointerState
45+ p.motion(r.lay, 2)
46+ p.motion(r.lay, 2) // same column: nothing
47+ p.motion(r.lay, 3)
48+ p.motion(r.lay, 5) // over the non-interactive module: leaves a
49+ p.motion(r.lay, 6) // still nothing
50+ p.motion(r.lay, 1)
51+ p.leave()
52+ p.leave()
53+ want := []string{"hover 2", "hover 3", "leave", "hover 1", "leave"}
54+ if got := drain(r.a); !slices.Equal(got, want) {
55+ t.Fatalf("lines = %q, want %q", got, want)
56+ }
57+ if got := drain(r.b); len(got) != 0 {
58+ t.Fatalf("a non-interactive module got %q", got)
59+ }
60+}
61+
62+func TestClickTokensAndButtons(t *testing.T) {
63+ r := newInputRig(t)
64+ var p pointerState
65+ p.button(r.lay, 1, btnLeft, 7)
66+ p.button(r.lay, 2, btnMiddle, 8)
67+ p.button(r.lay, 3, btnRight, 9)
68+ p.button(r.lay, 3, 0x113, 10) // another button: ignored
69+ p.button(r.lay, 5, btnLeft, 11)
70+ p.button(r.lay, 20, btnLeft, 12) // empty bar space
71+ want := []string{"click left 1 1", "click middle 2 2", "click right 3 3"}
72+ if got := drain(r.a); !slices.Equal(got, want) {
73+ t.Fatalf("lines = %q, want %q", got, want)
74+ }
75+ if p.press.token != 3 || p.press.serial != 9 || p.press.at.IsZero() {
76+ t.Fatalf("press = %+v", p.press)
77+ }
78+}
79+
80+func TestScrollLines(t *testing.T) {
81+ r := newInputRig(t)
82+ var p pointerState
83+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
84+ p.axis(r.lay, 1, neferclient.AxisVertical, -240, 0)
85+ p.axis(r.lay, 2, neferclient.AxisHorizontal, 120, 0)
86+ p.axis(r.lay, 2, neferclient.AxisHorizontal, -120, 0)
87+ p.axis(r.lay, 2, neferclient.AxisVertical, 0, 100) // continuous after a wheel: ignored
88+ p.axis(r.lay, 6, neferclient.AxisVertical, 120, 0) // not interactive
89+ want := []string{"scroll down 1 1", "scroll up 2 1", "scroll right 1 2", "scroll left 1 2"}
90+ if got := drain(r.a); !slices.Equal(got, want) {
91+ t.Fatalf("lines = %q, want %q", got, want)
92+ }
93+}
94+
95+func TestWheelAccumulator(t *testing.T) {
96+ var a wheelAcc
97+ for _, c := range []struct {
98+ axis uint32
99+ v120 int32
100+ d float64
101+ want int32
102+ }{
103+ {neferclient.AxisVertical, 120, 0, 1},
104+ {neferclient.AxisVertical, 240, 0, 2},
105+ {neferclient.AxisVertical, -120, 0, -1},
106+ {neferclient.AxisVertical, 60, 0, 0},
107+ {neferclient.AxisVertical, 60, 0, 1}, // two half notches
108+ {neferclient.AxisHorizontal, 120, 0, 1},
109+ {neferclient.AxisVertical, 0, 50, 0}, // continuous after a wheel is ignored
110+ } {
111+ if got := a.add(c.axis, c.v120, c.d); got != c.want {
112+ t.Errorf("add(%d, %d, %v) = %d, want %d", c.axis, c.v120, c.d, got, c.want)
113+ }
114+ }
115+ a.reset()
116+ for _, c := range []struct {
117+ d float64
118+ want int32
119+ }{{15, 1}, {10, 0}, {5, 1}, {-30, -2}, {7, 0}} {
120+ if got := a.add(neferclient.AxisVertical, 0, c.d); got != c.want {
121+ t.Errorf("continuous %v = %d, want %d", c.d, got, c.want)
122+ }
123+ }
124+ if a.add(2, 120, 0) != 0 {
125+ t.Error("an unknown axis must be ignored")
126+ }
127+}
128+
129+func TestInputRect(t *testing.T) {
130+ // Scale 1.6, 13 px cells: columns 2..4 are physical px 26..65, logical
131+ // 16.25..40.625, rounded outward.
132+ got := inputRect(2, 3, 13, 1.6, 24)
133+ want := neferclient.Rect{X: 16, Y: 0, Width: 25, Height: 24}
134+ if got != want {
135+ t.Fatalf("rect = %+v, want %+v", got, want)
136+ }
137+ if got = inputRect(0, 4, 10, 1, 20); got != (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 20}) {
138+ t.Fatalf("exact rect = %+v", got)
139+ }
140+ if got = inputRect(3, 2, 10, 2, 20); got != (neferclient.Rect{X: 15, Y: 0, Width: 10, Height: 20}) {
141+ t.Fatalf("scale 2 rect = %+v", got)
142+ }
143+}
144+
145+func TestInputRegionUpdates(t *testing.T) {
146+ r := newInputRig(t)
147+ var in inputRegion
148+ key := inputKey{cellW: 10, scale: 1, height: 20}
149+ rects, changed := in.update(r.lay, key)
150+ want := []neferclient.Rect{{X: 0, Y: 0, Width: 40, Height: 20}}
151+ if !changed || !slices.Equal(rects, want) {
152+ t.Fatalf("first update = %+v, %v; want %+v", rects, changed, want)
153+ }
154+ if _, changed = in.update(r.lay, key); changed {
155+ t.Fatal("same spans must not change the region")
156+ }
157+ if _, changed = in.update(r.lay, inputKey{cellW: 10, scale: 2, height: 20}); !changed {
158+ t.Fatal("a scale change must change the region")
159+ }
160+ in.reset()
161+ if _, changed = in.update(r.lay, key); !changed {
162+ t.Fatal("a new surface needs the region again")
163+ }
164+ // No interactive module left: back to an empty, non-nil region.
165+ r.a.Interactive = false
166+ rects, changed = in.update(r.lay, key)
167+ if !changed || rects == nil || len(rects) != 0 {
168+ t.Fatalf("empty update = %#v, %v; want non-nil empty", rects, changed)
169+ }
170+ if _, changed = in.update(r.lay, key); changed {
171+ t.Fatal("staying empty must not change the region")
172+ }
173+}
174+
175+func TestPointerAllocs(t *testing.T) {
176+ if racecheck.Enabled {
177+ t.Skip("the race detector allocates")
178+ }
179+ r := newInputRig(t)
180+ var p pointerState
181+ cols := [...]int{1, 2, 3, 5, 9}
182+ i := 0
183+ if got := testing.AllocsPerRun(200, func() {
184+ p.motion(r.lay, cols[i%len(cols)])
185+ i++
186+ }); got > 0 {
187+ t.Errorf("motion allocates %.1f objects; want 0", got)
188+ }
189+ if got := testing.AllocsPerRun(200, func() {
190+ p.button(r.lay, 1, btnLeft, 1)
191+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
192+ p.leave()
193+ }); got > 0 {
194+ t.Errorf("click/scroll/leave allocate %.1f objects; want 0", got)
195+ }
196+ var in inputRegion
197+ key := inputKey{cellW: 10, scale: 1, height: 20}
198+ in.update(r.lay, key)
199+ if got := testing.AllocsPerRun(200, func() { in.update(r.lay, key) }); got > 0 {
200+ t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201+ }
202+}
@@ -0,0 +1,202 @@
1+package bar
2+
3+import (
4+ "log/slog"
5+ "slices"
6+ "testing"
7+
8+ "github.com/bnema/neferclient"
9+
10+ "github.com/bnema/neferbar/internal/layout"
11+ "github.com/bnema/neferbar/internal/module"
12+ "github.com/bnema/neferbar/internal/racecheck"
13+)
14+
15+// inputRig is a 30-column layout: "a" (interactive) at columns 0-3, "b" (not)
16+// at 4-7, both in the left zone.
17+type inputRig struct {
18+ lay *layout.Layout
19+ a, b *module.Module
20+}
21+
22+func newInputRig(t testing.TB) *inputRig {
23+ t.Helper()
24+ wake := make(chan struct{}, 1)
25+ log := slog.New(slog.DiscardHandler)
26+ a := module.New("a", module.Left, "true", wake, log)
27+ b := module.New("b", module.Left, "true", wake, log)
28+ a.Interactive = true
29+ lay := layout.New(30, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, [16][3]uint8{}, []*module.Module{a, b})
30+ module.PublishForTest(a, []byte("AAAA"))
31+ module.PublishForTest(b, []byte("BBBB"))
32+ lay.Update()
33+ lay.Compose()
34+ return &inputRig{lay: lay, a: a, b: b}
35+}
36+
37+// drain returns the lines queued on m and empties its queue.
38+func drain(m *module.Module) []string {
39+ return module.DrainForTest(m)
40+}
41+
42+func TestHoverAndLeave(t *testing.T) {
43+ r := newInputRig(t)
44+ var p pointerState
45+ p.motion(r.lay, 2)
46+ p.motion(r.lay, 2) // same column: nothing
47+ p.motion(r.lay, 3)
48+ p.motion(r.lay, 5) // over the non-interactive module: leaves a
49+ p.motion(r.lay, 6) // still nothing
50+ p.motion(r.lay, 1)
51+ p.leave()
52+ p.leave()
53+ want := []string{"hover 2", "hover 3", "leave", "hover 1", "leave"}
54+ if got := drain(r.a); !slices.Equal(got, want) {
55+ t.Fatalf("lines = %q, want %q", got, want)
56+ }
57+ if got := drain(r.b); len(got) != 0 {
58+ t.Fatalf("a non-interactive module got %q", got)
59+ }
60+}
61+
62+func TestClickTokensAndButtons(t *testing.T) {
63+ r := newInputRig(t)
64+ var p pointerState
65+ p.button(r.lay, 1, btnLeft, 7)
66+ p.button(r.lay, 2, btnMiddle, 8)
67+ p.button(r.lay, 3, btnRight, 9)
68+ p.button(r.lay, 3, 0x113, 10) // another button: ignored
69+ p.button(r.lay, 5, btnLeft, 11)
70+ p.button(r.lay, 20, btnLeft, 12) // empty bar space
71+ want := []string{"click left 1 1", "click middle 2 2", "click right 3 3"}
72+ if got := drain(r.a); !slices.Equal(got, want) {
73+ t.Fatalf("lines = %q, want %q", got, want)
74+ }
75+ if p.press.token != 3 || p.press.serial != 9 || p.press.at.IsZero() {
76+ t.Fatalf("press = %+v", p.press)
77+ }
78+}
79+
80+func TestScrollLines(t *testing.T) {
81+ r := newInputRig(t)
82+ var p pointerState
83+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
84+ p.axis(r.lay, 1, neferclient.AxisVertical, -240, 0)
85+ p.axis(r.lay, 2, neferclient.AxisHorizontal, 120, 0)
86+ p.axis(r.lay, 2, neferclient.AxisHorizontal, -120, 0)
87+ p.axis(r.lay, 2, neferclient.AxisVertical, 0, 100) // continuous after a wheel: ignored
88+ p.axis(r.lay, 6, neferclient.AxisVertical, 120, 0) // not interactive
89+ want := []string{"scroll down 1 1", "scroll up 2 1", "scroll right 1 2", "scroll left 1 2"}
90+ if got := drain(r.a); !slices.Equal(got, want) {
91+ t.Fatalf("lines = %q, want %q", got, want)
92+ }
93+}
94+
95+func TestWheelAccumulator(t *testing.T) {
96+ var a wheelAcc
97+ for _, c := range []struct {
98+ axis uint32
99+ v120 int32
100+ d float64
101+ want int32
102+ }{
103+ {neferclient.AxisVertical, 120, 0, 1},
104+ {neferclient.AxisVertical, 240, 0, 2},
105+ {neferclient.AxisVertical, -120, 0, -1},
106+ {neferclient.AxisVertical, 60, 0, 0},
107+ {neferclient.AxisVertical, 60, 0, 1}, // two half notches
108+ {neferclient.AxisHorizontal, 120, 0, 1},
109+ {neferclient.AxisVertical, 0, 50, 0}, // continuous after a wheel is ignored
110+ } {
111+ if got := a.add(c.axis, c.v120, c.d); got != c.want {
112+ t.Errorf("add(%d, %d, %v) = %d, want %d", c.axis, c.v120, c.d, got, c.want)
113+ }
114+ }
115+ a.reset()
116+ for _, c := range []struct {
117+ d float64
118+ want int32
119+ }{{15, 1}, {10, 0}, {5, 1}, {-30, -2}, {7, 0}} {
120+ if got := a.add(neferclient.AxisVertical, 0, c.d); got != c.want {
121+ t.Errorf("continuous %v = %d, want %d", c.d, got, c.want)
122+ }
123+ }
124+ if a.add(2, 120, 0) != 0 {
125+ t.Error("an unknown axis must be ignored")
126+ }
127+}
128+
129+func TestInputRect(t *testing.T) {
130+ // Scale 1.6, 13 px cells: columns 2..4 are physical px 26..65, logical
131+ // 16.25..40.625, rounded outward.
132+ got := inputRect(2, 3, 13, 1.6, 24)
133+ want := neferclient.Rect{X: 16, Y: 0, Width: 25, Height: 24}
134+ if got != want {
135+ t.Fatalf("rect = %+v, want %+v", got, want)
136+ }
137+ if got = inputRect(0, 4, 10, 1, 20); got != (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 20}) {
138+ t.Fatalf("exact rect = %+v", got)
139+ }
140+ if got = inputRect(3, 2, 10, 2, 20); got != (neferclient.Rect{X: 15, Y: 0, Width: 10, Height: 20}) {
141+ t.Fatalf("scale 2 rect = %+v", got)
142+ }
143+}
144+
145+func TestInputRegionUpdates(t *testing.T) {
146+ r := newInputRig(t)
147+ var in inputRegion
148+ key := inputKey{cellW: 10, scale: 1, height: 20}
149+ rects, changed := in.update(r.lay, key)
150+ want := []neferclient.Rect{{X: 0, Y: 0, Width: 40, Height: 20}}
151+ if !changed || !slices.Equal(rects, want) {
152+ t.Fatalf("first update = %+v, %v; want %+v", rects, changed, want)
153+ }
154+ if _, changed = in.update(r.lay, key); changed {
155+ t.Fatal("same spans must not change the region")
156+ }
157+ if _, changed = in.update(r.lay, inputKey{cellW: 10, scale: 2, height: 20}); !changed {
158+ t.Fatal("a scale change must change the region")
159+ }
160+ in.reset()
161+ if _, changed = in.update(r.lay, key); !changed {
162+ t.Fatal("a new surface needs the region again")
163+ }
164+ // No interactive module left: back to an empty, non-nil region.
165+ r.a.Interactive = false
166+ rects, changed = in.update(r.lay, key)
167+ if !changed || rects == nil || len(rects) != 0 {
168+ t.Fatalf("empty update = %#v, %v; want non-nil empty", rects, changed)
169+ }
170+ if _, changed = in.update(r.lay, key); changed {
171+ t.Fatal("staying empty must not change the region")
172+ }
173+}
174+
175+func TestPointerAllocs(t *testing.T) {
176+ if racecheck.Enabled {
177+ t.Skip("the race detector allocates")
178+ }
179+ r := newInputRig(t)
180+ var p pointerState
181+ cols := [...]int{1, 2, 3, 5, 9}
182+ i := 0
183+ if got := testing.AllocsPerRun(200, func() {
184+ p.motion(r.lay, cols[i%len(cols)])
185+ i++
186+ }); got > 0 {
187+ t.Errorf("motion allocates %.1f objects; want 0", got)
188+ }
189+ if got := testing.AllocsPerRun(200, func() {
190+ p.button(r.lay, 1, btnLeft, 1)
191+ p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
192+ p.leave()
193+ }); got > 0 {
194+ t.Errorf("click/scroll/leave allocate %.1f objects; want 0", got)
195+ }
196+ var in inputRegion
197+ key := inputKey{cellW: 10, scale: 1, height: 20}
198+ in.update(r.lay, key)
199+ if got := testing.AllocsPerRun(200, func() { in.update(r.lay, key) }); got > 0 {
200+ t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201+ }
202+}
M internal/module/module.go +14 -0
@@ -300,6 +300,20 @@ func (w *logWriter) Write(p []byte) (int, error) {
300300 return len(p), nil
301301 }
302302
303+// DrainForTest empties the queue of Send lines and returns them without their
304+// newlines.
305+func DrainForTest(m *Module) []string {
306+ var out []string
307+ for {
308+ select {
309+ case ev := <-m.input:
310+ out = append(out, string(ev.b[:ev.n-1]))
311+ default:
312+ return out
313+ }
314+ }
315+}
316+
303317 // PublishForTest injects a frame as if the script had printed it.
304318 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
305319
@@ -300,6 +300,20 @@ func (w *logWriter) Write(p []byte) (int, error) {
300 return len(p), nil300 return len(p), nil
301 }301 }
302 302
303+// DrainForTest empties the queue of Send lines and returns them without their
304+// newlines.
305+func DrainForTest(m *Module) []string {
306+ var out []string
307+ for {
308+ select {
309+ case ev := <-m.input:
310+ out = append(out, string(ev.b[:ev.n-1]))
311+ default:
312+ return out
313+ }
314+ }
315+}
316+
303 // PublishForTest injects a frame as if the script had printed it.317 // PublishForTest injects a frame as if the script had printed it.
304 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }318 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
305 319

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