detached b9309c2

feat(cursor): hide the cursor after cursor.hide-after without motion (#95)

* feat(cursor): hide the cursor after cursor.hide-after without motion

* fix(cursor): keep an idle-hidden cursor hidden on layout and constraint resyncs

* test(cursor): pin the idle hide to motion time across a resync
commit
b9309c22939b01b11295d992052a7e4c7d37a376
author
bnema · · 2026-10-03 02:03 UTC
committer
GitHub · · 2026-10-03 02:03 UTC
parent
4f3524c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +418 -28
M .mockery.yml +5 -0
@@ -67,6 +67,11 @@ packages:
6767 dir: internal/app
6868 pkgname: app
6969 filename: securitylease_mock_test.go
70+ cursor:
71+ config:
72+ dir: internal/app
73+ pkgname: app
74+ filename: cursor_mock_test.go
7075 github.com/bnema/neferwl/internal/adapters/statefile:
7176 interfaces:
7277 procTree:
@@ -67,6 +67,11 @@ packages:
67 dir: internal/app67 dir: internal/app
68 pkgname: app68 pkgname: app
69 filename: securitylease_mock_test.go69 filename: securitylease_mock_test.go
70+ cursor:
71+ config:
72+ dir: internal/app
73+ pkgname: app
74+ filename: cursor_mock_test.go
70 github.com/bnema/neferwl/internal/adapters/statefile:75 github.com/bnema/neferwl/internal/adapters/statefile:
71 interfaces:76 interfaces:
72 procTree:77 procTree:
M docs/config.md +1 -0
@@ -38,6 +38,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
3838 | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) |
3939 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |
4040 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |
41+| `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
4142 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
4243 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, the overview and a window focused as it opens |
4344 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
@@ -38,6 +38,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
38 | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) |38 | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) |
39 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |39 | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed |
40 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |40 | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` |
41+| `cursor.hide-after` | `5s` | Hides the pointer cursor after this long without motion; the next motion shows it. 100ms to 1h, or `off` |
41 | `focus.follow-move` | `off` | Follow a column moved to another workspace |42 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
42 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, the overview and a window focused as it opens |43 | `focus.animation` | `pulse` | Focus indicator on a window that keeps the focus for 150 ms: `pulse` (brief rise and fall) or `off`. Skipped for fullscreen windows, the overview and a window focused as it opens |
43 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |44 | `focus.effect` | `screen` | What the focus indicator draws: `screen` brightens the window slightly, visible on black too. HDR (PQ) client content is not brightened on HDR outputs |
M examples/config +4 -0
@@ -57,6 +57,10 @@ touchpad.accel-profile = adaptive
5757 # Two-finger scroll distance multiplier.
5858 touchpad.scroll-factor = 1
5959
60+# Hide the pointer cursor after this long without motion (100ms to 1h, or
61+# off); the next motion shows it.
62+cursor.hide-after = 5s
63+
6064 # on: moving a column or window to another workspace shows that workspace.
6165 focus.follow-move = off
6266 # Focus indicator on the newly focused window: an animation (pulse or off)
@@ -57,6 +57,10 @@ touchpad.accel-profile = adaptive
57 # Two-finger scroll distance multiplier.57 # Two-finger scroll distance multiplier.
58 touchpad.scroll-factor = 158 touchpad.scroll-factor = 1
59 59
60+# Hide the pointer cursor after this long without motion (100ms to 1h, or
61+# off); the next motion shows it.
62+cursor.hide-after = 5s
63+
60 # on: moving a column or window to another workspace shows that workspace.64 # on: moving a column or window to another workspace shows that workspace.
61 focus.follow-move = off65 focus.follow-move = off
62 # Focus indicator on the newly focused window: an animation (pulse or off)66 # Focus indicator on the newly focused window: an animation (pulse or off)
M internal/adapters/config/config.go +14 -0
@@ -23,6 +23,9 @@ import (
2323 // VRR panel (48 Hz: 20.8 ms).
2424 const maxVRRFlipGap = 10 * time.Millisecond
2525
26+// maxCursorHideAfter bounds cursor.hide-after.
27+const maxCursorHideAfter = time.Hour
28+
2629 // defaultBinds use the config syntax and go through the same parser as user binds.
2730 var defaultBinds = []struct{ combo, action string }{
2831 {"cmd+return", "spawn-terminal"},
@@ -122,6 +125,7 @@ func Defaults() ports.Config {
122125 c.Touchpad.Tap = true
123126 c.Touchpad.AccelProfile = ports.AccelAdaptive
124127 c.Touchpad.ScrollFactor = 1
128+ c.Cursor.HideAfter = 5 * time.Second
125129 c.Focus.Animation = ports.FocusAnimationPulse
126130 c.Focus.Effect = ports.FocusEffectScreen
127131 c.Focus.Strength = 0.04
@@ -582,6 +586,16 @@ func set(c *ports.Config, key, v string) error {
582586 return fmt.Errorf("must be above 0 and at most 10")
583587 }
584588 c.Touchpad.ScrollFactor = f
589+ case "cursor.hide-after":
590+ if v == "off" {
591+ c.Cursor.HideAfter = 0
592+ return nil
593+ }
594+ d, err := time.ParseDuration(v)
595+ if err != nil || d < 100*time.Millisecond || d > maxCursorHideAfter {
596+ return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)
597+ }
598+ c.Cursor.HideAfter = d
585599 case "focus.follow-move":
586600 b, err := onOff(v)
587601 if err != nil {
@@ -23,6 +23,9 @@ import (
23 // VRR panel (48 Hz: 20.8 ms).23 // VRR panel (48 Hz: 20.8 ms).
24 const maxVRRFlipGap = 10 * time.Millisecond24 const maxVRRFlipGap = 10 * time.Millisecond
25 25
26+// maxCursorHideAfter bounds cursor.hide-after.
27+const maxCursorHideAfter = time.Hour
28+
26 // defaultBinds use the config syntax and go through the same parser as user binds.29 // defaultBinds use the config syntax and go through the same parser as user binds.
27 var defaultBinds = []struct{ combo, action string }{30 var defaultBinds = []struct{ combo, action string }{
28 {"cmd+return", "spawn-terminal"},31 {"cmd+return", "spawn-terminal"},
@@ -122,6 +125,7 @@ func Defaults() ports.Config {
122 c.Touchpad.Tap = true125 c.Touchpad.Tap = true
123 c.Touchpad.AccelProfile = ports.AccelAdaptive126 c.Touchpad.AccelProfile = ports.AccelAdaptive
124 c.Touchpad.ScrollFactor = 1127 c.Touchpad.ScrollFactor = 1
128+ c.Cursor.HideAfter = 5 * time.Second
125 c.Focus.Animation = ports.FocusAnimationPulse129 c.Focus.Animation = ports.FocusAnimationPulse
126 c.Focus.Effect = ports.FocusEffectScreen130 c.Focus.Effect = ports.FocusEffectScreen
127 c.Focus.Strength = 0.04131 c.Focus.Strength = 0.04
@@ -582,6 +586,16 @@ func set(c *ports.Config, key, v string) error {
582 return fmt.Errorf("must be above 0 and at most 10")586 return fmt.Errorf("must be above 0 and at most 10")
583 }587 }
584 c.Touchpad.ScrollFactor = f588 c.Touchpad.ScrollFactor = f
589+ case "cursor.hide-after":
590+ if v == "off" {
591+ c.Cursor.HideAfter = 0
592+ return nil
593+ }
594+ d, err := time.ParseDuration(v)
595+ if err != nil || d < 100*time.Millisecond || d > maxCursorHideAfter {
596+ return fmt.Errorf("must be off or a duration between 100ms and %s, e.g. 5s", maxCursorHideAfter)
597+ }
598+ c.Cursor.HideAfter = d
585 case "focus.follow-move":599 case "focus.follow-move":
586 b, err := onOff(v)600 b, err := onOff(v)
587 if err != nil {601 if err != nil {
M internal/adapters/config/config_test.go +21 -0
@@ -535,6 +535,27 @@ func TestVRRFlipGap(t *testing.T) {
535535 }
536536 }
537537
538+func TestCursorHideAfter(t *testing.T) {
539+ if d := Defaults().Cursor.HideAfter; d != 5*time.Second {
540+ t.Fatalf("default %v", d)
541+ }
542+ for _, tc := range []struct {
543+ value string
544+ want time.Duration
545+ warning bool
546+ }{
547+ {"off", 0, false}, {"100ms", 100 * time.Millisecond, false}, {"2s", 2 * time.Second, false}, {"1h", time.Hour, false},
548+ {"0", 5 * time.Second, true}, {"50ms", 5 * time.Second, true}, {"2h", 5 * time.Second, true}, {"on", 5 * time.Second, true},
549+ } {
550+ t.Run(tc.value, func(t *testing.T) {
551+ c, warnings := parseString(t, "cursor.hide-after = "+tc.value)
552+ if c.Cursor.HideAfter != tc.want || (len(warnings) != 0) != tc.warning {
553+ t.Fatalf("hide-after %v, warnings %v", c.Cursor.HideAfter, warnings)
554+ }
555+ })
556+ }
557+}
558+
538559 func TestFloatingDim(t *testing.T) {
539560 for _, tc := range []struct {
540561 value string
@@ -535,6 +535,27 @@ func TestVRRFlipGap(t *testing.T) {
535 }535 }
536 }536 }
537 537
538+func TestCursorHideAfter(t *testing.T) {
539+ if d := Defaults().Cursor.HideAfter; d != 5*time.Second {
540+ t.Fatalf("default %v", d)
541+ }
542+ for _, tc := range []struct {
543+ value string
544+ want time.Duration
545+ warning bool
546+ }{
547+ {"off", 0, false}, {"100ms", 100 * time.Millisecond, false}, {"2s", 2 * time.Second, false}, {"1h", time.Hour, false},
548+ {"0", 5 * time.Second, true}, {"50ms", 5 * time.Second, true}, {"2h", 5 * time.Second, true}, {"on", 5 * time.Second, true},
549+ } {
550+ t.Run(tc.value, func(t *testing.T) {
551+ c, warnings := parseString(t, "cursor.hide-after = "+tc.value)
552+ if c.Cursor.HideAfter != tc.want || (len(warnings) != 0) != tc.warning {
553+ t.Fatalf("hide-after %v, warnings %v", c.Cursor.HideAfter, warnings)
554+ }
555+ })
556+ }
557+}
558+
538 func TestFloatingDim(t *testing.T) {559 func TestFloatingDim(t *testing.T) {
539 for _, tc := range []struct {560 for _, tc := range []struct {
540 value string561 value string
M internal/adapters/libinput/libinput.go +12 -10
@@ -205,8 +205,9 @@ type Options struct {
205205 Active <-chan bool
206206 // MoveCursor, when set, places the hardware cursor as soon as motion is
207207 // read, before core sees the event: the output under the pointer and
208- // the physical position on it.
209- MoveCursor func(output string, x, y float64)
208+ // the physical position on it. motion is false when the pointer is only
209+ // placed again after a layout or constraint change.
210+ MoveCursor func(output string, x, y float64, motion bool)
210211 Log zerowrap.Logger
211212 // LogMotion logs every pointer motion (--debug=input-motion); motions
212213 // are otherwise only counted in the input stats.
@@ -244,7 +245,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
244245 return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
245246 }
246247 p := newPointer(opts.Layout)
247- p.moved(opts.MoveCursor)
248+ p.moved(opts.MoveCursor, false)
248249 in := &inputState{touchpad: opts.Touchpad, devices: map[uintptr]bool{}}
249250 defer in.release()
250251 fd := getFD(li)
@@ -278,10 +279,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
278279 opts.Log.Info().Msg("keymap replaced")
279280 case l := <-opts.Layouts:
280281 p.setLayout(l)
281- p.moved(opts.MoveCursor)
282+ p.moved(opts.MoveCursor, false)
282283 case c := <-opts.Constraints:
283284 p.constrain(c)
284- p.moved(opts.MoveCursor)
285+ p.moved(opts.MoveCursor, false)
285286 case t := <-opts.Touchpads:
286287 in.touchpad = t
287288 for dev := range in.devices {
@@ -484,7 +485,7 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state
484485 m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)
485486 m.TimeUsec = pointerUsec(pe)
486487 m.TimeMsec = msec(m.TimeUsec)
487- p.moved(opts.MoveCursor)
488+ p.moved(opts.MoveCursor, true)
488489 if opts.LogMotion {
489490 log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
490491 }
@@ -498,7 +499,7 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state
498499 return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil
499500 }
500501 x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))
501- p.moved(opts.MoveCursor)
502+ p.moved(opts.MoveCursor, true)
502503 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
503504 case evPointerButton:
504505 pe := pointerEvent(ev)
@@ -683,9 +684,10 @@ func (p *pointer) bounds() ports.Rect {
683684 return r
684685 }
685686
686-// moved reports the physical position on the output under the pointer.
687-func (p *pointer) moved(move func(output string, x, y float64)) {
687+// moved reports the physical position on the output under the pointer;
688+// motion tells the device moved it.
689+func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {
688690 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
689- move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale)
691+ move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale, motion)
690692 }
691693 }
@@ -205,8 +205,9 @@ type Options struct {
205 Active <-chan bool205 Active <-chan bool
206 // MoveCursor, when set, places the hardware cursor as soon as motion is206 // MoveCursor, when set, places the hardware cursor as soon as motion is
207 // read, before core sees the event: the output under the pointer and207 // read, before core sees the event: the output under the pointer and
208- // the physical position on it.208+ // the physical position on it. motion is false when the pointer is only
209- MoveCursor func(output string, x, y float64)209+ // placed again after a layout or constraint change.
210+ MoveCursor func(output string, x, y float64, motion bool)
210 Log zerowrap.Logger211 Log zerowrap.Logger
211 // LogMotion logs every pointer motion (--debug=input-motion); motions212 // LogMotion logs every pointer motion (--debug=input-motion); motions
212 // are otherwise only counted in the input stats.213 // are otherwise only counted in the input stats.
@@ -244,7 +245,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
244 return fmt.Errorf("libinput_udev_assign_seat %s failed", name)245 return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
245 }246 }
246 p := newPointer(opts.Layout)247 p := newPointer(opts.Layout)
247- p.moved(opts.MoveCursor)248+ p.moved(opts.MoveCursor, false)
248 in := &inputState{touchpad: opts.Touchpad, devices: map[uintptr]bool{}}249 in := &inputState{touchpad: opts.Touchpad, devices: map[uintptr]bool{}}
249 defer in.release()250 defer in.release()
250 fd := getFD(li)251 fd := getFD(li)
@@ -278,10 +279,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
278 opts.Log.Info().Msg("keymap replaced")279 opts.Log.Info().Msg("keymap replaced")
279 case l := <-opts.Layouts:280 case l := <-opts.Layouts:
280 p.setLayout(l)281 p.setLayout(l)
281- p.moved(opts.MoveCursor)282+ p.moved(opts.MoveCursor, false)
282 case c := <-opts.Constraints:283 case c := <-opts.Constraints:
283 p.constrain(c)284 p.constrain(c)
284- p.moved(opts.MoveCursor)285+ p.moved(opts.MoveCursor, false)
285 case t := <-opts.Touchpads:286 case t := <-opts.Touchpads:
286 in.touchpad = t287 in.touchpad = t
287 for dev := range in.devices {288 for dev := range in.devices {
@@ -484,7 +485,7 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state
484 m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)485 m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe)
485 m.TimeUsec = pointerUsec(pe)486 m.TimeUsec = pointerUsec(pe)
486 m.TimeMsec = msec(m.TimeUsec)487 m.TimeMsec = msec(m.TimeUsec)
487- p.moved(opts.MoveCursor)488+ p.moved(opts.MoveCursor, true)
488 if opts.LogMotion {489 if opts.LogMotion {
489 log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")490 log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer")
490 }491 }
@@ -498,7 +499,7 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state
498 return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil499 return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil
499 }500 }
500 x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))501 x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))
501- p.moved(opts.MoveCursor)502+ p.moved(opts.MoveCursor, true)
502 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil503 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
503 case evPointerButton:504 case evPointerButton:
504 pe := pointerEvent(ev)505 pe := pointerEvent(ev)
@@ -683,9 +684,10 @@ func (p *pointer) bounds() ports.Rect {
683 return r684 return r
684 }685 }
685 686
686-// moved reports the physical position on the output under the pointer.687+// moved reports the physical position on the output under the pointer;
687-func (p *pointer) moved(move func(output string, x, y float64)) {688+// motion tells the device moved it.
689+func (p *pointer) moved(move func(output string, x, y float64, motion bool), motion bool) {
688 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {690 if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
689- move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale)691+ move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale, motion)
690 }692 }
691 }693 }
M internal/adapters/libinput/libinput_test.go +1 -1
@@ -132,7 +132,7 @@ func TestPointerAcrossOutputs(t *testing.T) {
132132 }
133133 var out string
134134 var px, py float64
135- p.moved(func(o string, x, y float64) { out, px, py = o, x, y })
135+ p.moved(func(o string, x, y float64, _ bool) { out, px, py = o, x, y }, true)
136136 if out != "B" || px != 120 || py != 50 {
137137 t.Fatal(out, px, py)
138138 }
@@ -132,7 +132,7 @@ func TestPointerAcrossOutputs(t *testing.T) {
132 }132 }
133 var out string133 var out string
134 var px, py float64134 var px, py float64
135- p.moved(func(o string, x, y float64) { out, px, py = o, x, y })135+ p.moved(func(o string, x, y float64, _ bool) { out, px, py = o, x, y }, true)
136 if out != "B" || px != 120 || py != 50 {136 if out != "B" || px != 120 || py != 50 {
137 t.Fatal(out, px, py)137 t.Fatal(out, px, py)
138 }138 }
M internal/app/app.go +8 -5
@@ -201,13 +201,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
201201 }()
202202 }
203203 filtered := make(chan ports.ConfigChanged, 8)
204+ curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
205+ defer curs.stop()
204206 workers.Add(1)
205207 go func() {
206208 defer workers.Done()
207- relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, commands, logging.For(ctx, "config"))
209+ relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, curs, commands, logging.For(ctx, "config"))
208210 }()
209211 script := make(chan string)
210- curs := newCursors()
211212 go func() {
212213 defer workers.Done()
213214 if hw != nil {
@@ -231,7 +232,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
231232 })
232233 return
233234 }
234- if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
235+ if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
235236 select {
236237 case done <- err:
237238 case <-ctx.Done():
@@ -352,8 +353,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
352353 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
353354 // to the input goroutine and sends it to clients; a repeat-only change just updates
354355 // clients. A keymap that fails to build keeps the previous layout. A touchpad
355-// change goes to the input goroutine; only the newest one waits there.
356-func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
356+// change goes to the input goroutine; only the newest one waits there. The
357+// cursor idle delay goes straight to the cursor router.
358+func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
357359 kb := cur.Keyboard
358360 tp := cur.Touchpad
359361 for {
@@ -389,6 +391,7 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
389391 }
390392 }
391393 }
394+ curs.setHideAfter(ev.Config.Cursor.HideAfter)
392395 if ev.Config.Touchpad != tp {
393396 tp = ev.Config.Touchpad
394397 select {
@@ -201,13 +201,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
201 }()201 }()
202 }202 }
203 filtered := make(chan ports.ConfigChanged, 8)203 filtered := make(chan ports.ConfigChanged, 8)
204+ curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
205+ defer curs.stop()
204 workers.Add(1)206 workers.Add(1)
205 go func() {207 go func() {
206 defer workers.Done()208 defer workers.Done()
207- relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, commands, logging.For(ctx, "config"))209+ relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, curs, commands, logging.For(ctx, "config"))
208 }()210 }()
209 script := make(chan string)211 script := make(chan string)
210- curs := newCursors()
211 go func() {212 go func() {
212 defer workers.Done()213 defer workers.Done()
213 if hw != nil {214 if hw != nil {
@@ -231,7 +232,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
231 })232 })
232 return233 return
233 }234 }
234- if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {235+ if err := headlessinput.RunSecure(ctx, km, keymaps, script, input, layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
235 select {236 select {
236 case done <- err:237 case done <- err:
237 case <-ctx.Done():238 case <-ctx.Done():
@@ -352,8 +353,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
352 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it353 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
353 // to the input goroutine and sends it to clients; a repeat-only change just updates354 // to the input goroutine and sends it to clients; a repeat-only change just updates
354 // clients. A keymap that fails to build keeps the previous layout. A touchpad355 // clients. A keymap that fails to build keeps the previous layout. A touchpad
355-// change goes to the input goroutine; only the newest one waits there.356+// change goes to the input goroutine; only the newest one waits there. The
356-func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, commands chan<- ports.ClientCommand, log zerowrap.Logger) {357+// cursor idle delay goes straight to the cursor router.
358+func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) {
357 kb := cur.Keyboard359 kb := cur.Keyboard
358 tp := cur.Touchpad360 tp := cur.Touchpad
359 for {361 for {
@@ -389,6 +391,7 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
389 }391 }
390 }392 }
391 }393 }
394+ curs.setHideAfter(ev.Config.Cursor.HideAfter)
392 if ev.Config.Touchpad != tp {395 if ev.Config.Touchpad != tp {
393 tp = ev.Config.Touchpad396 tp = ev.Config.Touchpad
394 select {397 select {
M internal/app/app_test.go +2 -1
@@ -12,6 +12,7 @@ import (
1212 "testing"
1313 "time"
1414
15+ "github.com/bnema/neferwl/internal/adapters/clock"
1516 "github.com/bnema/neferwl/internal/adapters/config"
1617 "github.com/bnema/neferwl/internal/adapters/xkb"
1718 "github.com/bnema/neferwl/internal/logging"
@@ -273,7 +274,7 @@ func TestRelayConfigKeyboard(t *testing.T) {
273274 keymaps := make(chan *xkb.Keymap, 1)
274275 commands := make(chan ports.ClientCommand, 1)
275276 touchpads := make(chan ports.TouchpadConfig, 1)
276- go relayConfig(ctx, cur, in, out, keymaps, touchpads, commands, logging.For(ctx, "config"))
277+ go relayConfig(ctx, cur, in, out, keymaps, touchpads, newCursors(clock.System{}, 0), commands, logging.For(ctx, "config"))
277278
278279 next := config.Defaults()
279280 next.Background.Color = "#000000"
@@ -12,6 +12,7 @@ import (
12 "testing"12 "testing"
13 "time"13 "time"
14 14
15+ "github.com/bnema/neferwl/internal/adapters/clock"
15 "github.com/bnema/neferwl/internal/adapters/config"16 "github.com/bnema/neferwl/internal/adapters/config"
16 "github.com/bnema/neferwl/internal/adapters/xkb"17 "github.com/bnema/neferwl/internal/adapters/xkb"
17 "github.com/bnema/neferwl/internal/logging"18 "github.com/bnema/neferwl/internal/logging"
@@ -273,7 +274,7 @@ func TestRelayConfigKeyboard(t *testing.T) {
273 keymaps := make(chan *xkb.Keymap, 1)274 keymaps := make(chan *xkb.Keymap, 1)
274 commands := make(chan ports.ClientCommand, 1)275 commands := make(chan ports.ClientCommand, 1)
275 touchpads := make(chan ports.TouchpadConfig, 1)276 touchpads := make(chan ports.TouchpadConfig, 1)
276- go relayConfig(ctx, cur, in, out, keymaps, touchpads, commands, logging.For(ctx, "config"))277+ go relayConfig(ctx, cur, in, out, keymaps, touchpads, newCursors(clock.System{}, 0), commands, logging.For(ctx, "config"))
277 278
278 next := config.Defaults()279 next := config.Defaults()
279 next.Background.Color = "#000000"280 next.Background.Color = "#000000"
A internal/app/cursor_mock_test.go +124 -0
@@ -0,0 +1,124 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockcursor creates a new instance of mockcursor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockcursor(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockcursor {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockcursor{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockcursor is an autogenerated mock type for the cursor type
35+type mockcursor struct {
36+ mock.Mock
37+}
38+
39+type mockcursor_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockcursor) EXPECT() *mockcursor_Expecter {
44+ return &mockcursor_Expecter{mock: &_m.Mock}
45+}
46+
47+// Hide provides a mock function for the type mockcursor
48+func (_mock *mockcursor) Hide() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockcursor_Hide_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hide'
54+type mockcursor_Hide_Call struct {
55+ *mock.Call
56+}
57+
58+// Hide is a helper method to define mock.On call
59+func (_e *mockcursor_Expecter) Hide() *mockcursor_Hide_Call {
60+ return &mockcursor_Hide_Call{Call: _e.mock.On("Hide")}
61+}
62+
63+func (_c *mockcursor_Hide_Call) Run(run func()) *mockcursor_Hide_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockcursor_Hide_Call) Return() *mockcursor_Hide_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockcursor_Hide_Call) RunAndReturn(run func()) *mockcursor_Hide_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// Move provides a mock function for the type mockcursor
81+func (_mock *mockcursor) Move(x float64, y float64) {
82+ _mock.Called(x, y)
83+ return
84+}
85+
86+// mockcursor_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
87+type mockcursor_Move_Call struct {
88+ *mock.Call
89+}
90+
91+// Move is a helper method to define mock.On call
92+// - x float64
93+// - y float64
94+func (_e *mockcursor_Expecter) Move(x any, y any) *mockcursor_Move_Call {
95+ return &mockcursor_Move_Call{Call: _e.mock.On("Move", x, y)}
96+}
97+
98+func (_c *mockcursor_Move_Call) Run(run func(x float64, y float64)) *mockcursor_Move_Call {
99+ _c.Call.Run(func(args mock.Arguments) {
100+ var arg0 float64
101+ if args[0] != nil {
102+ arg0 = args[0].(float64)
103+ }
104+ var arg1 float64
105+ if args[1] != nil {
106+ arg1 = args[1].(float64)
107+ }
108+ run(
109+ arg0,
110+ arg1,
111+ )
112+ })
113+ return _c
114+}
115+
116+func (_c *mockcursor_Move_Call) Return() *mockcursor_Move_Call {
117+ _c.Call.Return()
118+ return _c
119+}
120+
121+func (_c *mockcursor_Move_Call) RunAndReturn(run func(x float64, y float64)) *mockcursor_Move_Call {
122+ _c.Run(run)
123+ return _c
124+}
@@ -0,0 +1,124 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockcursor creates a new instance of mockcursor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockcursor(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockcursor {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockcursor{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockcursor is an autogenerated mock type for the cursor type
35+type mockcursor struct {
36+ mock.Mock
37+}
38+
39+type mockcursor_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockcursor) EXPECT() *mockcursor_Expecter {
44+ return &mockcursor_Expecter{mock: &_m.Mock}
45+}
46+
47+// Hide provides a mock function for the type mockcursor
48+func (_mock *mockcursor) Hide() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockcursor_Hide_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hide'
54+type mockcursor_Hide_Call struct {
55+ *mock.Call
56+}
57+
58+// Hide is a helper method to define mock.On call
59+func (_e *mockcursor_Expecter) Hide() *mockcursor_Hide_Call {
60+ return &mockcursor_Hide_Call{Call: _e.mock.On("Hide")}
61+}
62+
63+func (_c *mockcursor_Hide_Call) Run(run func()) *mockcursor_Hide_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockcursor_Hide_Call) Return() *mockcursor_Hide_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockcursor_Hide_Call) RunAndReturn(run func()) *mockcursor_Hide_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// Move provides a mock function for the type mockcursor
81+func (_mock *mockcursor) Move(x float64, y float64) {
82+ _mock.Called(x, y)
83+ return
84+}
85+
86+// mockcursor_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
87+type mockcursor_Move_Call struct {
88+ *mock.Call
89+}
90+
91+// Move is a helper method to define mock.On call
92+// - x float64
93+// - y float64
94+func (_e *mockcursor_Expecter) Move(x any, y any) *mockcursor_Move_Call {
95+ return &mockcursor_Move_Call{Call: _e.mock.On("Move", x, y)}
96+}
97+
98+func (_c *mockcursor_Move_Call) Run(run func(x float64, y float64)) *mockcursor_Move_Call {
99+ _c.Call.Run(func(args mock.Arguments) {
100+ var arg0 float64
101+ if args[0] != nil {
102+ arg0 = args[0].(float64)
103+ }
104+ var arg1 float64
105+ if args[1] != nil {
106+ arg1 = args[1].(float64)
107+ }
108+ run(
109+ arg0,
110+ arg1,
111+ )
112+ })
113+ return _c
114+}
115+
116+func (_c *mockcursor_Move_Call) Return() *mockcursor_Move_Call {
117+ _c.Call.Return()
118+ return _c
119+}
120+
121+func (_c *mockcursor_Move_Call) RunAndReturn(run func(x float64, y float64)) *mockcursor_Move_Call {
122+ _c.Run(run)
123+ return _c
124+}
A internal/app/cursors_test.go +124 -0
@@ -0,0 +1,124 @@
1+package app
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/mock"
10+)
11+
12+const testHideAfter = 5 * time.Second
13+
14+// idleRig is a cursor router on a clock the test drives. The first arm
15+// creates the timer through AfterFunc (exactly once); later arms Reset it.
16+type idleRig struct {
17+ c *cursors
18+ timer *portsmocks.MockTimer
19+ cur *mockcursor
20+ now time.Time
21+ fire func()
22+}
23+
24+func newIdleRig(t *testing.T) *idleRig {
25+ t.Helper()
26+ r := &idleRig{now: time.Unix(1000, 0), timer: portsmocks.NewMockTimer(t)}
27+ clk := portsmocks.NewMockClock(t)
28+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return r.now }).Maybe()
29+ clk.EXPECT().AfterFunc(testHideAfter, mock.Anything).RunAndReturn(func(_ time.Duration, f func()) ports.Timer {
30+ r.fire = f
31+ return r.timer
32+ }).Once()
33+ r.c = newCursors(clk, testHideAfter)
34+ r.cur = newMockcursor(t)
35+ r.cur.EXPECT().Hide().Once() // a cursor registered before the first move is hidden
36+ r.c.set("A", r.cur)
37+ r.cur.EXPECT().Move(1.0, 1.0).Once()
38+ r.c.move("A", 1, 1, true)
39+ return r
40+}
41+
42+// idleHide lets the delay pass and expects the cursor to hide.
43+func (r *idleRig) idleHide() {
44+ r.now = r.now.Add(testHideAfter)
45+ r.cur.EXPECT().Hide().Once()
46+ r.fire()
47+}
48+
49+func TestCursorHidesWhenIdleAndShowsOnMotion(t *testing.T) {
50+ r := newIdleRig(t)
51+ r.idleHide()
52+ r.cur.EXPECT().Move(2.0, 2.0).Once()
53+ r.timer.EXPECT().Reset(testHideAfter).Return(false).Once()
54+ r.c.move("A", 2, 2, true)
55+}
56+
57+// A timer that fires while the pointer kept moving waits for the time left.
58+func TestCursorIdleTimerWaitsForTimeLeft(t *testing.T) {
59+ r := newIdleRig(t)
60+ r.now = r.now.Add(3 * time.Second)
61+ r.cur.EXPECT().Move(2.0, 2.0).Once()
62+ r.c.move("A", 2, 2, true)
63+ r.now = r.now.Add(2 * time.Second)
64+ r.timer.EXPECT().Reset(3 * time.Second).Return(false).Once()
65+ r.fire() // no Hide: only 2s since the last motion
66+}
67+
68+// Placing the pointer again after a layout or constraint change is not
69+// motion: it moves a visible cursor without delaying its hide, and never
70+// shows a hidden one.
71+func TestCursorResyncIsNotMotion(t *testing.T) {
72+ r := newIdleRig(t)
73+ r.now = r.now.Add(3 * time.Second)
74+ r.cur.EXPECT().Move(2.0, 2.0).Once()
75+ r.c.move("A", 2, 2, false)
76+ r.now = r.now.Add(2 * time.Second)
77+ r.cur.EXPECT().Hide().Once()
78+ r.fire() // 5s after the last motion, only 2s after the resync
79+ r.c.move("A", 3, 3, false)
80+}
81+
82+// An output that restarts while the cursor is idle-hidden keeps it hidden.
83+func TestCursorStaysHiddenOnOutputRestart(t *testing.T) {
84+ r := newIdleRig(t)
85+ r.idleHide()
86+ next := newMockcursor(t)
87+ next.EXPECT().Hide().Once()
88+ r.c.set("A", next)
89+}
90+
91+func TestCursorHideAfterReload(t *testing.T) {
92+ t.Run("visible", func(t *testing.T) {
93+ r := newIdleRig(t)
94+ r.timer.EXPECT().Stop().Return(true).Once()
95+ r.timer.EXPECT().Reset(10 * time.Second).Return(false).Once()
96+ r.c.setHideAfter(10 * time.Second)
97+ })
98+ t.Run("hidden", func(t *testing.T) {
99+ r := newIdleRig(t)
100+ r.idleHide()
101+ r.timer.EXPECT().Stop().Return(false).Once()
102+ r.c.setHideAfter(10 * time.Second) // no re-arm: nothing to hide
103+ next := newMockcursor(t)
104+ next.EXPECT().Hide().Once()
105+ r.c.set("A", next)
106+ })
107+ t.Run("off", func(t *testing.T) {
108+ r := newIdleRig(t)
109+ r.timer.EXPECT().Stop().Return(true).Once()
110+ r.c.setHideAfter(0)
111+ r.now = r.now.Add(time.Minute)
112+ r.fire() // a fire already under way hides nothing
113+ r.cur.EXPECT().Move(2.0, 2.0).Once()
114+ r.c.move("A", 2, 2, true) // and motion arms no timer
115+ })
116+}
117+
118+func TestCursorStopCancelsPendingHide(t *testing.T) {
119+ r := newIdleRig(t)
120+ r.timer.EXPECT().Stop().Return(true).Once()
121+ r.c.stop()
122+ r.now = r.now.Add(testHideAfter)
123+ r.fire() // no Hide
124+}
@@ -0,0 +1,124 @@
1+package app
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/mock"
10+)
11+
12+const testHideAfter = 5 * time.Second
13+
14+// idleRig is a cursor router on a clock the test drives. The first arm
15+// creates the timer through AfterFunc (exactly once); later arms Reset it.
16+type idleRig struct {
17+ c *cursors
18+ timer *portsmocks.MockTimer
19+ cur *mockcursor
20+ now time.Time
21+ fire func()
22+}
23+
24+func newIdleRig(t *testing.T) *idleRig {
25+ t.Helper()
26+ r := &idleRig{now: time.Unix(1000, 0), timer: portsmocks.NewMockTimer(t)}
27+ clk := portsmocks.NewMockClock(t)
28+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return r.now }).Maybe()
29+ clk.EXPECT().AfterFunc(testHideAfter, mock.Anything).RunAndReturn(func(_ time.Duration, f func()) ports.Timer {
30+ r.fire = f
31+ return r.timer
32+ }).Once()
33+ r.c = newCursors(clk, testHideAfter)
34+ r.cur = newMockcursor(t)
35+ r.cur.EXPECT().Hide().Once() // a cursor registered before the first move is hidden
36+ r.c.set("A", r.cur)
37+ r.cur.EXPECT().Move(1.0, 1.0).Once()
38+ r.c.move("A", 1, 1, true)
39+ return r
40+}
41+
42+// idleHide lets the delay pass and expects the cursor to hide.
43+func (r *idleRig) idleHide() {
44+ r.now = r.now.Add(testHideAfter)
45+ r.cur.EXPECT().Hide().Once()
46+ r.fire()
47+}
48+
49+func TestCursorHidesWhenIdleAndShowsOnMotion(t *testing.T) {
50+ r := newIdleRig(t)
51+ r.idleHide()
52+ r.cur.EXPECT().Move(2.0, 2.0).Once()
53+ r.timer.EXPECT().Reset(testHideAfter).Return(false).Once()
54+ r.c.move("A", 2, 2, true)
55+}
56+
57+// A timer that fires while the pointer kept moving waits for the time left.
58+func TestCursorIdleTimerWaitsForTimeLeft(t *testing.T) {
59+ r := newIdleRig(t)
60+ r.now = r.now.Add(3 * time.Second)
61+ r.cur.EXPECT().Move(2.0, 2.0).Once()
62+ r.c.move("A", 2, 2, true)
63+ r.now = r.now.Add(2 * time.Second)
64+ r.timer.EXPECT().Reset(3 * time.Second).Return(false).Once()
65+ r.fire() // no Hide: only 2s since the last motion
66+}
67+
68+// Placing the pointer again after a layout or constraint change is not
69+// motion: it moves a visible cursor without delaying its hide, and never
70+// shows a hidden one.
71+func TestCursorResyncIsNotMotion(t *testing.T) {
72+ r := newIdleRig(t)
73+ r.now = r.now.Add(3 * time.Second)
74+ r.cur.EXPECT().Move(2.0, 2.0).Once()
75+ r.c.move("A", 2, 2, false)
76+ r.now = r.now.Add(2 * time.Second)
77+ r.cur.EXPECT().Hide().Once()
78+ r.fire() // 5s after the last motion, only 2s after the resync
79+ r.c.move("A", 3, 3, false)
80+}
81+
82+// An output that restarts while the cursor is idle-hidden keeps it hidden.
83+func TestCursorStaysHiddenOnOutputRestart(t *testing.T) {
84+ r := newIdleRig(t)
85+ r.idleHide()
86+ next := newMockcursor(t)
87+ next.EXPECT().Hide().Once()
88+ r.c.set("A", next)
89+}
90+
91+func TestCursorHideAfterReload(t *testing.T) {
92+ t.Run("visible", func(t *testing.T) {
93+ r := newIdleRig(t)
94+ r.timer.EXPECT().Stop().Return(true).Once()
95+ r.timer.EXPECT().Reset(10 * time.Second).Return(false).Once()
96+ r.c.setHideAfter(10 * time.Second)
97+ })
98+ t.Run("hidden", func(t *testing.T) {
99+ r := newIdleRig(t)
100+ r.idleHide()
101+ r.timer.EXPECT().Stop().Return(false).Once()
102+ r.c.setHideAfter(10 * time.Second) // no re-arm: nothing to hide
103+ next := newMockcursor(t)
104+ next.EXPECT().Hide().Once()
105+ r.c.set("A", next)
106+ })
107+ t.Run("off", func(t *testing.T) {
108+ r := newIdleRig(t)
109+ r.timer.EXPECT().Stop().Return(true).Once()
110+ r.c.setHideAfter(0)
111+ r.now = r.now.Add(time.Minute)
112+ r.fire() // a fire already under way hides nothing
113+ r.cur.EXPECT().Move(2.0, 2.0).Once()
114+ r.c.move("A", 2, 2, true) // and motion arms no timer
115+ })
116+}
117+
118+func TestCursorStopCancelsPendingHide(t *testing.T) {
119+ r := newIdleRig(t)
120+ r.timer.EXPECT().Stop().Return(true).Once()
121+ r.c.stop()
122+ r.now = r.now.Add(testHideAfter)
123+ r.fire() // no Hide
124+}
M internal/app/headless.go +1 -1
@@ -52,7 +52,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
5252 }
5353 apply.heads(inventory)
5454 // The pointer starts centred on the first output, like libinput's.
55- curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
55+ curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2, false)
5656 for {
5757 configs, configNext := apply.configOut(ch.configured), apply.config
5858 replies, replyNext := apply.replyOut(ch.replies)
@@ -52,7 +52,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
52 }52 }
53 apply.heads(inventory)53 apply.heads(inventory)
54 // The pointer starts centred on the first output, like libinput's.54 // The pointer starts centred on the first output, like libinput's.
55- curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)55+ curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2, false)
56 for {56 for {
57 configs, configNext := apply.configOut(ch.configured), apply.config57 configs, configNext := apply.configOut(ch.configured), apply.config
58 replies, replyNext := apply.replyOut(ch.replies)58 replies, replyNext := apply.replyOut(ch.replies)
M internal/app/outputs.go +97 -9
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "sync"
7+ "time"
78
89 "github.com/bnema/neferwl/internal/adapters/capture"
910
@@ -301,11 +302,22 @@ func (s *outputSet) wait() error {
301302 }
302303
303304 // cursors routes pointer moves from the input goroutine to the cursor of
304-// the output under the pointer and hides the others.
305+// the output under the pointer and hides the others. After hideAfter
306+// without motion it hides that cursor too; the next motion shows it.
305307 type cursors struct {
306- mu sync.Mutex
307- all map[string]cursor
308- last string
308+ mu sync.Mutex
309+ all map[string]cursor
310+ last string
311+ clock ports.Clock
312+ // hideAfter is 0 when the cursor never hides. lastMove is when the
313+ // latest motion came; armed tells the timer runs; hidden tells the
314+ // cursor is idle-hidden. The timer is not reset on every motion: when
315+ // it fires early it re-arms for the time left.
316+ hideAfter time.Duration
317+ lastMove time.Time
318+ timer ports.Timer
319+ armed bool
320+ hidden bool
309321 }
310322
311323 // cursor is a hardware or software cursor of one output.
@@ -314,7 +326,68 @@ type cursor interface {
314326 Hide()
315327 }
316328
317-func newCursors() *cursors { return &cursors{all: map[string]cursor{}} }
329+func newCursors(clock ports.Clock, hideAfter time.Duration) *cursors {
330+ return &cursors{all: map[string]cursor{}, clock: clock, hideAfter: hideAfter}
331+}
332+
333+// setHideAfter changes the idle delay; 0 never hides the cursor. A cursor
334+// already hidden stays hidden until the next motion.
335+func (c *cursors) setHideAfter(d time.Duration) {
336+ c.mu.Lock()
337+ defer c.mu.Unlock()
338+ if d == c.hideAfter {
339+ return
340+ }
341+ c.hideAfter = d
342+ c.disarm()
343+ if d > 0 && c.last != "" && !c.hidden {
344+ c.lastMove = c.clock.Now()
345+ c.arm(d)
346+ }
347+}
348+
349+// stop cancels a pending hide; the router is not used after.
350+func (c *cursors) stop() {
351+ c.mu.Lock()
352+ defer c.mu.Unlock()
353+ c.disarm()
354+}
355+
356+// disarm stops the idle timer. Callers hold mu.
357+func (c *cursors) disarm() {
358+ if c.timer != nil {
359+ c.timer.Stop()
360+ }
361+ c.armed = false
362+}
363+
364+// arm starts the idle timer for d. Callers hold mu.
365+func (c *cursors) arm(d time.Duration) {
366+ c.armed = true
367+ if c.timer == nil {
368+ c.timer = c.clock.AfterFunc(d, c.idle)
369+ return
370+ }
371+ c.timer.Reset(d)
372+}
373+
374+// idle hides the cursor once hideAfter passed since the last move, or
375+// re-arms the timer for the time left.
376+func (c *cursors) idle() {
377+ c.mu.Lock()
378+ defer c.mu.Unlock()
379+ if !c.armed || c.hideAfter <= 0 {
380+ return
381+ }
382+ if left := c.hideAfter - c.clock.Now().Sub(c.lastMove); left > 0 {
383+ c.timer.Reset(left)
384+ return
385+ }
386+ c.armed, c.hidden = false, true
387+ if cur := c.all[c.last]; cur != nil {
388+ cur.Hide()
389+ }
390+}
318391
319392 // set registers the cursor of an output; nil removes it.
320393 func (c *cursors) set(output string, cur cursor) {
@@ -326,16 +399,22 @@ func (c *cursors) set(output string, cur cursor) {
326399 }
327400 c.all[output] = cur
328401 // Only the output under the pointer shows it; before the first move,
329- // none does (a new cursor would show at its top-left corner).
330- if output != c.last {
402+ // none does (a new cursor would show at its top-left corner). An idle
403+ // hidden cursor stays hidden.
404+ if output != c.last || c.hidden {
331405 cur.Hide()
332406 }
333407 }
334408
335-// move places the cursor on output at physical (x, y).
336-func (c *cursors) move(output string, x, y float64) {
409+// move places the cursor on output at physical (x, y). motion is false
410+// when the pointer is only placed again (layout or constraint change): it
411+// neither shows an idle-hidden cursor nor delays the hide.
412+func (c *cursors) move(output string, x, y float64, motion bool) {
337413 c.mu.Lock()
338414 defer c.mu.Unlock()
415+ if motion {
416+ c.hidden = false
417+ }
339418 if output != c.last {
340419 for name, cur := range c.all {
341420 if name != output {
@@ -344,9 +423,18 @@ func (c *cursors) move(output string, x, y float64) {
344423 }
345424 c.last = output
346425 }
426+ if c.hidden {
427+ return // the next motion moves it with fresh coordinates
428+ }
347429 if cur := c.all[output]; cur != nil {
348430 cur.Move(x, y)
349431 }
432+ if c.hideAfter > 0 && (motion || !c.armed) {
433+ c.lastMove = c.clock.Now()
434+ if !c.armed {
435+ c.arm(c.hideAfter)
436+ }
437+ }
350438 }
351439
352440 // routeCapture transfers the request to its output or closes it on failure.
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "sync"6 "sync"
7+ "time"
7 8
8 "github.com/bnema/neferwl/internal/adapters/capture"9 "github.com/bnema/neferwl/internal/adapters/capture"
9 10
@@ -301,11 +302,22 @@ func (s *outputSet) wait() error {
301 }302 }
302 303
303 // cursors routes pointer moves from the input goroutine to the cursor of304 // cursors routes pointer moves from the input goroutine to the cursor of
304-// the output under the pointer and hides the others.305+// the output under the pointer and hides the others. After hideAfter
306+// without motion it hides that cursor too; the next motion shows it.
305 type cursors struct {307 type cursors struct {
306- mu sync.Mutex308+ mu sync.Mutex
307- all map[string]cursor309+ all map[string]cursor
308- last string310+ last string
311+ clock ports.Clock
312+ // hideAfter is 0 when the cursor never hides. lastMove is when the
313+ // latest motion came; armed tells the timer runs; hidden tells the
314+ // cursor is idle-hidden. The timer is not reset on every motion: when
315+ // it fires early it re-arms for the time left.
316+ hideAfter time.Duration
317+ lastMove time.Time
318+ timer ports.Timer
319+ armed bool
320+ hidden bool
309 }321 }
310 322
311 // cursor is a hardware or software cursor of one output.323 // cursor is a hardware or software cursor of one output.
@@ -314,7 +326,68 @@ type cursor interface {
314 Hide()326 Hide()
315 }327 }
316 328
317-func newCursors() *cursors { return &cursors{all: map[string]cursor{}} }329+func newCursors(clock ports.Clock, hideAfter time.Duration) *cursors {
330+ return &cursors{all: map[string]cursor{}, clock: clock, hideAfter: hideAfter}
331+}
332+
333+// setHideAfter changes the idle delay; 0 never hides the cursor. A cursor
334+// already hidden stays hidden until the next motion.
335+func (c *cursors) setHideAfter(d time.Duration) {
336+ c.mu.Lock()
337+ defer c.mu.Unlock()
338+ if d == c.hideAfter {
339+ return
340+ }
341+ c.hideAfter = d
342+ c.disarm()
343+ if d > 0 && c.last != "" && !c.hidden {
344+ c.lastMove = c.clock.Now()
345+ c.arm(d)
346+ }
347+}
348+
349+// stop cancels a pending hide; the router is not used after.
350+func (c *cursors) stop() {
351+ c.mu.Lock()
352+ defer c.mu.Unlock()
353+ c.disarm()
354+}
355+
356+// disarm stops the idle timer. Callers hold mu.
357+func (c *cursors) disarm() {
358+ if c.timer != nil {
359+ c.timer.Stop()
360+ }
361+ c.armed = false
362+}
363+
364+// arm starts the idle timer for d. Callers hold mu.
365+func (c *cursors) arm(d time.Duration) {
366+ c.armed = true
367+ if c.timer == nil {
368+ c.timer = c.clock.AfterFunc(d, c.idle)
369+ return
370+ }
371+ c.timer.Reset(d)
372+}
373+
374+// idle hides the cursor once hideAfter passed since the last move, or
375+// re-arms the timer for the time left.
376+func (c *cursors) idle() {
377+ c.mu.Lock()
378+ defer c.mu.Unlock()
379+ if !c.armed || c.hideAfter <= 0 {
380+ return
381+ }
382+ if left := c.hideAfter - c.clock.Now().Sub(c.lastMove); left > 0 {
383+ c.timer.Reset(left)
384+ return
385+ }
386+ c.armed, c.hidden = false, true
387+ if cur := c.all[c.last]; cur != nil {
388+ cur.Hide()
389+ }
390+}
318 391
319 // set registers the cursor of an output; nil removes it.392 // set registers the cursor of an output; nil removes it.
320 func (c *cursors) set(output string, cur cursor) {393 func (c *cursors) set(output string, cur cursor) {
@@ -326,16 +399,22 @@ func (c *cursors) set(output string, cur cursor) {
326 }399 }
327 c.all[output] = cur400 c.all[output] = cur
328 // Only the output under the pointer shows it; before the first move,401 // Only the output under the pointer shows it; before the first move,
329- // none does (a new cursor would show at its top-left corner).402+ // none does (a new cursor would show at its top-left corner). An idle
330- if output != c.last {403+ // hidden cursor stays hidden.
404+ if output != c.last || c.hidden {
331 cur.Hide()405 cur.Hide()
332 }406 }
333 }407 }
334 408
335-// move places the cursor on output at physical (x, y).409+// move places the cursor on output at physical (x, y). motion is false
336-func (c *cursors) move(output string, x, y float64) {410+// when the pointer is only placed again (layout or constraint change): it
411+// neither shows an idle-hidden cursor nor delays the hide.
412+func (c *cursors) move(output string, x, y float64, motion bool) {
337 c.mu.Lock()413 c.mu.Lock()
338 defer c.mu.Unlock()414 defer c.mu.Unlock()
415+ if motion {
416+ c.hidden = false
417+ }
339 if output != c.last {418 if output != c.last {
340 for name, cur := range c.all {419 for name, cur := range c.all {
341 if name != output {420 if name != output {
@@ -344,9 +423,18 @@ func (c *cursors) move(output string, x, y float64) {
344 }423 }
345 c.last = output424 c.last = output
346 }425 }
426+ if c.hidden {
427+ return // the next motion moves it with fresh coordinates
428+ }
347 if cur := c.all[output]; cur != nil {429 if cur := c.all[output]; cur != nil {
348 cur.Move(x, y)430 cur.Move(x, y)
349 }431 }
432+ if c.hideAfter > 0 && (motion || !c.armed) {
433+ c.lastMove = c.clock.Now()
434+ if !c.armed {
435+ c.arm(c.hideAfter)
436+ }
437+ }
350 }438 }
351 439
352 // routeCapture transfers the request to its output or closes it on failure.440 // routeCapture transfers the request to its output or closes it on failure.
M internal/ports/config.go +4 -1
@@ -111,7 +111,10 @@ type Config struct {
111111 Outputs []OutputLayout
112112 }
113113 Touchpad TouchpadConfig
114- Focus struct {
114+ // Cursor.HideAfter hides the pointer cursor after this long without
115+ // motion; the next motion shows it. 0 never hides it.
116+ Cursor struct{ HideAfter time.Duration }
117+ Focus struct {
115118 // FollowMove shows the target workspace after a column or window
116119 // moves to it.
117120 FollowMove bool
@@ -111,7 +111,10 @@ type Config struct {
111 Outputs []OutputLayout111 Outputs []OutputLayout
112 }112 }
113 Touchpad TouchpadConfig113 Touchpad TouchpadConfig
114- Focus struct {114+ // Cursor.HideAfter hides the pointer cursor after this long without
115+ // motion; the next motion shows it. 0 never hides it.
116+ Cursor struct{ HideAfter time.Duration }
117+ Focus struct {
115 // FollowMove shows the target workspace after a column or window118 // FollowMove shows the target workspace after a column or window
116 // moves to it.119 // moves to it.
117 FollowMove bool120 FollowMove bool

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 23

Tags 10

↑↓ moveenter openesc close