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 Unverified
B5690EEEBB952194The key is unknown
14 files · +418 -28
M .mockery.yml +5 -0
| @@ -67,6 +67,11 @@ packages: | |||
| 67 | 67 | dir: internal/app | |
| 68 | 68 | pkgname: app | |
| 69 | 69 | filename: securitylease_mock_test.go | |
| 70 | + | cursor: | |
| 71 | + | config: | |
| 72 | + | dir: internal/app | |
| 73 | + | pkgname: app | |
| 74 | + | filename: cursor_mock_test.go | |
| 70 | 75 | github.com/bnema/neferwl/internal/adapters/statefile: | |
| 71 | 76 | interfaces: | |
| 72 | 77 | procTree: | |
| @@ -67,6 +67,11 @@ packages: | |||||
| 67 | dir: internal/app | 67 | dir: internal/app | ||
| 68 | pkgname: app | 68 | pkgname: app | ||
| 69 | filename: securitylease_mock_test.go | 69 | 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`). | |||
| 38 | 38 | | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) | | |
| 39 | 39 | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | |
| 40 | 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 | 42 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 42 | 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 | 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 | | |
| @@ -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 | |||
| 57 | 57 | # Two-finger scroll distance multiplier. | |
| 58 | 58 | 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 | 64 | # on: moving a column or window to another workspace shows that workspace. | |
| 61 | 65 | focus.follow-move = off | |
| 62 | 66 | # 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 = 1 | 58 | 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 = off | 65 | 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 ( | |||
| 23 | 23 | // VRR panel (48 Hz: 20.8 ms). | |
| 24 | 24 | const maxVRRFlipGap = 10 * time.Millisecond | |
| 25 | 25 | ||
| 26 | + | // maxCursorHideAfter bounds cursor.hide-after. | |
| 27 | + | const maxCursorHideAfter = time.Hour | |
| 28 | + | ||
| 26 | 29 | // defaultBinds use the config syntax and go through the same parser as user binds. | |
| 27 | 30 | var defaultBinds = []struct{ combo, action string }{ | |
| 28 | 31 | {"cmd+return", "spawn-terminal"}, | |
| @@ -122,6 +125,7 @@ func Defaults() ports.Config { | |||
| 122 | 125 | c.Touchpad.Tap = true | |
| 123 | 126 | c.Touchpad.AccelProfile = ports.AccelAdaptive | |
| 124 | 127 | c.Touchpad.ScrollFactor = 1 | |
| 128 | + | c.Cursor.HideAfter = 5 * time.Second | |
| 125 | 129 | c.Focus.Animation = ports.FocusAnimationPulse | |
| 126 | 130 | c.Focus.Effect = ports.FocusEffectScreen | |
| 127 | 131 | c.Focus.Strength = 0.04 | |
| @@ -582,6 +586,16 @@ func set(c *ports.Config, key, v string) error { | |||
| 582 | 586 | return fmt.Errorf("must be above 0 and at most 10") | |
| 583 | 587 | } | |
| 584 | 588 | 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 | 599 | case "focus.follow-move": | |
| 586 | 600 | b, err := onOff(v) | |
| 587 | 601 | 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.Millisecond | 24 | 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 = true | 125 | c.Touchpad.Tap = true | ||
| 123 | c.Touchpad.AccelProfile = ports.AccelAdaptive | 126 | c.Touchpad.AccelProfile = ports.AccelAdaptive | ||
| 124 | c.Touchpad.ScrollFactor = 1 | 127 | c.Touchpad.ScrollFactor = 1 | ||
| 128 | + | c.Cursor.HideAfter = 5 * time.Second | |||
| 125 | c.Focus.Animation = ports.FocusAnimationPulse | 129 | c.Focus.Animation = ports.FocusAnimationPulse | ||
| 126 | c.Focus.Effect = ports.FocusEffectScreen | 130 | c.Focus.Effect = ports.FocusEffectScreen | ||
| 127 | c.Focus.Strength = 0.04 | 131 | 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 = f | 588 | 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) { | |||
| 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 | 559 | func TestFloatingDim(t *testing.T) { | |
| 539 | 560 | for _, tc := range []struct { | |
| 540 | 561 | 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 string | 561 | value string | ||
M internal/adapters/libinput/libinput.go +12 -10
| @@ -205,8 +205,9 @@ type Options struct { | |||
| 205 | 205 | Active <-chan bool | |
| 206 | 206 | // MoveCursor, when set, places the hardware cursor as soon as motion is | |
| 207 | 207 | // 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) | |
| 210 | 211 | Log zerowrap.Logger | |
| 211 | 212 | // LogMotion logs every pointer motion (--debug=input-motion); motions | |
| 212 | 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 | 245 | return fmt.Errorf("libinput_udev_assign_seat %s failed", name) | |
| 245 | 246 | } | |
| 246 | 247 | p := newPointer(opts.Layout) | |
| 247 | - | p.moved(opts.MoveCursor) | |
| 248 | + | p.moved(opts.MoveCursor, false) | |
| 248 | 249 | in := &inputState{touchpad: opts.Touchpad, devices: map[uintptr]bool{}} | |
| 249 | 250 | defer in.release() | |
| 250 | 251 | fd := getFD(li) | |
| @@ -278,10 +279,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 278 | 279 | opts.Log.Info().Msg("keymap replaced") | |
| 279 | 280 | case l := <-opts.Layouts: | |
| 280 | 281 | p.setLayout(l) | |
| 281 | - | p.moved(opts.MoveCursor) | |
| 282 | + | p.moved(opts.MoveCursor, false) | |
| 282 | 283 | case c := <-opts.Constraints: | |
| 283 | 284 | p.constrain(c) | |
| 284 | - | p.moved(opts.MoveCursor) | |
| 285 | + | p.moved(opts.MoveCursor, false) | |
| 285 | 286 | case t := <-opts.Touchpads: | |
| 286 | 287 | in.touchpad = t | |
| 287 | 288 | for dev := range in.devices { | |
| @@ -484,7 +485,7 @@ func translateEvent(ev uintptr, opts Options, p *pointer, in *inputState, state | |||
| 484 | 485 | m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe) | |
| 485 | 486 | m.TimeUsec = pointerUsec(pe) | |
| 486 | 487 | m.TimeMsec = msec(m.TimeUsec) | |
| 487 | - | p.moved(opts.MoveCursor) | |
| 488 | + | p.moved(opts.MoveCursor, true) | |
| 488 | 489 | if opts.LogMotion { | |
| 489 | 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 | 499 | return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil | |
| 499 | 500 | } | |
| 500 | 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 | 503 | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 503 | 504 | case evPointerButton: | |
| 504 | 505 | pe := pointerEvent(ev) | |
| @@ -683,9 +684,10 @@ func (p *pointer) bounds() ports.Rect { | |||
| 683 | 684 | return r | |
| 684 | 685 | } | |
| 685 | 686 | ||
| 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) { | |
| 688 | 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 | } | |
| @@ -205,8 +205,9 @@ type Options struct { | |||||
| 205 | Active <-chan bool | 205 | Active <-chan bool | ||
| 206 | // MoveCursor, when set, places the hardware cursor as soon as motion is | 206 | // MoveCursor, when set, places the hardware cursor as soon as motion is | ||
| 207 | // read, before core sees the event: the output under the pointer and | 207 | // 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.Logger | 211 | Log zerowrap.Logger | ||
| 211 | // LogMotion logs every pointer motion (--debug=input-motion); motions | 212 | // 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 = t | 287 | 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}, nil | 499 | 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}, nil | 503 | 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 r | 684 | 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) { | |||
| 132 | 132 | } | |
| 133 | 133 | var out string | |
| 134 | 134 | 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 | 136 | if out != "B" || px != 120 || py != 50 { | |
| 137 | 137 | t.Fatal(out, px, py) | |
| 138 | 138 | } | |
| @@ -132,7 +132,7 @@ func TestPointerAcrossOutputs(t *testing.T) { | |||||
| 132 | } | 132 | } | ||
| 133 | var out string | 133 | var out string | ||
| 134 | var px, py float64 | 134 | 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) | |||
| 201 | 201 | }() | |
| 202 | 202 | } | |
| 203 | 203 | filtered := make(chan ports.ConfigChanged, 8) | |
| 204 | + | curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter) | |
| 205 | + | defer curs.stop() | |
| 204 | 206 | workers.Add(1) | |
| 205 | 207 | go func() { | |
| 206 | 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 | 211 | script := make(chan string) | |
| 210 | - | curs := newCursors() | |
| 211 | 212 | go func() { | |
| 212 | 213 | defer workers.Done() | |
| 213 | 214 | if hw != nil { | |
| @@ -231,7 +232,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 231 | 232 | }) | |
| 232 | 233 | 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 | 236 | select { | |
| 236 | 237 | case done <- err: | |
| 237 | 238 | case <-ctx.Done(): | |
| @@ -352,8 +353,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 352 | 353 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | |
| 353 | 354 | // to the input goroutine and sends it to clients; a repeat-only change just updates | |
| 354 | 355 | // 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) { | |
| 357 | 359 | kb := cur.Keyboard | |
| 358 | 360 | tp := cur.Touchpad | |
| 359 | 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 | 395 | if ev.Config.Touchpad != tp { | |
| 393 | 396 | tp = ev.Config.Touchpad | |
| 394 | 397 | 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 | return | 233 | 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 it | 353 | // 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 updates | 354 | // 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 touchpad | 355 | // 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.Keyboard | 359 | kb := cur.Keyboard | ||
| 358 | tp := cur.Touchpad | 360 | 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.Touchpad | 396 | tp = ev.Config.Touchpad | ||
| 394 | select { | 397 | select { | ||
M internal/app/app_test.go +2 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "testing" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 15 | 16 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 16 | 17 | "github.com/bnema/neferwl/internal/adapters/xkb" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/logging" | |
| @@ -273,7 +274,7 @@ func TestRelayConfigKeyboard(t *testing.T) { | |||
| 273 | 274 | keymaps := make(chan *xkb.Keymap, 1) | |
| 274 | 275 | commands := make(chan ports.ClientCommand, 1) | |
| 275 | 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 | 279 | next := config.Defaults() | |
| 279 | 280 | 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 | |||
| 52 | 52 | } | |
| 53 | 53 | apply.heads(inventory) | |
| 54 | 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 | 56 | for { | |
| 57 | 57 | configs, configNext := apply.configOut(ch.configured), apply.config | |
| 58 | 58 | 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.config | 57 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "sync" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 9 | 10 | ||
| @@ -301,11 +302,22 @@ func (s *outputSet) wait() error { | |||
| 301 | 302 | } | |
| 302 | 303 | ||
| 303 | 304 | // 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 | 307 | 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 | |
| 309 | 321 | } | |
| 310 | 322 | ||
| 311 | 323 | // cursor is a hardware or software cursor of one output. | |
| @@ -314,7 +326,68 @@ type cursor interface { | |||
| 314 | 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 | 392 | // set registers the cursor of an output; nil removes it. | |
| 320 | 393 | func (c *cursors) set(output string, cur cursor) { | |
| @@ -326,16 +399,22 @@ func (c *cursors) set(output string, cur cursor) { | |||
| 326 | 399 | } | |
| 327 | 400 | c.all[output] = cur | |
| 328 | 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). | |
| 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 { | |
| 331 | 405 | cur.Hide() | |
| 332 | 406 | } | |
| 333 | 407 | } | |
| 334 | 408 | ||
| 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) { | |
| 337 | 413 | c.mu.Lock() | |
| 338 | 414 | defer c.mu.Unlock() | |
| 415 | + | if motion { | |
| 416 | + | c.hidden = false | |
| 417 | + | } | |
| 339 | 418 | if output != c.last { | |
| 340 | 419 | for name, cur := range c.all { | |
| 341 | 420 | if name != output { | |
| @@ -344,9 +423,18 @@ func (c *cursors) move(output string, x, y float64) { | |||
| 344 | 423 | } | |
| 345 | 424 | c.last = output | |
| 346 | 425 | } | |
| 426 | + | if c.hidden { | |
| 427 | + | return // the next motion moves it with fresh coordinates | |
| 428 | + | } | |
| 347 | 429 | if cur := c.all[output]; cur != nil { | |
| 348 | 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 | 440 | // 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 of | 304 | // 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.Mutex | 308 | + | mu sync.Mutex |
| 307 | - | all map[string]cursor | 309 | + | all map[string]cursor |
| 308 | - | last string | 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 | |||
| 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] = cur | 400 | 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 = output | 424 | 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 { | |||
| 111 | 111 | Outputs []OutputLayout | |
| 112 | 112 | } | |
| 113 | 113 | 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 | 118 | // FollowMove shows the target workspace after a column or window | |
| 116 | 119 | // moves to it. | |
| 117 | 120 | FollowMove bool | |
| @@ -111,7 +111,10 @@ type Config struct { | |||||
| 111 | Outputs []OutputLayout | 111 | Outputs []OutputLayout | ||
| 112 | } | 112 | } | ||
| 113 | Touchpad TouchpadConfig | 113 | 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 window | 118 | // FollowMove shows the target workspace after a column or window | ||
| 116 | // moves to it. | 119 | // moves to it. | ||
| 117 | FollowMove bool | 120 | FollowMove bool | ||