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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 115 | 115 | // mappedW is the logical width the current layer surface was first drawn at. | |
| 116 | 116 | // It resets to 0 when the surface is recreated. | |
| 117 | 117 | 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 | |
| 121 | 125 | } | |
| 122 | 126 | ||
| 123 | 127 | // 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 | 315 | ctx, cancel := context.WithCancel(b.runCtx) | |
| 312 | 316 | m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log) | |
| 313 | 317 | m.Env = b.env | |
| 318 | + | m.Interactive = w.Interactive | |
| 314 | 319 | m.Start(ctx) | |
| 315 | 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 | 329 | for _, r := range next { | |
| 325 | 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 | 335 | if b.lay != nil { | |
| 328 | 336 | b.lay.SetModules(b.mods) | |
| 329 | 337 | } | |
| @@ -498,6 +506,7 @@ func (b *Bar) createSurface(h int32) error { | |||
| 498 | 506 | } | |
| 499 | 507 | b.surf, b.sid = surf, surf.ID() | |
| 500 | 508 | b.mappedW = 0 | |
| 509 | + | b.input.reset() // the new surface is click-through | |
| 501 | 510 | b.fresh = true | |
| 502 | 511 | b.configured, b.canPresent = false, false | |
| 503 | 512 | return nil | |
| @@ -524,6 +533,7 @@ func (b *Bar) draw() error { | |||
| 524 | 533 | b.Stats.NoSlot++ // Released retries | |
| 525 | 534 | return nil | |
| 526 | 535 | } | |
| 536 | + | b.syncInputRegion() | |
| 527 | 537 | if err = b.present(); err != nil { | |
| 528 | 538 | 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 | 873 | // Error logs non-fatal connection errors. | |
| 826 | 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 | 878 | func (b *Bar) OutputRemoved(uint32) {} | |
| 831 | 879 | func (b *Bar) Locked() {} | |
| 832 | 880 | func (b *Bar) LockFinished() {} | |
| 833 | - | func (b *Bar) Pointer(*neferclient.PointerEvent) {} | |
| 834 | 881 | func (b *Bar) Key(*neferclient.KeyEvent) {} | |
| 835 | 882 | func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {} | |
| 836 | 883 | 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 int32 | 117 | mappedW int32 | ||
| 118 | - | Stats Stats | 118 | + | |
| 119 | - | fatal error | 119 | + | ptr pointerState // pointer events turned into module lines |
| 120 | - | closed bool | 120 | + | 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 came | 127 | // 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.env | 317 | 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 = 0 | 508 | b.mappedW = 0 | ||
| 509 | + | b.input.reset() // the new surface is click-through | |||
| 501 | b.fresh = true | 510 | b.fresh = true | ||
| 502 | b.configured, b.canPresent = false, false | 511 | b.configured, b.canPresent = false, false | ||
| 503 | return nil | 512 | return nil | ||
| @@ -524,6 +533,7 @@ func (b *Bar) draw() error { | |||||
| 524 | b.Stats.NoSlot++ // Released retries | 533 | b.Stats.NoSlot++ // Released retries | ||
| 525 | return nil | 534 | return nil | ||
| 526 | } | 535 | } | ||
| 536 | + | b.syncInputRegion() | |||
| 527 | if err = b.present(); err != nil { | 537 | if err = b.present(); err != nil { | ||
| 528 | return err | 538 | 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) { | |||
| 300 | 300 | 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 | 317 | // PublishForTest injects a frame as if the script had printed it. | |
| 304 | 318 | func PublishForTest(m *Module, frame []byte) { m.publish(frame) } | |
| 305 | 319 | ||
| @@ -300,6 +300,20 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||||
| 300 | return len(p), nil | 300 | 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 | ||||