feat(core): show per-monitor position hint dots on band and workspace changes
A bottom-centered dot grid appears for 600ms then fades on the output whose workspace, cascade band or monitor focus changed. It follows the overview axis, scales with the output, stays out of captures, damages only its rect and allocates nothing in steady state. Configured by focus.position-hint (default on).
- commit
- 392b5b6bc2ae798098eff7bbbedc5982777f5af4
- author
- brice · · 2026-10-19 20:23 UTC
- parent
- d5d679d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +765 -11
M docs/config.md +8 -0
| @@ -50,6 +50,7 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 50 | 50 | | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers | | |
| 51 | 51 | | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` | | |
| 52 | 52 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 53 | + | | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured | | |
| 53 | 54 | | `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed | | |
| 54 | 55 | | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` | | |
| 55 | 56 | | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` | | |
| @@ -83,6 +84,13 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 83 | 84 | ||
| 84 | 85 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | |
| 85 | 86 | ||
| 87 | + | The position hint follows actual workspace/band changes and monitor focus, regardless | |
| 88 | + | of input method. It lists all ext-workspace entries in monitor order: numbered | |
| 89 | + | workspaces (including the empty spare), then configured hidden named workspaces, | |
| 90 | + | even when empty. Cascade workspaces use top-aligned band columns; scroll/fixed | |
| 91 | + | workspaces use one vertical workspace column. Large lists show at most 16 entries | |
| 92 | + | with the active entry kept visible; band rows are capped at 16. | |
| 93 | + | ||
| 86 | 94 | The focus flash marks the window, column or output an action changes with a brief | |
| 87 | 95 | inner border. When a flash-triggering action crosses screens, it marks the whole | |
| 88 | 96 | destination output, even if that output has no windows. Same-screen workspace | |
| @@ -50,6 +50,7 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 50 | | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers | | 50 | | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers | | ||
| 51 | | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` | | 51 | | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` | | ||
| 52 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 52 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 53 | + | | `focus.position-hint` | `on` | Show workspace/band dots near the output bottom for 600ms after a position change, then fade for 150ms (no fade with animations off); hidden in overview, fullscreen and session lock; never captured | | |||
| 53 | | `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed | | 54 | | `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed | | ||
| 54 | | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` | | 55 | | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` | | ||
| 55 | | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` | | 56 | | `focus.flash-opacity` | `0.6` | Starting opacity of the flash, `0.1` to `1` | | ||
| @@ -83,6 +84,13 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 83 | 84 | ||||
| 84 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | 85 | `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`). | ||
| 85 | 86 | ||||
| 87 | + | The position hint follows actual workspace/band changes and monitor focus, regardless | |||
| 88 | + | of input method. It lists all ext-workspace entries in monitor order: numbered | |||
| 89 | + | workspaces (including the empty spare), then configured hidden named workspaces, | |||
| 90 | + | even when empty. Cascade workspaces use top-aligned band columns; scroll/fixed | |||
| 91 | + | workspaces use one vertical workspace column. Large lists show at most 16 entries | |||
| 92 | + | with the active entry kept visible; band rows are capped at 16. | |||
| 93 | + | ||||
| 86 | The focus flash marks the window, column or output an action changes with a brief | 94 | The focus flash marks the window, column or output an action changes with a brief | ||
| 87 | inner border. When a flash-triggering action crosses screens, it marks the whole | 95 | inner border. When a flash-triggering action crosses screens, it marks the whole | ||
| 88 | destination output, even if that output has no windows. Same-screen workspace | 96 | destination output, even if that output has no windows. Same-screen workspace | ||
M internal/adapters/config/config.go +7 -0
| @@ -188,6 +188,7 @@ func Defaults() ports.Config { | |||
| 188 | 188 | // Animations are experimental: off unless the config turns them on. | |
| 189 | 189 | c.Animations.On = false | |
| 190 | 190 | c.Animations.Slowdown = normalSlowdown | |
| 191 | + | c.Focus.PositionHint = true | |
| 191 | 192 | c.Focus.Flash = true | |
| 192 | 193 | c.Focus.FlashWidth = 2 | |
| 193 | 194 | c.Focus.FlashOpacity = 0.6 | |
| @@ -845,6 +846,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 845 | 846 | return err | |
| 846 | 847 | } | |
| 847 | 848 | c.Focus.FollowMove = b | |
| 849 | + | case "focus.position-hint": | |
| 850 | + | b, err := onOff(v) | |
| 851 | + | if err != nil { | |
| 852 | + | return err | |
| 853 | + | } | |
| 854 | + | c.Focus.PositionHint = b | |
| 848 | 855 | case "focus.flash": | |
| 849 | 856 | b, err := onOff(v) | |
| 850 | 857 | if err != nil { | |
| @@ -188,6 +188,7 @@ func Defaults() ports.Config { | |||||
| 188 | // Animations are experimental: off unless the config turns them on. | 188 | // Animations are experimental: off unless the config turns them on. | ||
| 189 | c.Animations.On = false | 189 | c.Animations.On = false | ||
| 190 | c.Animations.Slowdown = normalSlowdown | 190 | c.Animations.Slowdown = normalSlowdown | ||
| 191 | + | c.Focus.PositionHint = true | |||
| 191 | c.Focus.Flash = true | 192 | c.Focus.Flash = true | ||
| 192 | c.Focus.FlashWidth = 2 | 193 | c.Focus.FlashWidth = 2 | ||
| 193 | c.Focus.FlashOpacity = 0.6 | 194 | c.Focus.FlashOpacity = 0.6 | ||
| @@ -845,6 +846,12 @@ func set(c *ports.Config, key, v string) error { | |||||
| 845 | return err | 846 | return err | ||
| 846 | } | 847 | } | ||
| 847 | c.Focus.FollowMove = b | 848 | c.Focus.FollowMove = b | ||
| 849 | + | case "focus.position-hint": | |||
| 850 | + | b, err := onOff(v) | |||
| 851 | + | if err != nil { | |||
| 852 | + | return err | |||
| 853 | + | } | |||
| 854 | + | c.Focus.PositionHint = b | |||
| 848 | case "focus.flash": | 855 | case "focus.flash": | ||
| 849 | b, err := onOff(v) | 856 | b, err := onOff(v) | ||
| 850 | if err != nil { | 857 | if err != nil { | ||
M internal/adapters/config/config_test.go +18 -0
| @@ -1045,3 +1045,21 @@ func TestHintEnable(t *testing.T) { | |||
| 1045 | 1045 | t.Fatal(c.Hint, w) | |
| 1046 | 1046 | } | |
| 1047 | 1047 | } | |
| 1048 | + | ||
| 1049 | + | func TestPositionHint(t *testing.T) { | |
| 1050 | + | if !Defaults().Focus.PositionHint { | |
| 1051 | + | t.Fatal("position hint default off") | |
| 1052 | + | } | |
| 1053 | + | for _, value := range []string{"on", "off", "invalid"} { | |
| 1054 | + | c, warnings := parseString(t, "focus.position-hint = "+value+"\n") | |
| 1055 | + | if value == "invalid" { | |
| 1056 | + | if len(warnings) == 0 { | |
| 1057 | + | t.Fatal("accepted invalid value") | |
| 1058 | + | } | |
| 1059 | + | continue | |
| 1060 | + | } | |
| 1061 | + | if len(warnings) != 0 || c.Focus.PositionHint != (value == "on") { | |
| 1062 | + | t.Fatalf("%s: %+v %v", value, c.Focus, warnings) | |
| 1063 | + | } | |
| 1064 | + | } | |
| 1065 | + | } | |
| @@ -1045,3 +1045,21 @@ func TestHintEnable(t *testing.T) { | |||||
| 1045 | t.Fatal(c.Hint, w) | 1045 | t.Fatal(c.Hint, w) | ||
| 1046 | } | 1046 | } | ||
| 1047 | } | 1047 | } | ||
| 1048 | + | ||||
| 1049 | + | func TestPositionHint(t *testing.T) { | |||
| 1050 | + | if !Defaults().Focus.PositionHint { | |||
| 1051 | + | t.Fatal("position hint default off") | |||
| 1052 | + | } | |||
| 1053 | + | for _, value := range []string{"on", "off", "invalid"} { | |||
| 1054 | + | c, warnings := parseString(t, "focus.position-hint = "+value+"\n") | |||
| 1055 | + | if value == "invalid" { | |||
| 1056 | + | if len(warnings) == 0 { | |||
| 1057 | + | t.Fatal("accepted invalid value") | |||
| 1058 | + | } | |||
| 1059 | + | continue | |||
| 1060 | + | } | |||
| 1061 | + | if len(warnings) != 0 || c.Focus.PositionHint != (value == "on") { | |||
| 1062 | + | t.Fatalf("%s: %+v %v", value, c.Focus, warnings) | |||
| 1063 | + | } | |||
| 1064 | + | } | |||
| 1065 | + | } | |||
M internal/adapters/drm/overlay.go +3 -0
| @@ -67,6 +67,9 @@ type overlayWin struct { | |||
| 67 | 67 | // composed, never put on the plane. | |
| 68 | 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | |
| 69 | 69 | // A hardware plane would bypass the compositor-owned flash border. | |
| 70 | + | if s.PositionHint.Shown() { | |
| 71 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "position_hint" | |
| 72 | + | } | |
| 70 | 73 | if s.Flash != nil { | |
| 71 | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | |
| 72 | 75 | } | |
| @@ -67,6 +67,9 @@ type overlayWin struct { | |||||
| 67 | // composed, never put on the plane. | 67 | // composed, never put on the plane. | ||
| 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | 68 | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | ||
| 69 | // A hardware plane would bypass the compositor-owned flash border. | 69 | // A hardware plane would bypass the compositor-owned flash border. | ||
| 70 | + | if s.PositionHint.Shown() { | |||
| 71 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "position_hint" | |||
| 72 | + | } | |||
| 70 | if s.Flash != nil { | 73 | if s.Flash != nil { | ||
| 71 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | 74 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash" | ||
| 72 | } | 75 | } | ||
M internal/adapters/drm/scanout.go +3 -0
| @@ -91,6 +91,9 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 91 | 91 | // it is never put on a plane (nil: none is). | |
| 92 | 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | |
| 93 | 93 | // A hardware plane would bypass the compositor-owned flash border. | |
| 94 | + | if s.PositionHint.Shown() { | |
| 95 | + | return ports.SurfaceContent{}, "position_hint" | |
| 96 | + | } | |
| 94 | 97 | if s.Flash != nil { | |
| 95 | 98 | return ports.SurfaceContent{}, "focus_flash" | |
| 96 | 99 | } | |
| @@ -91,6 +91,9 @@ func fullscreenShown(s *ports.Scene) bool { | |||||
| 91 | // it is never put on a plane (nil: none is). | 91 | // it is never put on a plane (nil: none is). | ||
| 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | 92 | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | ||
| 93 | // A hardware plane would bypass the compositor-owned flash border. | 93 | // A hardware plane would bypass the compositor-owned flash border. | ||
| 94 | + | if s.PositionHint.Shown() { | |||
| 95 | + | return ports.SurfaceContent{}, "position_hint" | |||
| 96 | + | } | |||
| 94 | if s.Flash != nil { | 97 | if s.Flash != nil { | ||
| 95 | return ports.SurfaceContent{}, "focus_flash" | 98 | return ports.SurfaceContent{}, "focus_flash" | ||
| 96 | } | 99 | } | ||
M internal/adapters/outputkit/capture/excluded.go +3 -2
| @@ -68,6 +68,7 @@ func excludedShown(s ports.Scene) bool { | |||
| 68 | 68 | // renderer redraws the whole target instead of reusing a displayed frame. | |
| 69 | 69 | func plain(s ports.Scene) ports.Scene { | |
| 70 | 70 | s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0 | |
| 71 | + | s.PositionHint = ports.PositionHint{} | |
| 71 | 72 | return s | |
| 72 | 73 | } | |
| 73 | 74 | ||
| @@ -85,7 +86,7 @@ func plain(s ports.Scene) ports.Scene { | |||
| 85 | 86 | // the plain render reads client buffers even when the requests are rejected, | |
| 86 | 87 | // so the caller owns it (hold or wait it) whatever happened, error included. | |
| 87 | 88 | func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) { | |
| 88 | - | if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 { | |
| 89 | + | if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 && !s.PositionHint.Shown() { | |
| 89 | 90 | return false, nil, nil | |
| 90 | 91 | } | |
| 91 | 92 | done, err = r.Render(plain(s), surfaces) | |
| @@ -164,7 +165,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex | |||
| 164 | 165 | } | |
| 165 | 166 | clear(reqs[n:]) | |
| 166 | 167 | reqs = reqs[:n] | |
| 167 | - | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 | |
| 168 | + | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 || s.PositionHint.Shown() | |
| 168 | 169 | group := func(q ports.CaptureRequest) int { | |
| 169 | 170 | switch { | |
| 170 | 171 | case q.OffScreen: | |
| @@ -68,6 +68,7 @@ func excludedShown(s ports.Scene) bool { | |||||
| 68 | // renderer redraws the whole target instead of reusing a displayed frame. | 68 | // renderer redraws the whole target instead of reusing a displayed frame. | ||
| 69 | func plain(s ports.Scene) ports.Scene { | 69 | func plain(s ports.Scene) ports.Scene { | ||
| 70 | s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0 | 70 | s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0 | ||
| 71 | + | s.PositionHint = ports.PositionHint{} | |||
| 71 | return s | 72 | return s | ||
| 72 | } | 73 | } | ||
| 73 | 74 | ||||
| @@ -85,7 +86,7 @@ func plain(s ports.Scene) ports.Scene { | |||||
| 85 | // the plain render reads client buffers even when the requests are rejected, | 86 | // the plain render reads client buffers even when the requests are rejected, | ||
| 86 | // so the caller owns it (hold or wait it) whatever happened, error included. | 87 | // so the caller owns it (hold or wait it) whatever happened, error included. | ||
| 87 | func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) { | 88 | func (p *Pipeline) SubmitPlain(r ports.Renderer, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, normal []ports.CaptureRequest) (took bool, done *os.File, err error) { | ||
| 88 | - | if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 { | 89 | + | if len(normal) == 0 || len(s.CaptureIndicators) == 0 && len(s.HintLabels) == 0 && !s.PositionHint.Shown() { |
| 89 | return false, nil, nil | 90 | return false, nil, nil | ||
| 90 | } | 91 | } | ||
| 91 | done, err = r.Render(plain(s), surfaces) | 92 | done, err = r.Render(plain(s), surfaces) | ||
| @@ -164,7 +165,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex | |||||
| 164 | } | 165 | } | ||
| 165 | clear(reqs[n:]) | 166 | clear(reqs[n:]) | ||
| 166 | reqs = reqs[:n] | 167 | reqs = reqs[:n] | ||
| 167 | - | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 | 168 | + | excl := excludedShown(s) || len(s.CaptureIndicators) > 0 || len(s.HintLabels) > 0 || s.PositionHint.Shown() |
| 168 | group := func(q ports.CaptureRequest) int { | 169 | group := func(q ports.CaptureRequest) int { | ||
| 169 | switch { | 170 | switch { | ||
| 170 | case q.OffScreen: | 171 | case q.OffScreen: | ||
M internal/adapters/outputkit/capture/excluded_test.go +12 -0
| @@ -195,3 +195,15 @@ func TestSplitWindowFailsClosed(t *testing.T) { | |||
| 195 | 195 | require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved) | |
| 196 | 196 | } | |
| 197 | 197 | } | |
| 198 | + | ||
| 199 | + | func TestPositionHintRemovedFromPlainCapture(t *testing.T) { | |
| 200 | + | s := ports.Scene{PositionHint: ports.PositionHint{Opacity: 1, Columns: 1, Counts: [16]uint8{1}}} | |
| 201 | + | require.True(t, s.PositionHint.Shown()) | |
| 202 | + | require.False(t, plain(s).PositionHint.Shown()) | |
| 203 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 204 | + | defer p.Close(nil) | |
| 205 | + | s.Capture = &ports.SceneCapture{Session: 7} | |
| 206 | + | normal, excluded, _ := p.Split(s, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}}) | |
| 207 | + | require.Empty(t, normal) | |
| 208 | + | require.Len(t, excluded, 1) | |
| 209 | + | } | |
| @@ -195,3 +195,15 @@ func TestSplitWindowFailsClosed(t *testing.T) { | |||||
| 195 | require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved) | 195 | require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved) | ||
| 196 | } | 196 | } | ||
| 197 | } | 197 | } | ||
| 198 | + | ||||
| 199 | + | func TestPositionHintRemovedFromPlainCapture(t *testing.T) { | |||
| 200 | + | s := ports.Scene{PositionHint: ports.PositionHint{Opacity: 1, Columns: 1, Counts: [16]uint8{1}}} | |||
| 201 | + | require.True(t, s.PositionHint.Shown()) | |||
| 202 | + | require.False(t, plain(s).PositionHint.Shown()) | |||
| 203 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |||
| 204 | + | defer p.Close(nil) | |||
| 205 | + | s.Capture = &ports.SceneCapture{Session: 7} | |||
| 206 | + | normal, excluded, _ := p.Split(s, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}}) | |||
| 207 | + | require.Empty(t, normal) | |||
| 208 | + | require.Len(t, excluded, 1) | |||
| 209 | + | } | |||
M internal/adapters/vulkan/damage.go +8 -0
| @@ -288,6 +288,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||
| 288 | 288 | return image.Rectangle{}, false | |
| 289 | 289 | } | |
| 290 | 290 | var area image.Rectangle | |
| 291 | + | if old.PositionHint != cur.PositionHint { | |
| 292 | + | if old.PositionHint.Shown() { | |
| 293 | + | area = area.Union(phys(old.PositionHint.Bounds(old.OutputWidth, old.OutputHeight))) | |
| 294 | + | } | |
| 295 | + | if cur.PositionHint.Shown() { | |
| 296 | + | area = area.Union(phys(cur.PositionHint.Bounds(cur.OutputWidth, cur.OutputHeight))) | |
| 297 | + | } | |
| 298 | + | } | |
| 291 | 299 | if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) { | |
| 292 | 300 | if old.Flash != nil { | |
| 293 | 301 | area = area.Union(phys(old.Flash.Rect)) | |
| @@ -288,6 +288,14 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||||
| 288 | return image.Rectangle{}, false | 288 | return image.Rectangle{}, false | ||
| 289 | } | 289 | } | ||
| 290 | var area image.Rectangle | 290 | var area image.Rectangle | ||
| 291 | + | if old.PositionHint != cur.PositionHint { | |||
| 292 | + | if old.PositionHint.Shown() { | |||
| 293 | + | area = area.Union(phys(old.PositionHint.Bounds(old.OutputWidth, old.OutputHeight))) | |||
| 294 | + | } | |||
| 295 | + | if cur.PositionHint.Shown() { | |||
| 296 | + | area = area.Union(phys(cur.PositionHint.Bounds(cur.OutputWidth, cur.OutputHeight))) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 291 | if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) { | 299 | if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) { | ||
| 292 | if old.Flash != nil { | 300 | if old.Flash != nil { | ||
| 293 | area = area.Union(phys(old.Flash.Rect)) | 301 | area = area.Union(phys(old.Flash.Rect)) | ||
M internal/adapters/vulkan/damage_delta_test.go +1 -1
| @@ -19,7 +19,7 @@ import ( | |||
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | 20 | // 25 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | 21 | // schedules the next frame, it draws nothing. | |
| 22 | - | const fields = 25 | |
| 22 | + | const fields = 26 | |
| 23 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 24 | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |
| 25 | 25 | } | |
| @@ -19,7 +19,7 @@ import ( | |||||
| 19 | func TestSceneFieldCount(t *testing.T) { | 19 | func TestSceneFieldCount(t *testing.T) { | ||
| 20 | // 25 with Animating, which sceneDelta intentionally ignores: it only | 20 | // 25 with Animating, which sceneDelta intentionally ignores: it only | ||
| 21 | // schedules the next frame, it draws nothing. | 21 | // schedules the next frame, it draws nothing. | ||
| 22 | - | const fields = 25 | 22 | + | const fields = 26 |
| 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | ||
| 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | ||
| 25 | } | 25 | } | ||
A internal/adapters/vulkan/position_hint.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func (w *sceneWalk) positionHint() { | |
| 11 | + | h := w.s.PositionHint | |
| 12 | + | if !h.Shown() { | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | b := h.Bounds(w.s.OutputWidth, w.s.OutputHeight) | |
| 16 | + | alpha := w.alpha | |
| 17 | + | w.alpha = h.Opacity * 0.8 | |
| 18 | + | w.hintRound(w.physRect(b.X, b.Y, b.W, b.H), w.phys(ports.PositionHintRadius), [3]uint8{30, 30, 38}) | |
| 19 | + | active := parseColor(w.s.Border.Active) | |
| 20 | + | for col := range min(int(h.Columns), ports.PositionHintMaxColumns) { | |
| 21 | + | for row := range min(int(h.Counts[col]), ports.PositionHintMaxRows) { | |
| 22 | + | color, opacity := [3]uint8{190, 190, 200}, float32(0.3) | |
| 23 | + | if !h.Vertical && col == int(h.ActiveCol) { | |
| 24 | + | opacity = 0.65 | |
| 25 | + | } | |
| 26 | + | if col == int(h.ActiveCol) && row == int(h.ActiveRow) { | |
| 27 | + | color, opacity = active, 1 | |
| 28 | + | } | |
| 29 | + | w.alpha = h.Opacity * opacity | |
| 30 | + | x := b.X + ports.PositionHintPadding + col*(ports.PositionHintDiameter+ports.PositionHintGap) | |
| 31 | + | y := b.Y + ports.PositionHintPadding + row*(ports.PositionHintDiameter+ports.PositionHintGap) | |
| 32 | + | w.hintRound(w.physRect(x, y, ports.PositionHintDiameter, ports.PositionHintDiameter), w.phys(ports.PositionHintDiameter)/2, color) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | w.alpha = alpha | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Rounded fills use the existing solid instances and batching pipeline. Each | |
| 39 | + | // physical scanline is bounded by the scaled radius; no textures or per-frame | |
| 40 | + | // buffers are allocated. Non-overlapping strips preserve translucent alpha. | |
| 41 | + | func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) { | |
| 42 | + | radius = min(radius, r.Dx()/2, r.Dy()/2) | |
| 43 | + | if radius <= 0 { | |
| 44 | + | w.fill(r, color) | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | w.fill(image.Rect(r.Min.X, r.Min.Y+radius, r.Max.X, r.Max.Y-radius), color) | |
| 48 | + | for y := range radius { | |
| 49 | + | dy := float64(radius-y) - 0.5 | |
| 50 | + | inset := radius - int(math.Sqrt(float64(radius*radius)-dy*dy)+0.5) | |
| 51 | + | w.fill(image.Rect(r.Min.X+inset, r.Min.Y+y, r.Max.X-inset, r.Min.Y+y+1), color) | |
| 52 | + | w.fill(image.Rect(r.Min.X+inset, r.Max.Y-y-1, r.Max.X-inset, r.Max.Y-y), color) | |
| 53 | + | } | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func (w *sceneWalk) positionHint() { | |||
| 11 | + | h := w.s.PositionHint | |||
| 12 | + | if !h.Shown() { | |||
| 13 | + | return | |||
| 14 | + | } | |||
| 15 | + | b := h.Bounds(w.s.OutputWidth, w.s.OutputHeight) | |||
| 16 | + | alpha := w.alpha | |||
| 17 | + | w.alpha = h.Opacity * 0.8 | |||
| 18 | + | w.hintRound(w.physRect(b.X, b.Y, b.W, b.H), w.phys(ports.PositionHintRadius), [3]uint8{30, 30, 38}) | |||
| 19 | + | active := parseColor(w.s.Border.Active) | |||
| 20 | + | for col := range min(int(h.Columns), ports.PositionHintMaxColumns) { | |||
| 21 | + | for row := range min(int(h.Counts[col]), ports.PositionHintMaxRows) { | |||
| 22 | + | color, opacity := [3]uint8{190, 190, 200}, float32(0.3) | |||
| 23 | + | if !h.Vertical && col == int(h.ActiveCol) { | |||
| 24 | + | opacity = 0.65 | |||
| 25 | + | } | |||
| 26 | + | if col == int(h.ActiveCol) && row == int(h.ActiveRow) { | |||
| 27 | + | color, opacity = active, 1 | |||
| 28 | + | } | |||
| 29 | + | w.alpha = h.Opacity * opacity | |||
| 30 | + | x := b.X + ports.PositionHintPadding + col*(ports.PositionHintDiameter+ports.PositionHintGap) | |||
| 31 | + | y := b.Y + ports.PositionHintPadding + row*(ports.PositionHintDiameter+ports.PositionHintGap) | |||
| 32 | + | w.hintRound(w.physRect(x, y, ports.PositionHintDiameter, ports.PositionHintDiameter), w.phys(ports.PositionHintDiameter)/2, color) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | w.alpha = alpha | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Rounded fills use the existing solid instances and batching pipeline. Each | |||
| 39 | + | // physical scanline is bounded by the scaled radius; no textures or per-frame | |||
| 40 | + | // buffers are allocated. Non-overlapping strips preserve translucent alpha. | |||
| 41 | + | func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) { | |||
| 42 | + | radius = min(radius, r.Dx()/2, r.Dy()/2) | |||
| 43 | + | if radius <= 0 { | |||
| 44 | + | w.fill(r, color) | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | w.fill(image.Rect(r.Min.X, r.Min.Y+radius, r.Max.X, r.Max.Y-radius), color) | |||
| 48 | + | for y := range radius { | |||
| 49 | + | dy := float64(radius-y) - 0.5 | |||
| 50 | + | inset := radius - int(math.Sqrt(float64(radius*radius)-dy*dy)+0.5) | |||
| 51 | + | w.fill(image.Rect(r.Min.X+inset, r.Min.Y+y, r.Max.X-inset, r.Min.Y+y+1), color) | |||
| 52 | + | w.fill(image.Rect(r.Min.X+inset, r.Max.Y-y-1, r.Max.X-inset, r.Max.Y-y), color) | |||
| 53 | + | } | |||
| 54 | + | } | |||
A internal/adapters/vulkan/position_hint_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "github.com/stretchr/testify/require" | |
| 6 | + | "image" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestPositionHintGeometryDamageAllocations(t *testing.T) { | |
| 11 | + | for _, scale := range []float64{1, 1.5, 2} { | |
| 12 | + | r := &Renderer{width: int(640 * scale), height: int(480 * scale)} | |
| 13 | + | s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: scale, Border: ports.Border{Active: "#abcdef"}, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}, ActiveRow: 1}} | |
| 14 | + | dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height)) | |
| 15 | + | draws := r.draws(s, nil, dmg) | |
| 16 | + | require.NotEmpty(t, draws) | |
| 17 | + | w := sceneWalk{scale: scale} | |
| 18 | + | b := s.PositionHint.Bounds(640, 480) | |
| 19 | + | want := w.physRect(b.X, b.Y, b.W, b.H) | |
| 20 | + | var area image.Rectangle | |
| 21 | + | for _, d := range draws { | |
| 22 | + | area = area.Union(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))) | |
| 23 | + | } | |
| 24 | + | require.Equal(t, want, area) | |
| 25 | + | require.Zero(t, testing.AllocsPerRun(100, func() { r.draws(s, nil, dmg) })) | |
| 26 | + | old := s | |
| 27 | + | old.PositionHint = ports.PositionHint{} | |
| 28 | + | phys := func(b ports.Rect) image.Rectangle { return w.physRect(b.X, b.Y, b.W, b.H) } | |
| 29 | + | got, ok := sceneDelta(old, s, phys, r.width*r.height) | |
| 30 | + | require.True(t, ok) | |
| 31 | + | require.Equal(t, want, got) | |
| 32 | + | s.PositionHint.Opacity = 0.5 | |
| 33 | + | got, ok = sceneDelta(old, s, phys, r.width*r.height) | |
| 34 | + | require.True(t, ok) | |
| 35 | + | require.Equal(t, want, got) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestPositionHintDamageTransitions(t *testing.T) { | |
| 40 | + | base := ports.Scene{Scale: 1, OutputWidth: 640, OutputHeight: 480, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}}} | |
| 41 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |
| 42 | + | bounds := func(s ports.Scene) image.Rectangle { return phys(s.PositionHint.Bounds(s.OutputWidth, s.OutputHeight)) } | |
| 43 | + | t.Run("disappear", func(t *testing.T) { | |
| 44 | + | cur := base | |
| 45 | + | cur.PositionHint = ports.PositionHint{} | |
| 46 | + | got, ok := sceneDelta(base, cur, phys, 640*480) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, bounds(base), got) | |
| 49 | + | }) | |
| 50 | + | t.Run("shape", func(t *testing.T) { | |
| 51 | + | cur := base | |
| 52 | + | cur.PositionHint.Columns = 3 | |
| 53 | + | cur.PositionHint.Counts[2] = 4 | |
| 54 | + | got, ok := sceneDelta(base, cur, phys, 640*480) | |
| 55 | + | require.True(t, ok) | |
| 56 | + | require.Equal(t, bounds(base).Union(bounds(cur)), got) | |
| 57 | + | }) | |
| 58 | + | t.Run("fade", func(t *testing.T) { | |
| 59 | + | cur := base | |
| 60 | + | cur.PositionHint.Opacity = 0.5 | |
| 61 | + | got, ok := sceneDelta(base, cur, phys, 640*480) | |
| 62 | + | require.True(t, ok) | |
| 63 | + | require.Equal(t, bounds(base), got) | |
| 64 | + | }) | |
| 65 | + | t.Run("scale", func(t *testing.T) { | |
| 66 | + | cur := base | |
| 67 | + | cur.Scale = 2 | |
| 68 | + | _, ok := sceneDelta(base, cur, phys, 640*480) | |
| 69 | + | require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately") | |
| 70 | + | }) | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "github.com/stretchr/testify/require" | |||
| 6 | + | "image" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestPositionHintGeometryDamageAllocations(t *testing.T) { | |||
| 11 | + | for _, scale := range []float64{1, 1.5, 2} { | |||
| 12 | + | r := &Renderer{width: int(640 * scale), height: int(480 * scale)} | |||
| 13 | + | s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: scale, Border: ports.Border{Active: "#abcdef"}, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}, ActiveRow: 1}} | |||
| 14 | + | dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height)) | |||
| 15 | + | draws := r.draws(s, nil, dmg) | |||
| 16 | + | require.NotEmpty(t, draws) | |||
| 17 | + | w := sceneWalk{scale: scale} | |||
| 18 | + | b := s.PositionHint.Bounds(640, 480) | |||
| 19 | + | want := w.physRect(b.X, b.Y, b.W, b.H) | |||
| 20 | + | var area image.Rectangle | |||
| 21 | + | for _, d := range draws { | |||
| 22 | + | area = area.Union(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))) | |||
| 23 | + | } | |||
| 24 | + | require.Equal(t, want, area) | |||
| 25 | + | require.Zero(t, testing.AllocsPerRun(100, func() { r.draws(s, nil, dmg) })) | |||
| 26 | + | old := s | |||
| 27 | + | old.PositionHint = ports.PositionHint{} | |||
| 28 | + | phys := func(b ports.Rect) image.Rectangle { return w.physRect(b.X, b.Y, b.W, b.H) } | |||
| 29 | + | got, ok := sceneDelta(old, s, phys, r.width*r.height) | |||
| 30 | + | require.True(t, ok) | |||
| 31 | + | require.Equal(t, want, got) | |||
| 32 | + | s.PositionHint.Opacity = 0.5 | |||
| 33 | + | got, ok = sceneDelta(old, s, phys, r.width*r.height) | |||
| 34 | + | require.True(t, ok) | |||
| 35 | + | require.Equal(t, want, got) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestPositionHintDamageTransitions(t *testing.T) { | |||
| 40 | + | base := ports.Scene{Scale: 1, OutputWidth: 640, OutputHeight: 480, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}}} | |||
| 41 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |||
| 42 | + | bounds := func(s ports.Scene) image.Rectangle { return phys(s.PositionHint.Bounds(s.OutputWidth, s.OutputHeight)) } | |||
| 43 | + | t.Run("disappear", func(t *testing.T) { | |||
| 44 | + | cur := base | |||
| 45 | + | cur.PositionHint = ports.PositionHint{} | |||
| 46 | + | got, ok := sceneDelta(base, cur, phys, 640*480) | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, bounds(base), got) | |||
| 49 | + | }) | |||
| 50 | + | t.Run("shape", func(t *testing.T) { | |||
| 51 | + | cur := base | |||
| 52 | + | cur.PositionHint.Columns = 3 | |||
| 53 | + | cur.PositionHint.Counts[2] = 4 | |||
| 54 | + | got, ok := sceneDelta(base, cur, phys, 640*480) | |||
| 55 | + | require.True(t, ok) | |||
| 56 | + | require.Equal(t, bounds(base).Union(bounds(cur)), got) | |||
| 57 | + | }) | |||
| 58 | + | t.Run("fade", func(t *testing.T) { | |||
| 59 | + | cur := base | |||
| 60 | + | cur.PositionHint.Opacity = 0.5 | |||
| 61 | + | got, ok := sceneDelta(base, cur, phys, 640*480) | |||
| 62 | + | require.True(t, ok) | |||
| 63 | + | require.Equal(t, bounds(base), got) | |||
| 64 | + | }) | |||
| 65 | + | t.Run("scale", func(t *testing.T) { | |||
| 66 | + | cur := base | |||
| 67 | + | cur.Scale = 2 | |||
| 68 | + | _, ok := sceneDelta(base, cur, phys, 640*480) | |||
| 69 | + | require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately") | |||
| 70 | + | }) | |||
| 71 | + | } | |||
M internal/adapters/vulkan/scene.go +1 -0
| @@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 60 | 60 | w.popups(true) | |
| 61 | 61 | w.focusFlash() | |
| 62 | 62 | w.captureIndicators() | |
| 63 | + | w.positionHint() | |
| 63 | 64 | w.hintLabels() | |
| 64 | 65 | r.scratchDraws = w.draws | |
| 65 | 66 | return w.draws | |
| @@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 60 | w.popups(true) | 60 | w.popups(true) | ||
| 61 | w.focusFlash() | 61 | w.focusFlash() | ||
| 62 | w.captureIndicators() | 62 | w.captureIndicators() | ||
| 63 | + | w.positionHint() | |||
| 63 | w.hintLabels() | 64 | w.hintLabels() | ||
| 64 | r.scratchDraws = w.draws | 65 | r.scratchDraws = w.draws | ||
| 65 | return w.draws | 66 | return w.draws | ||
M internal/core/core.go +12 -3
| @@ -164,8 +164,9 @@ type Core struct { | |||
| 164 | 164 | motionTime time.Duration | |
| 165 | 165 | pointerAt [2]float64 | |
| 166 | 166 | // capt is the capture state (capture.go, capindicator.go). | |
| 167 | - | capt captureState | |
| 168 | - | flash focusFlash | |
| 167 | + | capt captureState | |
| 168 | + | flash focusFlash | |
| 169 | + | positionHintTimer positionHintTimer | |
| 169 | 170 | // hint is keyboard hint mode (hint.go). | |
| 170 | 171 | hint hintState | |
| 171 | 172 | // keyState is the real modifier state of the latest key event, swallowed | |
| @@ -611,6 +612,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 611 | 612 | return errSecurityChanged | |
| 612 | 613 | } | |
| 613 | 614 | c.syncSecurity() | |
| 615 | + | c.updatePositionHints(only) | |
| 614 | 616 | c.hintValidate() | |
| 615 | 617 | if c.security.Protected { | |
| 616 | 618 | return c.publishProtected(ctx) | |
| @@ -719,7 +721,7 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||
| 719 | 721 | // other than only. | |
| 720 | 722 | func (c *Core) animatingOther(only *screen) bool { | |
| 721 | 723 | for _, sc := range c.screens { | |
| 722 | - | if sc != only && (sc.springing() || c.flashing(sc)) { | |
| 724 | + | if sc != only && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) { | |
| 723 | 725 | return true | |
| 724 | 726 | } | |
| 725 | 727 | } | |
| @@ -1068,6 +1070,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1068 | 1070 | defer c.stopFrame() | |
| 1069 | 1071 | defer c.stopCaptureTimer() | |
| 1070 | 1072 | defer c.stopFlashTimer() | |
| 1073 | + | defer c.stopPositionHintTimer() | |
| 1071 | 1074 | defer c.cancelSwitcher() | |
| 1072 | 1075 | defer c.stopRepeat() | |
| 1073 | 1076 | // Startup commands run once per session. Full launcher queues retain a | |
| @@ -1142,6 +1145,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1142 | 1145 | return nil | |
| 1143 | 1146 | } | |
| 1144 | 1147 | continue | |
| 1148 | + | case <-c.positionHintTimer.timerC: | |
| 1149 | + | c.positionHintTimer.timerC = nil | |
| 1150 | + | if c.securityCheckpoint(ctx) != nil || c.publish(ctx) != nil { | |
| 1151 | + | return nil | |
| 1152 | + | } | |
| 1153 | + | continue | |
| 1145 | 1154 | case <-c.flash.timerC: | |
| 1146 | 1155 | if c.securityCheckpoint(ctx) != nil { | |
| 1147 | 1156 | return nil | |
| @@ -164,8 +164,9 @@ type Core struct { | |||||
| 164 | motionTime time.Duration | 164 | motionTime time.Duration | ||
| 165 | pointerAt [2]float64 | 165 | pointerAt [2]float64 | ||
| 166 | // capt is the capture state (capture.go, capindicator.go). | 166 | // capt is the capture state (capture.go, capindicator.go). | ||
| 167 | - | capt captureState | 167 | + | capt captureState |
| 168 | - | flash focusFlash | 168 | + | flash focusFlash |
| 169 | + | positionHintTimer positionHintTimer | |||
| 169 | // hint is keyboard hint mode (hint.go). | 170 | // hint is keyboard hint mode (hint.go). | ||
| 170 | hint hintState | 171 | hint hintState | ||
| 171 | // keyState is the real modifier state of the latest key event, swallowed | 172 | // keyState is the real modifier state of the latest key event, swallowed | ||
| @@ -611,6 +612,7 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 611 | return errSecurityChanged | 612 | return errSecurityChanged | ||
| 612 | } | 613 | } | ||
| 613 | c.syncSecurity() | 614 | c.syncSecurity() | ||
| 615 | + | c.updatePositionHints(only) | |||
| 614 | c.hintValidate() | 616 | c.hintValidate() | ||
| 615 | if c.security.Protected { | 617 | if c.security.Protected { | ||
| 616 | return c.publishProtected(ctx) | 618 | return c.publishProtected(ctx) | ||
| @@ -719,7 +721,7 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||||
| 719 | // other than only. | 721 | // other than only. | ||
| 720 | func (c *Core) animatingOther(only *screen) bool { | 722 | func (c *Core) animatingOther(only *screen) bool { | ||
| 721 | for _, sc := range c.screens { | 723 | for _, sc := range c.screens { | ||
| 722 | - | if sc != only && (sc.springing() || c.flashing(sc)) { | 724 | + | if sc != only && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) { |
| 723 | return true | 725 | return true | ||
| 724 | } | 726 | } | ||
| 725 | } | 727 | } | ||
| @@ -1068,6 +1070,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1068 | defer c.stopFrame() | 1070 | defer c.stopFrame() | ||
| 1069 | defer c.stopCaptureTimer() | 1071 | defer c.stopCaptureTimer() | ||
| 1070 | defer c.stopFlashTimer() | 1072 | defer c.stopFlashTimer() | ||
| 1073 | + | defer c.stopPositionHintTimer() | |||
| 1071 | defer c.cancelSwitcher() | 1074 | defer c.cancelSwitcher() | ||
| 1072 | defer c.stopRepeat() | 1075 | defer c.stopRepeat() | ||
| 1073 | // Startup commands run once per session. Full launcher queues retain a | 1076 | // Startup commands run once per session. Full launcher queues retain a | ||
| @@ -1142,6 +1145,12 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1142 | return nil | 1145 | return nil | ||
| 1143 | } | 1146 | } | ||
| 1144 | continue | 1147 | continue | ||
| 1148 | + | case <-c.positionHintTimer.timerC: | |||
| 1149 | + | c.positionHintTimer.timerC = nil | |||
| 1150 | + | if c.securityCheckpoint(ctx) != nil || c.publish(ctx) != nil { | |||
| 1151 | + | return nil | |||
| 1152 | + | } | |||
| 1153 | + | continue | |||
| 1145 | case <-c.flash.timerC: | 1154 | case <-c.flash.timerC: | ||
| 1146 | if c.securityCheckpoint(ctx) != nil { | 1155 | if c.securityCheckpoint(ctx) != nil { | ||
| 1147 | return nil | 1156 | return nil | ||
M internal/core/gesture.go +3 -3
| @@ -589,7 +589,7 @@ func (c *Core) animate(now time.Time, only *screen) { | |||
| 589 | 589 | // sliding reports whether a spring or focus flash runs on the named output. | |
| 590 | 590 | func (c *Core) sliding(output string) bool { | |
| 591 | 591 | i := c.screenIndex(output) | |
| 592 | - | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) | |
| 592 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]) || c.positionHintFading(c.screens[i])) | |
| 593 | 593 | } | |
| 594 | 594 | ||
| 595 | 595 | // animating reports whether a spring or focus flash runs on any output. | |
| @@ -598,7 +598,7 @@ func (c *Core) animating() bool { | |||
| 598 | 598 | return true | |
| 599 | 599 | } | |
| 600 | 600 | for _, sc := range c.screens { | |
| 601 | - | if sc.springing() { | |
| 601 | + | if sc.springing() || c.positionHintFading(sc) { | |
| 602 | 602 | return true | |
| 603 | 603 | } | |
| 604 | 604 | } | |
| @@ -622,7 +622,7 @@ func (m *Monitor) springing() bool { | |||
| 622 | 622 | func (c *Core) frameFallback() time.Duration { | |
| 623 | 623 | refresh := 60000 | |
| 624 | 624 | for _, sc := range c.screens { | |
| 625 | - | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { | |
| 625 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) { | |
| 626 | 626 | refresh = min(refresh, r) | |
| 627 | 627 | } | |
| 628 | 628 | } | |
| @@ -589,7 +589,7 @@ func (c *Core) animate(now time.Time, only *screen) { | |||||
| 589 | // sliding reports whether a spring or focus flash runs on the named output. | 589 | // sliding reports whether a spring or focus flash runs on the named output. | ||
| 590 | func (c *Core) sliding(output string) bool { | 590 | func (c *Core) sliding(output string) bool { | ||
| 591 | i := c.screenIndex(output) | 591 | i := c.screenIndex(output) | ||
| 592 | - | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) | 592 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]) || c.positionHintFading(c.screens[i])) |
| 593 | } | 593 | } | ||
| 594 | 594 | ||||
| 595 | // animating reports whether a spring or focus flash runs on any output. | 595 | // animating reports whether a spring or focus flash runs on any output. | ||
| @@ -598,7 +598,7 @@ func (c *Core) animating() bool { | |||||
| 598 | return true | 598 | return true | ||
| 599 | } | 599 | } | ||
| 600 | for _, sc := range c.screens { | 600 | for _, sc := range c.screens { | ||
| 601 | - | if sc.springing() { | 601 | + | if sc.springing() || c.positionHintFading(sc) { |
| 602 | return true | 602 | return true | ||
| 603 | } | 603 | } | ||
| 604 | } | 604 | } | ||
| @@ -622,7 +622,7 @@ func (m *Monitor) springing() bool { | |||||
| 622 | func (c *Core) frameFallback() time.Duration { | 622 | func (c *Core) frameFallback() time.Duration { | ||
| 623 | refresh := 60000 | 623 | refresh := 60000 | ||
| 624 | for _, sc := range c.screens { | 624 | for _, sc := range c.screens { | ||
| 625 | - | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { | 625 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) { |
| 626 | refresh = min(refresh, r) | 626 | refresh = min(refresh, r) | ||
| 627 | } | 627 | } | ||
| 628 | } | 628 | } | ||
A internal/core/position_hint.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const positionHintHold = 600 * time.Millisecond | |
| 10 | + | const positionHintFade = 150 * time.Millisecond | |
| 11 | + | ||
| 12 | + | // positionHintState belongs exclusively to the core owner. Published samples | |
| 13 | + | // are fixed-size values, so no buffer recycling or renderer acknowledgement is | |
| 14 | + | // needed. The timer is retained (but stopped when idle) and Reset on reuse. | |
| 15 | + | type positionHintState struct { | |
| 16 | + | workspace *Workspace | |
| 17 | + | band int | |
| 18 | + | focused bool | |
| 19 | + | initialized bool | |
| 20 | + | start time.Time | |
| 21 | + | live bool | |
| 22 | + | fading bool | |
| 23 | + | sample ports.PositionHint | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type positionHintTimer struct { | |
| 27 | + | timer ports.Timer | |
| 28 | + | timerC <-chan time.Time | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // updatePositionHints compares actual positions at the publication boundary, | |
| 32 | + | // independent of which input/action changed them. Initial output discovery is | |
| 33 | + | // not a move. Other outputs retain their own start time and sample. | |
| 34 | + | func (c *Core) updatePositionHints(only *screen) { | |
| 35 | + | for i, sc := range c.screens { | |
| 36 | + | h := &sc.positionHint | |
| 37 | + | // Overview selection is provisional. Preserve the last committed | |
| 38 | + | // baseline until closing, so accept compares against entry and cancel | |
| 39 | + | // cannot announce intermediate browsing positions. | |
| 40 | + | if sc.mon.ov.open { | |
| 41 | + | h.live, h.sample = false, ports.PositionHint{} | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | w := sc.mon.Current() | |
| 45 | + | band := 0 | |
| 46 | + | if w.Overflow == OverflowCascade { | |
| 47 | + | band = w.band(w.Focus) | |
| 48 | + | } | |
| 49 | + | focused := i == c.focusScreen | |
| 50 | + | changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused) | |
| 51 | + | // Transferring the current workspace changes the source's fallback | |
| 52 | + | // position too, but only the receiving output should announce it. | |
| 53 | + | if h.initialized && h.workspace != w { | |
| 54 | + | for _, other := range c.screens { | |
| 55 | + | if other != sc && other.mon.has(h.workspace) { | |
| 56 | + | changed = false | |
| 57 | + | break | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | h.workspace, h.band, h.focused, h.initialized = w, band, focused, true | |
| 62 | + | if !c.cfg.Focus.PositionHint || c.protectionRequested() || w.cover() != 0 { | |
| 63 | + | h.live, h.sample = false, ports.PositionHint{} | |
| 64 | + | continue | |
| 65 | + | } | |
| 66 | + | if changed { | |
| 67 | + | h.start, h.live, h.fading = c.now(), true, false | |
| 68 | + | } | |
| 69 | + | if !h.live { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | if !changed && only != nil && only != sc { | |
| 73 | + | continue | |
| 74 | + | } | |
| 75 | + | elapsed := c.now().Sub(h.start) | |
| 76 | + | opacity := float32(1) | |
| 77 | + | h.fading = elapsed >= positionHintHold | |
| 78 | + | if h.fading { | |
| 79 | + | if !c.animOn() || elapsed >= positionHintHold+positionHintFade { | |
| 80 | + | h.live, h.sample = false, ports.PositionHint{} | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade) | |
| 84 | + | } | |
| 85 | + | h.sample = positionHintGrid(sc.mon) | |
| 86 | + | h.sample.Opacity = opacity | |
| 87 | + | } | |
| 88 | + | c.armPositionHintTimer() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Use ext-workspace's complete ordered list: numbered (including empty | |
| 92 | + | // spares), then configured hidden named workspaces. Unlike overview cards, | |
| 93 | + | // these remain addressable even when empty. Window the list at its active | |
| 94 | + | // entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp. | |
| 95 | + | func positionHintGrid(m *Monitor) ports.PositionHint { | |
| 96 | + | h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis} | |
| 97 | + | active, total := 0, 0 | |
| 98 | + | for w := range m.all() { | |
| 99 | + | if w == m.Current() { | |
| 100 | + | active = total | |
| 101 | + | } | |
| 102 | + | total++ | |
| 103 | + | } | |
| 104 | + | cap := ports.PositionHintMaxColumns | |
| 105 | + | if h.Vertical { | |
| 106 | + | cap = ports.PositionHintMaxRows | |
| 107 | + | } | |
| 108 | + | first := max(0, active-cap+1) | |
| 109 | + | if h.Vertical { | |
| 110 | + | h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first) | |
| 111 | + | return h | |
| 112 | + | } | |
| 113 | + | h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first) | |
| 114 | + | i := 0 | |
| 115 | + | for w := range m.all() { | |
| 116 | + | if i >= first && i < first+cap { | |
| 117 | + | n := 1 | |
| 118 | + | if w.Overflow == OverflowCascade { | |
| 119 | + | n, _ = w.cascadeBands() | |
| 120 | + | } | |
| 121 | + | h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows)) | |
| 122 | + | } | |
| 123 | + | i++ | |
| 124 | + | } | |
| 125 | + | w := m.Current() | |
| 126 | + | if w.Overflow == OverflowCascade { | |
| 127 | + | h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1)) | |
| 128 | + | } | |
| 129 | + | return h | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (c *Core) positionHintFading(sc *screen) bool { | |
| 133 | + | return sc.positionHint.live && sc.positionHint.fading | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // One timer wakes at the earliest hold boundary. Fade frames use the existing | |
| 137 | + | // flip/fallback scheduler; with no live hints no timer is armed or clock read. | |
| 138 | + | func (c *Core) armPositionHintTimer() { | |
| 139 | + | var earliest time.Time | |
| 140 | + | for _, sc := range c.screens { | |
| 141 | + | if sc.positionHint.live && !sc.positionHint.fading { | |
| 142 | + | end := sc.positionHint.start.Add(positionHintHold) | |
| 143 | + | if earliest.IsZero() || end.Before(earliest) { | |
| 144 | + | earliest = end | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | t := &c.positionHintTimer | |
| 149 | + | if earliest.IsZero() { | |
| 150 | + | c.stopPositionHintTimer() | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | d := max(earliest.Sub(c.now()), time.Nanosecond) | |
| 154 | + | if t.timer == nil { | |
| 155 | + | t.timer = newPortTimer(c.opts.Clock, d) | |
| 156 | + | } else { | |
| 157 | + | t.timer.Reset(d) | |
| 158 | + | } | |
| 159 | + | t.timerC = t.timer.C() | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (c *Core) stopPositionHintTimer() { | |
| 163 | + | t := &c.positionHintTimer | |
| 164 | + | if t.timerC != nil { | |
| 165 | + | t.timer.Stop() | |
| 166 | + | t.timerC = nil | |
| 167 | + | } | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const positionHintHold = 600 * time.Millisecond | |||
| 10 | + | const positionHintFade = 150 * time.Millisecond | |||
| 11 | + | ||||
| 12 | + | // positionHintState belongs exclusively to the core owner. Published samples | |||
| 13 | + | // are fixed-size values, so no buffer recycling or renderer acknowledgement is | |||
| 14 | + | // needed. The timer is retained (but stopped when idle) and Reset on reuse. | |||
| 15 | + | type positionHintState struct { | |||
| 16 | + | workspace *Workspace | |||
| 17 | + | band int | |||
| 18 | + | focused bool | |||
| 19 | + | initialized bool | |||
| 20 | + | start time.Time | |||
| 21 | + | live bool | |||
| 22 | + | fading bool | |||
| 23 | + | sample ports.PositionHint | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type positionHintTimer struct { | |||
| 27 | + | timer ports.Timer | |||
| 28 | + | timerC <-chan time.Time | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // updatePositionHints compares actual positions at the publication boundary, | |||
| 32 | + | // independent of which input/action changed them. Initial output discovery is | |||
| 33 | + | // not a move. Other outputs retain their own start time and sample. | |||
| 34 | + | func (c *Core) updatePositionHints(only *screen) { | |||
| 35 | + | for i, sc := range c.screens { | |||
| 36 | + | h := &sc.positionHint | |||
| 37 | + | // Overview selection is provisional. Preserve the last committed | |||
| 38 | + | // baseline until closing, so accept compares against entry and cancel | |||
| 39 | + | // cannot announce intermediate browsing positions. | |||
| 40 | + | if sc.mon.ov.open { | |||
| 41 | + | h.live, h.sample = false, ports.PositionHint{} | |||
| 42 | + | continue | |||
| 43 | + | } | |||
| 44 | + | w := sc.mon.Current() | |||
| 45 | + | band := 0 | |||
| 46 | + | if w.Overflow == OverflowCascade { | |||
| 47 | + | band = w.band(w.Focus) | |||
| 48 | + | } | |||
| 49 | + | focused := i == c.focusScreen | |||
| 50 | + | changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused) | |||
| 51 | + | // Transferring the current workspace changes the source's fallback | |||
| 52 | + | // position too, but only the receiving output should announce it. | |||
| 53 | + | if h.initialized && h.workspace != w { | |||
| 54 | + | for _, other := range c.screens { | |||
| 55 | + | if other != sc && other.mon.has(h.workspace) { | |||
| 56 | + | changed = false | |||
| 57 | + | break | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | h.workspace, h.band, h.focused, h.initialized = w, band, focused, true | |||
| 62 | + | if !c.cfg.Focus.PositionHint || c.protectionRequested() || w.cover() != 0 { | |||
| 63 | + | h.live, h.sample = false, ports.PositionHint{} | |||
| 64 | + | continue | |||
| 65 | + | } | |||
| 66 | + | if changed { | |||
| 67 | + | h.start, h.live, h.fading = c.now(), true, false | |||
| 68 | + | } | |||
| 69 | + | if !h.live { | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | if !changed && only != nil && only != sc { | |||
| 73 | + | continue | |||
| 74 | + | } | |||
| 75 | + | elapsed := c.now().Sub(h.start) | |||
| 76 | + | opacity := float32(1) | |||
| 77 | + | h.fading = elapsed >= positionHintHold | |||
| 78 | + | if h.fading { | |||
| 79 | + | if !c.animOn() || elapsed >= positionHintHold+positionHintFade { | |||
| 80 | + | h.live, h.sample = false, ports.PositionHint{} | |||
| 81 | + | continue | |||
| 82 | + | } | |||
| 83 | + | opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade) | |||
| 84 | + | } | |||
| 85 | + | h.sample = positionHintGrid(sc.mon) | |||
| 86 | + | h.sample.Opacity = opacity | |||
| 87 | + | } | |||
| 88 | + | c.armPositionHintTimer() | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Use ext-workspace's complete ordered list: numbered (including empty | |||
| 92 | + | // spares), then configured hidden named workspaces. Unlike overview cards, | |||
| 93 | + | // these remain addressable even when empty. Window the list at its active | |||
| 94 | + | // entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp. | |||
| 95 | + | func positionHintGrid(m *Monitor) ports.PositionHint { | |||
| 96 | + | h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis} | |||
| 97 | + | active, total := 0, 0 | |||
| 98 | + | for w := range m.all() { | |||
| 99 | + | if w == m.Current() { | |||
| 100 | + | active = total | |||
| 101 | + | } | |||
| 102 | + | total++ | |||
| 103 | + | } | |||
| 104 | + | cap := ports.PositionHintMaxColumns | |||
| 105 | + | if h.Vertical { | |||
| 106 | + | cap = ports.PositionHintMaxRows | |||
| 107 | + | } | |||
| 108 | + | first := max(0, active-cap+1) | |||
| 109 | + | if h.Vertical { | |||
| 110 | + | h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first) | |||
| 111 | + | return h | |||
| 112 | + | } | |||
| 113 | + | h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first) | |||
| 114 | + | i := 0 | |||
| 115 | + | for w := range m.all() { | |||
| 116 | + | if i >= first && i < first+cap { | |||
| 117 | + | n := 1 | |||
| 118 | + | if w.Overflow == OverflowCascade { | |||
| 119 | + | n, _ = w.cascadeBands() | |||
| 120 | + | } | |||
| 121 | + | h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows)) | |||
| 122 | + | } | |||
| 123 | + | i++ | |||
| 124 | + | } | |||
| 125 | + | w := m.Current() | |||
| 126 | + | if w.Overflow == OverflowCascade { | |||
| 127 | + | h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1)) | |||
| 128 | + | } | |||
| 129 | + | return h | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (c *Core) positionHintFading(sc *screen) bool { | |||
| 133 | + | return sc.positionHint.live && sc.positionHint.fading | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // One timer wakes at the earliest hold boundary. Fade frames use the existing | |||
| 137 | + | // flip/fallback scheduler; with no live hints no timer is armed or clock read. | |||
| 138 | + | func (c *Core) armPositionHintTimer() { | |||
| 139 | + | var earliest time.Time | |||
| 140 | + | for _, sc := range c.screens { | |||
| 141 | + | if sc.positionHint.live && !sc.positionHint.fading { | |||
| 142 | + | end := sc.positionHint.start.Add(positionHintHold) | |||
| 143 | + | if earliest.IsZero() || end.Before(earliest) { | |||
| 144 | + | earliest = end | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | t := &c.positionHintTimer | |||
| 149 | + | if earliest.IsZero() { | |||
| 150 | + | c.stopPositionHintTimer() | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | d := max(earliest.Sub(c.now()), time.Nanosecond) | |||
| 154 | + | if t.timer == nil { | |||
| 155 | + | t.timer = newPortTimer(c.opts.Clock, d) | |||
| 156 | + | } else { | |||
| 157 | + | t.timer.Reset(d) | |||
| 158 | + | } | |||
| 159 | + | t.timerC = t.timer.C() | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (c *Core) stopPositionHintTimer() { | |||
| 163 | + | t := &c.positionHintTimer | |||
| 164 | + | if t.timerC != nil { | |||
| 165 | + | t.timer.Stop() | |||
| 166 | + | t.timerC = nil | |||
| 167 | + | } | |||
| 168 | + | } | |||
A internal/core/position_hint_test.go +344 -0
| @@ -0,0 +1,344 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "testing/synctest" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestPositionHintChangesAndTiming(t *testing.T) { | |
| 16 | + | c, clock := indicatorCore(t) | |
| 17 | + | c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true | |
| 18 | + | a := c.cur() | |
| 19 | + | a.mon.Current().Overflow = OverflowCascade | |
| 20 | + | a.mon.Current().MaxColumns = 1 | |
| 21 | + | a.mon.AddWindow(1) | |
| 22 | + | a.mon.AddWindow(2) | |
| 23 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 24 | + | b := c.screens[1] | |
| 25 | + | a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1 | |
| 26 | + | a.mon.Current().Focus = 1 | |
| 27 | + | c.updatePositionHints(nil) | |
| 28 | + | require.False(t, a.positionHint.live) | |
| 29 | + | a.mon.Current().Focus = 0 | |
| 30 | + | c.updatePositionHints(nil) | |
| 31 | + | require.True(t, a.positionHint.sample.Shown()) | |
| 32 | + | require.False(t, b.positionHint.live) | |
| 33 | + | start := a.positionHint.start | |
| 34 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |
| 35 | + | c.updatePositionHints(nil) | |
| 36 | + | require.Equal(t, start, a.positionHint.start, "no-op restarted timer") | |
| 37 | + | a.mon.Current().Focus = 1 | |
| 38 | + | c.updatePositionHints(nil) | |
| 39 | + | require.Equal(t, clock.now, a.positionHint.start) | |
| 40 | + | clock.now = clock.now.Add(positionHintHold) | |
| 41 | + | c.updatePositionHints(nil) | |
| 42 | + | require.True(t, c.positionHintFading(a), "hold boundary must start frame scheduling") | |
| 43 | + | clock.now = clock.now.Add(positionHintFade / 2) | |
| 44 | + | c.updatePositionHints(nil) | |
| 45 | + | require.Equal(t, float32(0.5), a.positionHint.sample.Opacity) | |
| 46 | + | saved := a.positionHint.sample | |
| 47 | + | c.focusScreen = 1 | |
| 48 | + | c.updatePositionHints(b) | |
| 49 | + | require.Equal(t, saved, a.positionHint.sample) | |
| 50 | + | require.True(t, b.positionHint.live) | |
| 51 | + | clock.now = clock.now.Add(positionHintFade / 2) | |
| 52 | + | c.updatePositionHints(a) | |
| 53 | + | require.False(t, a.positionHint.live) | |
| 54 | + | require.True(t, b.positionHint.live) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestPositionHintWorkspaceAndSuppression(t *testing.T) { | |
| 58 | + | for _, mode := range []string{"workspace", "overview", "fullscreen", "off", "no-animation"} { | |
| 59 | + | t.Run(mode, func(t *testing.T) { | |
| 60 | + | c, clock := indicatorCore(t) | |
| 61 | + | c.cfg.Focus.PositionHint = true | |
| 62 | + | sc := c.cur() | |
| 63 | + | sc.mon.AddWindow(1) | |
| 64 | + | c.updatePositionHints(nil) | |
| 65 | + | sc.mon.show(sc.mon.Workspaces[1]) | |
| 66 | + | c.updatePositionHints(nil) | |
| 67 | + | require.True(t, sc.positionHint.live) | |
| 68 | + | switch mode { | |
| 69 | + | case "overview": | |
| 70 | + | sc.mon.ov.open = true | |
| 71 | + | case "fullscreen": | |
| 72 | + | sc.mon.Current().Overflow = OverflowFixed | |
| 73 | + | sc.mon.Current().fullscreen = 1 | |
| 74 | + | case "off": | |
| 75 | + | c.cfg.Focus.PositionHint = false | |
| 76 | + | default: | |
| 77 | + | clock.now = clock.now.Add(positionHintHold) | |
| 78 | + | } | |
| 79 | + | c.updatePositionHints(nil) | |
| 80 | + | require.False(t, sc.positionHint.sample.Shown()) | |
| 81 | + | require.Nil(t, c.positionHintTimer.timerC) | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestPositionHintGrid(t *testing.T) { | |
| 87 | + | c, _ := indicatorCore(t) | |
| 88 | + | m := c.cur().mon | |
| 89 | + | w := m.Current() | |
| 90 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |
| 91 | + | m.AddWindow(1) | |
| 92 | + | m.AddWindow(2) | |
| 93 | + | h := positionHintGrid(m) | |
| 94 | + | require.False(t, h.Vertical) | |
| 95 | + | require.Equal(t, uint8(2), h.Counts[0]) | |
| 96 | + | require.Equal(t, uint8(1), h.Counts[1], "empty noncascade workspace") | |
| 97 | + | require.Equal(t, uint8(1), h.ActiveRow) | |
| 98 | + | m.show(m.Workspaces[1]) | |
| 99 | + | h = positionHintGrid(m) | |
| 100 | + | require.True(t, h.Vertical, "current workspace decides axis") | |
| 101 | + | require.Equal(t, uint8(1), h.Columns) | |
| 102 | + | require.Equal(t, uint8(2), h.Counts[0]) | |
| 103 | + | require.Equal(t, uint8(1), h.ActiveRow) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestPositionHintAllocations(t *testing.T) { | |
| 107 | + | // Real clock avoids measuring generated mock bookkeeping, as publishRig does. | |
| 108 | + | c := publishRig(t, 1) | |
| 109 | + | c.cfg.Focus.PositionHint = true | |
| 110 | + | sc := c.cur() | |
| 111 | + | w := sc.mon.Current() | |
| 112 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |
| 113 | + | c.updatePositionHints(nil) | |
| 114 | + | defer c.stopPositionHintTimer() | |
| 115 | + | for name, run := range map[string]func(){ | |
| 116 | + | "trigger": func() { w.Focus = (w.Focus + 1) % 2; c.updatePositionHints(nil) }, | |
| 117 | + | "fade": func() { | |
| 118 | + | sc.positionHint.live = true | |
| 119 | + | sc.positionHint.start = c.now().Add(-positionHintHold - 50*time.Millisecond) | |
| 120 | + | c.updatePositionHints(nil) | |
| 121 | + | }, | |
| 122 | + | "inactive": func() { sc.positionHint.live = false; c.updatePositionHints(nil) }, | |
| 123 | + | } { | |
| 124 | + | t.Run(name, func(t *testing.T) { run(); require.Zero(t, testing.AllocsPerRun(100, run)) }) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestPositionHintMoveWorkspaceDestination(t *testing.T) { | |
| 129 | + | c, _ := indicatorCore(t) | |
| 130 | + | c.cfg.Focus.PositionHint = true | |
| 131 | + | c.cur().mon.AddWindow(1) | |
| 132 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 133 | + | c.updatePositionHints(nil) | |
| 134 | + | source := c.cur() | |
| 135 | + | moved := source.mon.Current() | |
| 136 | + | c.moveWorkspace(dirRight) | |
| 137 | + | c.updatePositionHints(nil) | |
| 138 | + | require.Same(t, moved, c.cur().mon.Current()) | |
| 139 | + | require.False(t, source.positionHint.live) | |
| 140 | + | require.True(t, c.cur().positionHint.sample.Shown()) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestPositionHintCapsAndProtection(t *testing.T) { | |
| 144 | + | c, _ := indicatorCore(t) | |
| 145 | + | c.cfg.Focus.PositionHint = true | |
| 146 | + | m := c.cur().mon | |
| 147 | + | for range 20 { | |
| 148 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 149 | + | } | |
| 150 | + | m.Active = 20 | |
| 151 | + | h := positionHintGrid(m) | |
| 152 | + | require.Equal(t, uint8(16), h.Counts[0]) | |
| 153 | + | require.Equal(t, uint8(15), h.ActiveRow) | |
| 154 | + | m.Current().Overflow = OverflowCascade | |
| 155 | + | h = positionHintGrid(m) | |
| 156 | + | require.Equal(t, uint8(16), h.Columns) | |
| 157 | + | require.Equal(t, uint8(15), h.ActiveCol) | |
| 158 | + | c.updatePositionHints(nil) | |
| 159 | + | c.cur().positionHint.live = true | |
| 160 | + | c.security.Protected = true | |
| 161 | + | c.updatePositionHints(nil) | |
| 162 | + | require.False(t, c.cur().positionHint.live) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestPositionHintIncludesNamedEmptyAndMixedOverflow(t *testing.T) { | |
| 166 | + | c, _ := indicatorCore(t) | |
| 167 | + | m := c.cur().mon | |
| 168 | + | w := m.Current() | |
| 169 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |
| 170 | + | m.AddWindow(1) | |
| 171 | + | m.AddWindow(2) | |
| 172 | + | named := m.newWorkspace() | |
| 173 | + | named.Name, named.Overflow = "hidden", OverflowScroll | |
| 174 | + | named.Columns = []Column{{Windows: []WindowID{3}}, {Windows: []WindowID{4}, BandStart: true}} | |
| 175 | + | m.hidden = append(m.hidden, named) | |
| 176 | + | empty := m.newWorkspace() | |
| 177 | + | empty.Name = "empty" | |
| 178 | + | m.hidden = append(m.hidden, empty) | |
| 179 | + | h := positionHintGrid(m) | |
| 180 | + | require.Equal(t, uint8(4), h.Columns) | |
| 181 | + | require.Equal(t, [16]uint8{2, 1, 1, 1}, h.Counts) | |
| 182 | + | require.Equal(t, uint8(0), h.ActiveCol) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestPositionHintOverviewCommittedBaseline(t *testing.T) { | |
| 186 | + | for _, cancel := range []bool{false, true} { | |
| 187 | + | t.Run(map[bool]string{false: "accept", true: "cancel"}[cancel], func(t *testing.T) { | |
| 188 | + | c, _ := indicatorCore(t) | |
| 189 | + | c.cfg.Focus.PositionHint = true | |
| 190 | + | m := c.cur().mon | |
| 191 | + | m.AddWindow(1) | |
| 192 | + | origin := m.Current() | |
| 193 | + | other := m.Workspaces[1] | |
| 194 | + | other.AddWindow(2) | |
| 195 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |
| 196 | + | m.ToggleOverview() | |
| 197 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |
| 198 | + | m.showOverview(other) | |
| 199 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |
| 200 | + | require.Same(t, origin, c.cur().positionHint.workspace) | |
| 201 | + | m.showOverview(origin) | |
| 202 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |
| 203 | + | m.showOverview(other) | |
| 204 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |
| 205 | + | if cancel { | |
| 206 | + | m.CancelOverview() | |
| 207 | + | } else { | |
| 208 | + | m.ToggleOverview() | |
| 209 | + | } | |
| 210 | + | require.Equal(t, !cancel, indicatorScene(t, c).PositionHint.Shown()) | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestPositionHintScrolledAwayFullscreen(t *testing.T) { | |
| 216 | + | c, _ := indicatorCore(t) | |
| 217 | + | c.cfg.Focus.PositionHint = true | |
| 218 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 219 | + | a := c.screens[0] | |
| 220 | + | w := a.mon.Current() | |
| 221 | + | w.Overflow = OverflowScroll | |
| 222 | + | a.mon.AddWindow(1) | |
| 223 | + | a.mon.AddWindow(2) | |
| 224 | + | w.fullscreen = 1 | |
| 225 | + | w.View = w.columnX(1) | |
| 226 | + | require.NotZero(t, w.fullscreen) | |
| 227 | + | require.Zero(t, w.cover()) | |
| 228 | + | c.focusScreen = 1 | |
| 229 | + | c.updatePositionHints(nil) | |
| 230 | + | c.focusScreen = 0 | |
| 231 | + | c.updatePositionHints(nil) | |
| 232 | + | require.True(t, a.positionHint.sample.Shown()) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestPositionHintStartupHotplugReload(t *testing.T) { | |
| 236 | + | c, _ := indicatorCore(t) | |
| 237 | + | c.cfg.Focus.PositionHint = true | |
| 238 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |
| 239 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 240 | + | for _, s := range stepScenes(t, c, nil) { | |
| 241 | + | require.False(t, s.PositionHint.Shown()) | |
| 242 | + | } | |
| 243 | + | require.NoError(t, c.apply(c.cfg)) | |
| 244 | + | for _, s := range stepScenes(t, c, nil) { | |
| 245 | + | require.False(t, s.PositionHint.Shown()) | |
| 246 | + | } | |
| 247 | + | c.focusScreen = 1 | |
| 248 | + | require.True(t, stepScenes(t, c, nil)["B"].PositionHint.Shown()) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestPositionHintMaxGridTransitionAllocations(t *testing.T) { | |
| 252 | + | c := publishRig(t, 1) | |
| 253 | + | c.cfg.Focus.PositionHint = true | |
| 254 | + | m := c.cur().mon | |
| 255 | + | for len(m.Workspaces) < 16 { | |
| 256 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 257 | + | } | |
| 258 | + | for _, w := range m.Workspaces { | |
| 259 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |
| 260 | + | w.Columns = make([]Column, 16) | |
| 261 | + | for i := range w.Columns { | |
| 262 | + | w.Columns[i] = Column{Windows: []WindowID{WindowID(i + 100)}, BandStart: true} | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | c.updatePositionHints(nil) | |
| 266 | + | defer c.stopPositionHintTimer() | |
| 267 | + | run := func() { m.Active = (m.Active + 1) % 16; c.updatePositionHints(nil) } | |
| 268 | + | run() | |
| 269 | + | require.Equal(t, [16]uint8{16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, c.cur().positionHint.sample.Counts) | |
| 270 | + | require.Zero(t, testing.AllocsPerRun(100, run)) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func TestPositionHintRunTimerLifecycle(t *testing.T) { | |
| 274 | + | synctest.Test(t, func(t *testing.T) { | |
| 275 | + | c := publishRig(t, 2) | |
| 276 | + | c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true | |
| 277 | + | c.ch.Commands = nil | |
| 278 | + | now := time.Unix(1000, 0) | |
| 279 | + | clock := portsmocks.NewMockClock(t) | |
| 280 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe() | |
| 281 | + | timer := portsmocks.NewMockTimer(t) | |
| 282 | + | tick := make(chan time.Time, 1) | |
| 283 | + | timer.EXPECT().C().Return((<-chan time.Time)(tick)).Maybe() | |
| 284 | + | timer.EXPECT().Reset(500 * time.Millisecond).Return(true).Once() | |
| 285 | + | timer.EXPECT().Reset(100 * time.Millisecond).Return(true).Once() | |
| 286 | + | timer.EXPECT().Stop().Return(true).Once() | |
| 287 | + | timer.EXPECT().Reset(positionHintHold).Return(true).Once() | |
| 288 | + | clock.EXPECT().NewTimer(positionHintHold).Return(timer).Once() | |
| 289 | + | // The second timer is the existing animation fallback, not another hint timer. | |
| 290 | + | frame := portsmocks.NewMockTimer(t) | |
| 291 | + | frame.EXPECT().C().Return(make(chan time.Time)).Maybe() | |
| 292 | + | frame.EXPECT().Stop().Return(true).Maybe() | |
| 293 | + | frame.EXPECT().Reset(mock.Anything).Return(true).Maybe() | |
| 294 | + | clock.EXPECT().NewTimer(mock.Anything).Return(frame).Maybe() | |
| 295 | + | c.opts.Clock = clock | |
| 296 | + | a, b := c.screens[0], c.screens[1] | |
| 297 | + | a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1 | |
| 298 | + | a.mon.Current().Focus = 1 | |
| 299 | + | c.updatePositionHints(nil) | |
| 300 | + | a.mon.Current().Focus = 0 | |
| 301 | + | c.updatePositionHints(nil) | |
| 302 | + | now = now.Add(100 * time.Millisecond) | |
| 303 | + | c.focusScreen = 1 | |
| 304 | + | c.updatePositionHints(nil) | |
| 305 | + | require.Same(t, timer, c.positionHintTimer.timer) | |
| 306 | + | frames := make(chan ports.OutputFrame) | |
| 307 | + | c.ch.Frames = frames | |
| 308 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 309 | + | done := make(chan error, 1) | |
| 310 | + | go func() { done <- c.Run(ctx) }() | |
| 311 | + | synctest.Wait() | |
| 312 | + | now = now.Add(500 * time.Millisecond) | |
| 313 | + | tick <- now | |
| 314 | + | synctest.Wait() | |
| 315 | + | require.True(t, a.positionHint.fading) | |
| 316 | + | require.False(t, b.positionHint.fading) | |
| 317 | + | // B still holds for 100ms; the same timer selects its deadline. | |
| 318 | + | now = now.Add(100 * time.Millisecond) | |
| 319 | + | tick <- now | |
| 320 | + | synctest.Wait() | |
| 321 | + | require.True(t, b.positionHint.fading) | |
| 322 | + | require.Nil(t, c.positionHintTimer.timerC) | |
| 323 | + | now = now.Add(positionHintFade) | |
| 324 | + | frames <- ports.OutputFrame{Output: "A"} | |
| 325 | + | synctest.Wait() | |
| 326 | + | frames <- ports.OutputFrame{Output: "B"} | |
| 327 | + | synctest.Wait() | |
| 328 | + | require.False(t, a.positionHint.sample.Shown()) | |
| 329 | + | require.False(t, b.positionHint.sample.Shown()) | |
| 330 | + | require.False(t, c.animating()) | |
| 331 | + | require.Nil(t, c.positionHintTimer.timerC) | |
| 332 | + | // Owner is durably blocked: disabling a newly restarted hint stops | |
| 333 | + | // the retained timer instead of leaving an idle wakeup armed. | |
| 334 | + | c.focusScreen = 0 | |
| 335 | + | c.updatePositionHints(nil) | |
| 336 | + | require.NotNil(t, c.positionHintTimer.timerC) | |
| 337 | + | c.cfg.Focus.PositionHint = false | |
| 338 | + | c.updatePositionHints(nil) | |
| 339 | + | require.Nil(t, c.positionHintTimer.timerC) | |
| 340 | + | cancel() | |
| 341 | + | synctest.Wait() | |
| 342 | + | require.NoError(t, <-done) | |
| 343 | + | }) | |
| 344 | + | } | |
| @@ -0,0 +1,344 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "testing/synctest" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestPositionHintChangesAndTiming(t *testing.T) { | |||
| 16 | + | c, clock := indicatorCore(t) | |||
| 17 | + | c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true | |||
| 18 | + | a := c.cur() | |||
| 19 | + | a.mon.Current().Overflow = OverflowCascade | |||
| 20 | + | a.mon.Current().MaxColumns = 1 | |||
| 21 | + | a.mon.AddWindow(1) | |||
| 22 | + | a.mon.AddWindow(2) | |||
| 23 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 24 | + | b := c.screens[1] | |||
| 25 | + | a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1 | |||
| 26 | + | a.mon.Current().Focus = 1 | |||
| 27 | + | c.updatePositionHints(nil) | |||
| 28 | + | require.False(t, a.positionHint.live) | |||
| 29 | + | a.mon.Current().Focus = 0 | |||
| 30 | + | c.updatePositionHints(nil) | |||
| 31 | + | require.True(t, a.positionHint.sample.Shown()) | |||
| 32 | + | require.False(t, b.positionHint.live) | |||
| 33 | + | start := a.positionHint.start | |||
| 34 | + | clock.now = clock.now.Add(100 * time.Millisecond) | |||
| 35 | + | c.updatePositionHints(nil) | |||
| 36 | + | require.Equal(t, start, a.positionHint.start, "no-op restarted timer") | |||
| 37 | + | a.mon.Current().Focus = 1 | |||
| 38 | + | c.updatePositionHints(nil) | |||
| 39 | + | require.Equal(t, clock.now, a.positionHint.start) | |||
| 40 | + | clock.now = clock.now.Add(positionHintHold) | |||
| 41 | + | c.updatePositionHints(nil) | |||
| 42 | + | require.True(t, c.positionHintFading(a), "hold boundary must start frame scheduling") | |||
| 43 | + | clock.now = clock.now.Add(positionHintFade / 2) | |||
| 44 | + | c.updatePositionHints(nil) | |||
| 45 | + | require.Equal(t, float32(0.5), a.positionHint.sample.Opacity) | |||
| 46 | + | saved := a.positionHint.sample | |||
| 47 | + | c.focusScreen = 1 | |||
| 48 | + | c.updatePositionHints(b) | |||
| 49 | + | require.Equal(t, saved, a.positionHint.sample) | |||
| 50 | + | require.True(t, b.positionHint.live) | |||
| 51 | + | clock.now = clock.now.Add(positionHintFade / 2) | |||
| 52 | + | c.updatePositionHints(a) | |||
| 53 | + | require.False(t, a.positionHint.live) | |||
| 54 | + | require.True(t, b.positionHint.live) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestPositionHintWorkspaceAndSuppression(t *testing.T) { | |||
| 58 | + | for _, mode := range []string{"workspace", "overview", "fullscreen", "off", "no-animation"} { | |||
| 59 | + | t.Run(mode, func(t *testing.T) { | |||
| 60 | + | c, clock := indicatorCore(t) | |||
| 61 | + | c.cfg.Focus.PositionHint = true | |||
| 62 | + | sc := c.cur() | |||
| 63 | + | sc.mon.AddWindow(1) | |||
| 64 | + | c.updatePositionHints(nil) | |||
| 65 | + | sc.mon.show(sc.mon.Workspaces[1]) | |||
| 66 | + | c.updatePositionHints(nil) | |||
| 67 | + | require.True(t, sc.positionHint.live) | |||
| 68 | + | switch mode { | |||
| 69 | + | case "overview": | |||
| 70 | + | sc.mon.ov.open = true | |||
| 71 | + | case "fullscreen": | |||
| 72 | + | sc.mon.Current().Overflow = OverflowFixed | |||
| 73 | + | sc.mon.Current().fullscreen = 1 | |||
| 74 | + | case "off": | |||
| 75 | + | c.cfg.Focus.PositionHint = false | |||
| 76 | + | default: | |||
| 77 | + | clock.now = clock.now.Add(positionHintHold) | |||
| 78 | + | } | |||
| 79 | + | c.updatePositionHints(nil) | |||
| 80 | + | require.False(t, sc.positionHint.sample.Shown()) | |||
| 81 | + | require.Nil(t, c.positionHintTimer.timerC) | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestPositionHintGrid(t *testing.T) { | |||
| 87 | + | c, _ := indicatorCore(t) | |||
| 88 | + | m := c.cur().mon | |||
| 89 | + | w := m.Current() | |||
| 90 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |||
| 91 | + | m.AddWindow(1) | |||
| 92 | + | m.AddWindow(2) | |||
| 93 | + | h := positionHintGrid(m) | |||
| 94 | + | require.False(t, h.Vertical) | |||
| 95 | + | require.Equal(t, uint8(2), h.Counts[0]) | |||
| 96 | + | require.Equal(t, uint8(1), h.Counts[1], "empty noncascade workspace") | |||
| 97 | + | require.Equal(t, uint8(1), h.ActiveRow) | |||
| 98 | + | m.show(m.Workspaces[1]) | |||
| 99 | + | h = positionHintGrid(m) | |||
| 100 | + | require.True(t, h.Vertical, "current workspace decides axis") | |||
| 101 | + | require.Equal(t, uint8(1), h.Columns) | |||
| 102 | + | require.Equal(t, uint8(2), h.Counts[0]) | |||
| 103 | + | require.Equal(t, uint8(1), h.ActiveRow) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestPositionHintAllocations(t *testing.T) { | |||
| 107 | + | // Real clock avoids measuring generated mock bookkeeping, as publishRig does. | |||
| 108 | + | c := publishRig(t, 1) | |||
| 109 | + | c.cfg.Focus.PositionHint = true | |||
| 110 | + | sc := c.cur() | |||
| 111 | + | w := sc.mon.Current() | |||
| 112 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |||
| 113 | + | c.updatePositionHints(nil) | |||
| 114 | + | defer c.stopPositionHintTimer() | |||
| 115 | + | for name, run := range map[string]func(){ | |||
| 116 | + | "trigger": func() { w.Focus = (w.Focus + 1) % 2; c.updatePositionHints(nil) }, | |||
| 117 | + | "fade": func() { | |||
| 118 | + | sc.positionHint.live = true | |||
| 119 | + | sc.positionHint.start = c.now().Add(-positionHintHold - 50*time.Millisecond) | |||
| 120 | + | c.updatePositionHints(nil) | |||
| 121 | + | }, | |||
| 122 | + | "inactive": func() { sc.positionHint.live = false; c.updatePositionHints(nil) }, | |||
| 123 | + | } { | |||
| 124 | + | t.Run(name, func(t *testing.T) { run(); require.Zero(t, testing.AllocsPerRun(100, run)) }) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestPositionHintMoveWorkspaceDestination(t *testing.T) { | |||
| 129 | + | c, _ := indicatorCore(t) | |||
| 130 | + | c.cfg.Focus.PositionHint = true | |||
| 131 | + | c.cur().mon.AddWindow(1) | |||
| 132 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 133 | + | c.updatePositionHints(nil) | |||
| 134 | + | source := c.cur() | |||
| 135 | + | moved := source.mon.Current() | |||
| 136 | + | c.moveWorkspace(dirRight) | |||
| 137 | + | c.updatePositionHints(nil) | |||
| 138 | + | require.Same(t, moved, c.cur().mon.Current()) | |||
| 139 | + | require.False(t, source.positionHint.live) | |||
| 140 | + | require.True(t, c.cur().positionHint.sample.Shown()) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestPositionHintCapsAndProtection(t *testing.T) { | |||
| 144 | + | c, _ := indicatorCore(t) | |||
| 145 | + | c.cfg.Focus.PositionHint = true | |||
| 146 | + | m := c.cur().mon | |||
| 147 | + | for range 20 { | |||
| 148 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |||
| 149 | + | } | |||
| 150 | + | m.Active = 20 | |||
| 151 | + | h := positionHintGrid(m) | |||
| 152 | + | require.Equal(t, uint8(16), h.Counts[0]) | |||
| 153 | + | require.Equal(t, uint8(15), h.ActiveRow) | |||
| 154 | + | m.Current().Overflow = OverflowCascade | |||
| 155 | + | h = positionHintGrid(m) | |||
| 156 | + | require.Equal(t, uint8(16), h.Columns) | |||
| 157 | + | require.Equal(t, uint8(15), h.ActiveCol) | |||
| 158 | + | c.updatePositionHints(nil) | |||
| 159 | + | c.cur().positionHint.live = true | |||
| 160 | + | c.security.Protected = true | |||
| 161 | + | c.updatePositionHints(nil) | |||
| 162 | + | require.False(t, c.cur().positionHint.live) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestPositionHintIncludesNamedEmptyAndMixedOverflow(t *testing.T) { | |||
| 166 | + | c, _ := indicatorCore(t) | |||
| 167 | + | m := c.cur().mon | |||
| 168 | + | w := m.Current() | |||
| 169 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |||
| 170 | + | m.AddWindow(1) | |||
| 171 | + | m.AddWindow(2) | |||
| 172 | + | named := m.newWorkspace() | |||
| 173 | + | named.Name, named.Overflow = "hidden", OverflowScroll | |||
| 174 | + | named.Columns = []Column{{Windows: []WindowID{3}}, {Windows: []WindowID{4}, BandStart: true}} | |||
| 175 | + | m.hidden = append(m.hidden, named) | |||
| 176 | + | empty := m.newWorkspace() | |||
| 177 | + | empty.Name = "empty" | |||
| 178 | + | m.hidden = append(m.hidden, empty) | |||
| 179 | + | h := positionHintGrid(m) | |||
| 180 | + | require.Equal(t, uint8(4), h.Columns) | |||
| 181 | + | require.Equal(t, [16]uint8{2, 1, 1, 1}, h.Counts) | |||
| 182 | + | require.Equal(t, uint8(0), h.ActiveCol) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestPositionHintOverviewCommittedBaseline(t *testing.T) { | |||
| 186 | + | for _, cancel := range []bool{false, true} { | |||
| 187 | + | t.Run(map[bool]string{false: "accept", true: "cancel"}[cancel], func(t *testing.T) { | |||
| 188 | + | c, _ := indicatorCore(t) | |||
| 189 | + | c.cfg.Focus.PositionHint = true | |||
| 190 | + | m := c.cur().mon | |||
| 191 | + | m.AddWindow(1) | |||
| 192 | + | origin := m.Current() | |||
| 193 | + | other := m.Workspaces[1] | |||
| 194 | + | other.AddWindow(2) | |||
| 195 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |||
| 196 | + | m.ToggleOverview() | |||
| 197 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |||
| 198 | + | m.showOverview(other) | |||
| 199 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |||
| 200 | + | require.Same(t, origin, c.cur().positionHint.workspace) | |||
| 201 | + | m.showOverview(origin) | |||
| 202 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |||
| 203 | + | m.showOverview(other) | |||
| 204 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |||
| 205 | + | if cancel { | |||
| 206 | + | m.CancelOverview() | |||
| 207 | + | } else { | |||
| 208 | + | m.ToggleOverview() | |||
| 209 | + | } | |||
| 210 | + | require.Equal(t, !cancel, indicatorScene(t, c).PositionHint.Shown()) | |||
| 211 | + | }) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestPositionHintScrolledAwayFullscreen(t *testing.T) { | |||
| 216 | + | c, _ := indicatorCore(t) | |||
| 217 | + | c.cfg.Focus.PositionHint = true | |||
| 218 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 219 | + | a := c.screens[0] | |||
| 220 | + | w := a.mon.Current() | |||
| 221 | + | w.Overflow = OverflowScroll | |||
| 222 | + | a.mon.AddWindow(1) | |||
| 223 | + | a.mon.AddWindow(2) | |||
| 224 | + | w.fullscreen = 1 | |||
| 225 | + | w.View = w.columnX(1) | |||
| 226 | + | require.NotZero(t, w.fullscreen) | |||
| 227 | + | require.Zero(t, w.cover()) | |||
| 228 | + | c.focusScreen = 1 | |||
| 229 | + | c.updatePositionHints(nil) | |||
| 230 | + | c.focusScreen = 0 | |||
| 231 | + | c.updatePositionHints(nil) | |||
| 232 | + | require.True(t, a.positionHint.sample.Shown()) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestPositionHintStartupHotplugReload(t *testing.T) { | |||
| 236 | + | c, _ := indicatorCore(t) | |||
| 237 | + | c.cfg.Focus.PositionHint = true | |||
| 238 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) | |||
| 239 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 240 | + | for _, s := range stepScenes(t, c, nil) { | |||
| 241 | + | require.False(t, s.PositionHint.Shown()) | |||
| 242 | + | } | |||
| 243 | + | require.NoError(t, c.apply(c.cfg)) | |||
| 244 | + | for _, s := range stepScenes(t, c, nil) { | |||
| 245 | + | require.False(t, s.PositionHint.Shown()) | |||
| 246 | + | } | |||
| 247 | + | c.focusScreen = 1 | |||
| 248 | + | require.True(t, stepScenes(t, c, nil)["B"].PositionHint.Shown()) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestPositionHintMaxGridTransitionAllocations(t *testing.T) { | |||
| 252 | + | c := publishRig(t, 1) | |||
| 253 | + | c.cfg.Focus.PositionHint = true | |||
| 254 | + | m := c.cur().mon | |||
| 255 | + | for len(m.Workspaces) < 16 { | |||
| 256 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |||
| 257 | + | } | |||
| 258 | + | for _, w := range m.Workspaces { | |||
| 259 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 | |||
| 260 | + | w.Columns = make([]Column, 16) | |||
| 261 | + | for i := range w.Columns { | |||
| 262 | + | w.Columns[i] = Column{Windows: []WindowID{WindowID(i + 100)}, BandStart: true} | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | c.updatePositionHints(nil) | |||
| 266 | + | defer c.stopPositionHintTimer() | |||
| 267 | + | run := func() { m.Active = (m.Active + 1) % 16; c.updatePositionHints(nil) } | |||
| 268 | + | run() | |||
| 269 | + | require.Equal(t, [16]uint8{16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, c.cur().positionHint.sample.Counts) | |||
| 270 | + | require.Zero(t, testing.AllocsPerRun(100, run)) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func TestPositionHintRunTimerLifecycle(t *testing.T) { | |||
| 274 | + | synctest.Test(t, func(t *testing.T) { | |||
| 275 | + | c := publishRig(t, 2) | |||
| 276 | + | c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true | |||
| 277 | + | c.ch.Commands = nil | |||
| 278 | + | now := time.Unix(1000, 0) | |||
| 279 | + | clock := portsmocks.NewMockClock(t) | |||
| 280 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe() | |||
| 281 | + | timer := portsmocks.NewMockTimer(t) | |||
| 282 | + | tick := make(chan time.Time, 1) | |||
| 283 | + | timer.EXPECT().C().Return((<-chan time.Time)(tick)).Maybe() | |||
| 284 | + | timer.EXPECT().Reset(500 * time.Millisecond).Return(true).Once() | |||
| 285 | + | timer.EXPECT().Reset(100 * time.Millisecond).Return(true).Once() | |||
| 286 | + | timer.EXPECT().Stop().Return(true).Once() | |||
| 287 | + | timer.EXPECT().Reset(positionHintHold).Return(true).Once() | |||
| 288 | + | clock.EXPECT().NewTimer(positionHintHold).Return(timer).Once() | |||
| 289 | + | // The second timer is the existing animation fallback, not another hint timer. | |||
| 290 | + | frame := portsmocks.NewMockTimer(t) | |||
| 291 | + | frame.EXPECT().C().Return(make(chan time.Time)).Maybe() | |||
| 292 | + | frame.EXPECT().Stop().Return(true).Maybe() | |||
| 293 | + | frame.EXPECT().Reset(mock.Anything).Return(true).Maybe() | |||
| 294 | + | clock.EXPECT().NewTimer(mock.Anything).Return(frame).Maybe() | |||
| 295 | + | c.opts.Clock = clock | |||
| 296 | + | a, b := c.screens[0], c.screens[1] | |||
| 297 | + | a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1 | |||
| 298 | + | a.mon.Current().Focus = 1 | |||
| 299 | + | c.updatePositionHints(nil) | |||
| 300 | + | a.mon.Current().Focus = 0 | |||
| 301 | + | c.updatePositionHints(nil) | |||
| 302 | + | now = now.Add(100 * time.Millisecond) | |||
| 303 | + | c.focusScreen = 1 | |||
| 304 | + | c.updatePositionHints(nil) | |||
| 305 | + | require.Same(t, timer, c.positionHintTimer.timer) | |||
| 306 | + | frames := make(chan ports.OutputFrame) | |||
| 307 | + | c.ch.Frames = frames | |||
| 308 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 309 | + | done := make(chan error, 1) | |||
| 310 | + | go func() { done <- c.Run(ctx) }() | |||
| 311 | + | synctest.Wait() | |||
| 312 | + | now = now.Add(500 * time.Millisecond) | |||
| 313 | + | tick <- now | |||
| 314 | + | synctest.Wait() | |||
| 315 | + | require.True(t, a.positionHint.fading) | |||
| 316 | + | require.False(t, b.positionHint.fading) | |||
| 317 | + | // B still holds for 100ms; the same timer selects its deadline. | |||
| 318 | + | now = now.Add(100 * time.Millisecond) | |||
| 319 | + | tick <- now | |||
| 320 | + | synctest.Wait() | |||
| 321 | + | require.True(t, b.positionHint.fading) | |||
| 322 | + | require.Nil(t, c.positionHintTimer.timerC) | |||
| 323 | + | now = now.Add(positionHintFade) | |||
| 324 | + | frames <- ports.OutputFrame{Output: "A"} | |||
| 325 | + | synctest.Wait() | |||
| 326 | + | frames <- ports.OutputFrame{Output: "B"} | |||
| 327 | + | synctest.Wait() | |||
| 328 | + | require.False(t, a.positionHint.sample.Shown()) | |||
| 329 | + | require.False(t, b.positionHint.sample.Shown()) | |||
| 330 | + | require.False(t, c.animating()) | |||
| 331 | + | require.Nil(t, c.positionHintTimer.timerC) | |||
| 332 | + | // Owner is durably blocked: disabling a newly restarted hint stops | |||
| 333 | + | // the retained timer instead of leaving an idle wakeup armed. | |||
| 334 | + | c.focusScreen = 0 | |||
| 335 | + | c.updatePositionHints(nil) | |||
| 336 | + | require.NotNil(t, c.positionHintTimer.timerC) | |||
| 337 | + | c.cfg.Focus.PositionHint = false | |||
| 338 | + | c.updatePositionHints(nil) | |||
| 339 | + | require.Nil(t, c.positionHintTimer.timerC) | |||
| 340 | + | cancel() | |||
| 341 | + | synctest.Wait() | |||
| 342 | + | require.NoError(t, <-done) | |||
| 343 | + | }) | |||
| 344 | + | } | |||
M internal/core/scene.go +1 -0
| @@ -121,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 121 | 121 | // The drag icon is over everything, popups and layers included. | |
| 122 | 122 | scene.Windows = append(scene.Windows, icon) | |
| 123 | 123 | } | |
| 124 | + | scene.PositionHint = sc.positionHint.sample | |
| 124 | 125 | scene.Flash = c.flashFor(sc) | |
| 125 | 126 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 126 | 127 | scene.HintLabels = c.hintLabels(sc) | |
| @@ -121,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 121 | // The drag icon is over everything, popups and layers included. | 121 | // The drag icon is over everything, popups and layers included. | ||
| 122 | scene.Windows = append(scene.Windows, icon) | 122 | scene.Windows = append(scene.Windows, icon) | ||
| 123 | } | 123 | } | ||
| 124 | + | scene.PositionHint = sc.positionHint.sample | |||
| 124 | scene.Flash = c.flashFor(sc) | 125 | scene.Flash = c.flashFor(sc) | ||
| 125 | scene.CaptureIndicators = c.captureIndicators(sc) | 126 | scene.CaptureIndicators = c.captureIndicators(sc) | ||
| 126 | scene.HintLabels = c.hintLabels(sc) | 127 | scene.HintLabels = c.hintLabels(sc) | ||
M internal/core/screens.go +1 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | // place in the global layout (ADR 011). Outputs are ordered by config | |
| 11 | 11 | // (output.<name>.*), then by connection; order places them (placeOutputs). | |
| 12 | 12 | type screen struct { | |
| 13 | + | positionHint positionHintState | |
| 13 | 14 | info ports.OutputInfo | |
| 14 | 15 | mon *Monitor | |
| 15 | 16 | scale, cfgScale float64 | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | // place in the global layout (ADR 011). Outputs are ordered by config | 10 | // place in the global layout (ADR 011). Outputs are ordered by config | ||
| 11 | // (output.<name>.*), then by connection; order places them (placeOutputs). | 11 | // (output.<name>.*), then by connection; order places them (placeOutputs). | ||
| 12 | type screen struct { | 12 | type screen struct { | ||
| 13 | + | positionHint positionHintState | |||
| 13 | info ports.OutputInfo | 14 | info ports.OutputInfo | ||
| 14 | mon *Monitor | 15 | mon *Monitor | ||
| 15 | scale, cfgScale float64 | 16 | scale, cfgScale float64 | ||
M internal/core/security.go +4 -0
| @@ -48,6 +48,10 @@ func (c *Core) syncSecurity() bool { | |||
| 48 | 48 | c.gesture = clientGesture{} | |
| 49 | 49 | c.stopAnimations() | |
| 50 | 50 | c.stopFlash() | |
| 51 | + | c.stopPositionHintTimer() | |
| 52 | + | for _, sc := range c.screens { | |
| 53 | + | sc.positionHint = positionHintState{} | |
| 54 | + | } | |
| 51 | 55 | c.cancelSwitcher() | |
| 52 | 56 | c.stopRepeat() | |
| 53 | 57 | if state.Protected { | |
| @@ -48,6 +48,10 @@ func (c *Core) syncSecurity() bool { | |||||
| 48 | c.gesture = clientGesture{} | 48 | c.gesture = clientGesture{} | ||
| 49 | c.stopAnimations() | 49 | c.stopAnimations() | ||
| 50 | c.stopFlash() | 50 | c.stopFlash() | ||
| 51 | + | c.stopPositionHintTimer() | |||
| 52 | + | for _, sc := range c.screens { | |||
| 53 | + | sc.positionHint = positionHintState{} | |||
| 54 | + | } | |||
| 51 | c.cancelSwitcher() | 55 | c.cancelSwitcher() | ||
| 52 | c.stopRepeat() | 56 | c.stopRepeat() | ||
| 53 | if state.Protected { | 57 | if state.Protected { | ||
M internal/ports/config.go +1 -0
| @@ -155,6 +155,7 @@ type Config struct { | |||
| 155 | 155 | // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade | |
| 156 | 156 | // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or | |
| 157 | 157 | // empty for Border.Active. | |
| 158 | + | PositionHint bool | |
| 158 | 159 | Flash bool | |
| 159 | 160 | FlashWidth int | |
| 160 | 161 | FlashOpacity float64 | |
| @@ -155,6 +155,7 @@ type Config struct { | |||||
| 155 | // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade | 155 | // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade | ||
| 156 | // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or | 156 | // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or | ||
| 157 | // empty for Border.Active. | 157 | // empty for Border.Active. | ||
| 158 | + | PositionHint bool | |||
| 158 | Flash bool | 159 | Flash bool | ||
| 159 | FlashWidth int | 160 | FlashWidth int | ||
| 160 | FlashOpacity float64 | 161 | FlashOpacity float64 | ||
A internal/ports/position_hint.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | const ( | |
| 4 | + | PositionHintMaxColumns = 16 | |
| 5 | + | PositionHintMaxRows = 16 | |
| 6 | + | PositionHintDiameter = 8 | |
| 7 | + | PositionHintGap = 10 | |
| 8 | + | PositionHintPadding = 10 | |
| 9 | + | PositionHintBottom = 48 | |
| 10 | + | PositionHintRadius = 10 | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // PositionHint is a bounded, comparable snapshot. Scenes copy it by value; | |
| 14 | + | // neither fading nor a changed grid shares mutable storage with a renderer. | |
| 15 | + | type PositionHint struct { | |
| 16 | + | Opacity float32 | |
| 17 | + | Vertical bool | |
| 18 | + | Columns uint8 | |
| 19 | + | Counts [PositionHintMaxColumns]uint8 | |
| 20 | + | ActiveCol, ActiveRow uint8 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (h PositionHint) Shown() bool { return h.Opacity > 0 && h.Columns > 0 } | |
| 24 | + | ||
| 25 | + | // Bounds is the hint's logical output-local background rectangle. | |
| 26 | + | func (h PositionHint) Bounds(width, height int) Rect { | |
| 27 | + | if !h.Shown() { | |
| 28 | + | return Rect{} | |
| 29 | + | } | |
| 30 | + | cols, rows := min(int(h.Columns), PositionHintMaxColumns), 1 | |
| 31 | + | for _, n := range h.Counts[:cols] { | |
| 32 | + | rows = max(rows, min(int(n), PositionHintMaxRows)) | |
| 33 | + | } | |
| 34 | + | w := 2*PositionHintPadding + cols*PositionHintDiameter + (cols-1)*PositionHintGap | |
| 35 | + | ht := 2*PositionHintPadding + rows*PositionHintDiameter + (rows-1)*PositionHintGap | |
| 36 | + | return Rect{X: (width - w) / 2, Y: height - PositionHintBottom - ht, W: w, H: ht} | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | const ( | |||
| 4 | + | PositionHintMaxColumns = 16 | |||
| 5 | + | PositionHintMaxRows = 16 | |||
| 6 | + | PositionHintDiameter = 8 | |||
| 7 | + | PositionHintGap = 10 | |||
| 8 | + | PositionHintPadding = 10 | |||
| 9 | + | PositionHintBottom = 48 | |||
| 10 | + | PositionHintRadius = 10 | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // PositionHint is a bounded, comparable snapshot. Scenes copy it by value; | |||
| 14 | + | // neither fading nor a changed grid shares mutable storage with a renderer. | |||
| 15 | + | type PositionHint struct { | |||
| 16 | + | Opacity float32 | |||
| 17 | + | Vertical bool | |||
| 18 | + | Columns uint8 | |||
| 19 | + | Counts [PositionHintMaxColumns]uint8 | |||
| 20 | + | ActiveCol, ActiveRow uint8 | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (h PositionHint) Shown() bool { return h.Opacity > 0 && h.Columns > 0 } | |||
| 24 | + | ||||
| 25 | + | // Bounds is the hint's logical output-local background rectangle. | |||
| 26 | + | func (h PositionHint) Bounds(width, height int) Rect { | |||
| 27 | + | if !h.Shown() { | |||
| 28 | + | return Rect{} | |||
| 29 | + | } | |||
| 30 | + | cols, rows := min(int(h.Columns), PositionHintMaxColumns), 1 | |||
| 31 | + | for _, n := range h.Counts[:cols] { | |||
| 32 | + | rows = max(rows, min(int(n), PositionHintMaxRows)) | |||
| 33 | + | } | |||
| 34 | + | w := 2*PositionHintPadding + cols*PositionHintDiameter + (cols-1)*PositionHintGap | |||
| 35 | + | ht := 2*PositionHintPadding + rows*PositionHintDiameter + (rows-1)*PositionHintGap | |||
| 36 | + | return Rect{X: (width - w) / 2, Y: height - PositionHintBottom - ht, W: w, H: ht} | |||
| 37 | + | } | |||
M internal/ports/scene.go +3 -1
| @@ -77,6 +77,8 @@ type Scene struct { | |||
| 77 | 77 | // Flash is the focus flash drawn over everything of this output; nil | |
| 78 | 78 | // draws nothing. | |
| 79 | 79 | Flash *FocusFlash | |
| 80 | + | // PositionHint is drawn on the physical output only, never in captures. | |
| 81 | + | PositionHint PositionHint | |
| 80 | 82 | // Animating is set by core when a spring or a focus flash runs on | |
| 81 | 83 | // this output after this frame was computed: the output reports the | |
| 82 | 84 | // flip of the scene to core (OutputFrame), which moves the animation to | |
| @@ -92,7 +94,7 @@ type Scene struct { | |||
| 92 | 94 | // is not compared). Add every new Scene field here; | |
| 93 | 95 | // TestSceneSameAsCoversEveryField fails until the field count is updated. | |
| 94 | 96 | func (s Scene) SameAs(o Scene) bool { | |
| 95 | - | if s.Security != o.Security || s.Output != o.Output || | |
| 97 | + | if s.PositionHint != o.PositionHint || s.Security != o.Security || s.Output != o.Output || | |
| 96 | 98 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | |
| 97 | 99 | s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off || | |
| 98 | 100 | s.Background != o.Background || s.Border != o.Border || | |
| @@ -77,6 +77,8 @@ type Scene struct { | |||||
| 77 | // Flash is the focus flash drawn over everything of this output; nil | 77 | // Flash is the focus flash drawn over everything of this output; nil | ||
| 78 | // draws nothing. | 78 | // draws nothing. | ||
| 79 | Flash *FocusFlash | 79 | Flash *FocusFlash | ||
| 80 | + | // PositionHint is drawn on the physical output only, never in captures. | |||
| 81 | + | PositionHint PositionHint | |||
| 80 | // Animating is set by core when a spring or a focus flash runs on | 82 | // Animating is set by core when a spring or a focus flash runs on | ||
| 81 | // this output after this frame was computed: the output reports the | 83 | // this output after this frame was computed: the output reports the | ||
| 82 | // flip of the scene to core (OutputFrame), which moves the animation to | 84 | // flip of the scene to core (OutputFrame), which moves the animation to | ||
| @@ -92,7 +94,7 @@ type Scene struct { | |||||
| 92 | // is not compared). Add every new Scene field here; | 94 | // is not compared). Add every new Scene field here; | ||
| 93 | // TestSceneSameAsCoversEveryField fails until the field count is updated. | 95 | // TestSceneSameAsCoversEveryField fails until the field count is updated. | ||
| 94 | func (s Scene) SameAs(o Scene) bool { | 96 | func (s Scene) SameAs(o Scene) bool { | ||
| 95 | - | if s.Security != o.Security || s.Output != o.Output || | 97 | + | if s.PositionHint != o.PositionHint || s.Security != o.Security || s.Output != o.Output || |
| 96 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | 98 | s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight || | ||
| 97 | s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off || | 99 | s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off || | ||
| 98 | s.Background != o.Background || s.Border != o.Border || | 100 | s.Background != o.Background || s.Border != o.Border || | ||
M internal/ports/scene_same_test.go +2 -1
| @@ -38,6 +38,7 @@ func TestSceneSameAs(t *testing.T) { | |||
| 38 | 38 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | |
| 39 | 39 | ||
| 40 | 40 | for name, edit := range map[string]func(*Scene){ | |
| 41 | + | "position hint": func(s *Scene) { s.PositionHint.Opacity = 1 }, | |
| 41 | 42 | "flash nil": func(s *Scene) { s.Flash = nil }, | |
| 42 | 43 | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | |
| 43 | 44 | "flash width": func(s *Scene) { s.Flash.Width++ }, | |
| @@ -93,7 +94,7 @@ func TestSceneSameAs(t *testing.T) { | |||
| 93 | 94 | func TestSceneSameAsCoversEveryField(t *testing.T) { | |
| 94 | 95 | // 25 with Animating, which SameAs intentionally does not compare: it | |
| 95 | 96 | // only schedules the next frame, it draws nothing. | |
| 96 | - | const fields = 25 | |
| 97 | + | const fields = 26 | |
| 97 | 98 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 98 | 99 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 99 | 100 | } | |
| @@ -38,6 +38,7 @@ func TestSceneSameAs(t *testing.T) { | |||||
| 38 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | 38 | assert.True(t, Scene{}.SameAs(Scene{Seq: 3})) | ||
| 39 | 39 | ||||
| 40 | for name, edit := range map[string]func(*Scene){ | 40 | for name, edit := range map[string]func(*Scene){ | ||
| 41 | + | "position hint": func(s *Scene) { s.PositionHint.Opacity = 1 }, | |||
| 41 | "flash nil": func(s *Scene) { s.Flash = nil }, | 42 | "flash nil": func(s *Scene) { s.Flash = nil }, | ||
| 42 | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | 43 | "flash rect": func(s *Scene) { s.Flash.Rect.X++ }, | ||
| 43 | "flash width": func(s *Scene) { s.Flash.Width++ }, | 44 | "flash width": func(s *Scene) { s.Flash.Width++ }, | ||
| @@ -93,7 +94,7 @@ func TestSceneSameAs(t *testing.T) { | |||||
| 93 | func TestSceneSameAsCoversEveryField(t *testing.T) { | 94 | func TestSceneSameAsCoversEveryField(t *testing.T) { | ||
| 94 | // 25 with Animating, which SameAs intentionally does not compare: it | 95 | // 25 with Animating, which SameAs intentionally does not compare: it | ||
| 95 | // only schedules the next frame, it draws nothing. | 96 | // only schedules the next frame, it draws nothing. | ||
| 96 | - | const fields = 25 | 97 | + | const fields = 26 |
| 97 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | 98 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | ||
| 98 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | 99 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | ||
| 99 | } | 100 | } | ||