Merge ba1d5fd7a20428d8eb45e2575730780e64b96283 into d5d679d9bbf4d05853071f869a5fc768109095c6
23 files · +787 -17
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 +57 -0
| @@ -0,0 +1,57 @@ | |||
| 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 | + | // The active dot is white, opaque and larger, whatever border.active is: | |
| 20 | + | // a grey accent must not blend it into the other dots of its column. | |
| 21 | + | for col := range min(int(h.Columns), ports.PositionHintMaxColumns) { | |
| 22 | + | for row := range min(int(h.Counts[col]), ports.PositionHintMaxRows) { | |
| 23 | + | color, opacity, d := [3]uint8{190, 190, 200}, float32(0.2), ports.PositionHintDiameter | |
| 24 | + | if !h.Vertical && col == int(h.ActiveCol) { | |
| 25 | + | opacity = 0.45 | |
| 26 | + | } | |
| 27 | + | if col == int(h.ActiveCol) && row == int(h.ActiveRow) { | |
| 28 | + | color, opacity, d = [3]uint8{255, 255, 255}, 1, ports.PositionHintActiveDiameter | |
| 29 | + | } | |
| 30 | + | w.alpha = h.Opacity * opacity | |
| 31 | + | // Every dot is centered in its PositionHintDiameter cell. | |
| 32 | + | inset := (ports.PositionHintDiameter - d) / 2 | |
| 33 | + | x := b.X + ports.PositionHintPadding + col*(ports.PositionHintDiameter+ports.PositionHintGap) + inset | |
| 34 | + | y := b.Y + ports.PositionHintPadding + row*(ports.PositionHintDiameter+ports.PositionHintGap) + inset | |
| 35 | + | w.hintRound(w.physRect(x, y, d, d), w.phys(d)/2, color) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | w.alpha = alpha | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Rounded fills use the existing solid instances and batching pipeline. Each | |
| 42 | + | // physical scanline is bounded by the scaled radius; no textures or per-frame | |
| 43 | + | // buffers are allocated. Non-overlapping strips preserve translucent alpha. | |
| 44 | + | func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) { | |
| 45 | + | radius = min(radius, r.Dx()/2, r.Dy()/2) | |
| 46 | + | if radius <= 0 { | |
| 47 | + | w.fill(r, color) | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | w.fill(image.Rect(r.Min.X, r.Min.Y+radius, r.Max.X, r.Max.Y-radius), color) | |
| 51 | + | for y := range radius { | |
| 52 | + | dy := float64(radius-y) - 0.5 | |
| 53 | + | inset := radius - int(math.Sqrt(float64(radius*radius)-dy*dy)+0.5) | |
| 54 | + | w.fill(image.Rect(r.Min.X+inset, r.Min.Y+y, r.Max.X-inset, r.Min.Y+y+1), color) | |
| 55 | + | w.fill(image.Rect(r.Min.X+inset, r.Max.Y-y-1, r.Max.X-inset, r.Max.Y-y), color) | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 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 | + | // The active dot is white, opaque and larger, whatever border.active is: | |||
| 20 | + | // a grey accent must not blend it into the other dots of its column. | |||
| 21 | + | for col := range min(int(h.Columns), ports.PositionHintMaxColumns) { | |||
| 22 | + | for row := range min(int(h.Counts[col]), ports.PositionHintMaxRows) { | |||
| 23 | + | color, opacity, d := [3]uint8{190, 190, 200}, float32(0.2), ports.PositionHintDiameter | |||
| 24 | + | if !h.Vertical && col == int(h.ActiveCol) { | |||
| 25 | + | opacity = 0.45 | |||
| 26 | + | } | |||
| 27 | + | if col == int(h.ActiveCol) && row == int(h.ActiveRow) { | |||
| 28 | + | color, opacity, d = [3]uint8{255, 255, 255}, 1, ports.PositionHintActiveDiameter | |||
| 29 | + | } | |||
| 30 | + | w.alpha = h.Opacity * opacity | |||
| 31 | + | // Every dot is centered in its PositionHintDiameter cell. | |||
| 32 | + | inset := (ports.PositionHintDiameter - d) / 2 | |||
| 33 | + | x := b.X + ports.PositionHintPadding + col*(ports.PositionHintDiameter+ports.PositionHintGap) + inset | |||
| 34 | + | y := b.Y + ports.PositionHintPadding + row*(ports.PositionHintDiameter+ports.PositionHintGap) + inset | |||
| 35 | + | w.hintRound(w.physRect(x, y, d, d), w.phys(d)/2, color) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | w.alpha = alpha | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Rounded fills use the existing solid instances and batching pipeline. Each | |||
| 42 | + | // physical scanline is bounded by the scaled radius; no textures or per-frame | |||
| 43 | + | // buffers are allocated. Non-overlapping strips preserve translucent alpha. | |||
| 44 | + | func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) { | |||
| 45 | + | radius = min(radius, r.Dx()/2, r.Dy()/2) | |||
| 46 | + | if radius <= 0 { | |||
| 47 | + | w.fill(r, color) | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | w.fill(image.Rect(r.Min.X, r.Min.Y+radius, r.Max.X, r.Max.Y-radius), color) | |||
| 51 | + | for y := range radius { | |||
| 52 | + | dy := float64(radius-y) - 0.5 | |||
| 53 | + | inset := radius - int(math.Sqrt(float64(radius*radius)-dy*dy)+0.5) | |||
| 54 | + | w.fill(image.Rect(r.Min.X+inset, r.Min.Y+y, r.Max.X-inset, r.Min.Y+y+1), color) | |||
| 55 | + | w.fill(image.Rect(r.Min.X+inset, r.Max.Y-y-1, r.Max.X-inset, r.Max.Y-y), color) | |||
| 56 | + | } | |||
| 57 | + | } | |||
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 +7 -5
| @@ -586,19 +586,21 @@ func (c *Core) animate(now time.Time, only *screen) { | |||
| 586 | 586 | } | |
| 587 | 587 | } | |
| 588 | 588 | ||
| 589 | - | // sliding reports whether a spring or focus flash runs on the named output. | |
| 589 | + | // sliding reports whether a spring, focus flash or position hint fade runs | |
| 590 | + | // on the named output. | |
| 590 | 591 | func (c *Core) sliding(output string) bool { | |
| 591 | 592 | i := c.screenIndex(output) | |
| 592 | - | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) | |
| 593 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]) || c.positionHintFading(c.screens[i])) | |
| 593 | 594 | } | |
| 594 | 595 | ||
| 595 | - | // animating reports whether a spring or focus flash runs on any output. | |
| 596 | + | // animating reports whether a spring, focus flash or position hint fade | |
| 597 | + | // runs on any output. | |
| 596 | 598 | func (c *Core) animating() bool { | |
| 597 | 599 | if c.flash.running != nil { | |
| 598 | 600 | return true | |
| 599 | 601 | } | |
| 600 | 602 | for _, sc := range c.screens { | |
| 601 | - | if sc.springing() { | |
| 603 | + | if sc.springing() || c.positionHintFading(sc) { | |
| 602 | 604 | return true | |
| 603 | 605 | } | |
| 604 | 606 | } | |
| @@ -622,7 +624,7 @@ func (m *Monitor) springing() bool { | |||
| 622 | 624 | func (c *Core) frameFallback() time.Duration { | |
| 623 | 625 | refresh := 60000 | |
| 624 | 626 | for _, sc := range c.screens { | |
| 625 | - | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { | |
| 627 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) { | |
| 626 | 628 | refresh = min(refresh, r) | |
| 627 | 629 | } | |
| 628 | 630 | } | |
| @@ -586,19 +586,21 @@ func (c *Core) animate(now time.Time, only *screen) { | |||||
| 586 | } | 586 | } | ||
| 587 | } | 587 | } | ||
| 588 | 588 | ||||
| 589 | - | // sliding reports whether a spring or focus flash runs on the named output. | 589 | + | // sliding reports whether a spring, focus flash or position hint fade runs |
| 590 | + | // on the named output. | |||
| 590 | func (c *Core) sliding(output string) bool { | 591 | func (c *Core) sliding(output string) bool { | ||
| 591 | i := c.screenIndex(output) | 592 | i := c.screenIndex(output) | ||
| 592 | - | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i])) | 593 | + | return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]) || c.positionHintFading(c.screens[i])) |
| 593 | } | 594 | } | ||
| 594 | 595 | ||||
| 595 | - | // animating reports whether a spring or focus flash runs on any output. | 596 | + | // animating reports whether a spring, focus flash or position hint fade |
| 597 | + | // runs on any output. | |||
| 596 | func (c *Core) animating() bool { | 598 | func (c *Core) animating() bool { | ||
| 597 | if c.flash.running != nil { | 599 | if c.flash.running != nil { | ||
| 598 | return true | 600 | return true | ||
| 599 | } | 601 | } | ||
| 600 | for _, sc := range c.screens { | 602 | for _, sc := range c.screens { | ||
| 601 | - | if sc.springing() { | 603 | + | if sc.springing() || c.positionHintFading(sc) { |
| 602 | return true | 604 | return true | ||
| 603 | } | 605 | } | ||
| 604 | } | 606 | } | ||
| @@ -622,7 +624,7 @@ func (m *Monitor) springing() bool { | |||||
| 622 | func (c *Core) frameFallback() time.Duration { | 624 | func (c *Core) frameFallback() time.Duration { | ||
| 623 | refresh := 60000 | 625 | refresh := 60000 | ||
| 624 | for _, sc := range c.screens { | 626 | for _, sc := range c.screens { | ||
| 625 | - | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) { | 627 | + | if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) { |
| 626 | refresh = min(refresh, r) | 628 | refresh = min(refresh, r) | ||
| 627 | } | 629 | } | ||
| 628 | } | 630 | } | ||
A internal/core/position_hint.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 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 | + | // Disabled hints skip the band walk; dropping the baseline keeps a | |
| 45 | + | // later re-enable from announcing the position it finds. | |
| 46 | + | if !c.cfg.Focus.PositionHint { | |
| 47 | + | *h = positionHintState{} | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | w := sc.mon.Current() | |
| 51 | + | band := 0 | |
| 52 | + | if w.Overflow == OverflowCascade { | |
| 53 | + | band = w.band(w.Focus) | |
| 54 | + | } | |
| 55 | + | focused := i == c.focusScreen | |
| 56 | + | changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused) | |
| 57 | + | // Transferring the current workspace changes the source's fallback | |
| 58 | + | // position too, but only the receiving output should announce it. | |
| 59 | + | if h.initialized && h.workspace != w { | |
| 60 | + | for _, other := range c.screens { | |
| 61 | + | if other != sc && other.mon.has(h.workspace) { | |
| 62 | + | changed = false | |
| 63 | + | break | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | h.workspace, h.band, h.focused, h.initialized = w, band, focused, true | |
| 68 | + | if c.protectionRequested() || w.cover() != 0 { | |
| 69 | + | h.live, h.sample = false, ports.PositionHint{} | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | if changed { | |
| 73 | + | h.start, h.live, h.fading = c.now(), true, false | |
| 74 | + | } | |
| 75 | + | if !h.live { | |
| 76 | + | continue | |
| 77 | + | } | |
| 78 | + | if !changed && only != nil && only != sc { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | elapsed := c.now().Sub(h.start) | |
| 82 | + | opacity := float32(1) | |
| 83 | + | h.fading = elapsed >= positionHintHold | |
| 84 | + | if h.fading { | |
| 85 | + | if !c.animOn() || elapsed >= positionHintHold+positionHintFade { | |
| 86 | + | h.live, h.sample = false, ports.PositionHint{} | |
| 87 | + | continue | |
| 88 | + | } | |
| 89 | + | opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade) | |
| 90 | + | } | |
| 91 | + | h.sample = positionHintGrid(sc.mon) | |
| 92 | + | h.sample.Opacity = opacity | |
| 93 | + | } | |
| 94 | + | c.armPositionHintTimer() | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Use ext-workspace's complete ordered list: numbered (including empty | |
| 98 | + | // spares), then configured hidden named workspaces. Unlike overview cards, | |
| 99 | + | // these remain addressable even when empty. Window the list at its active | |
| 100 | + | // entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp. | |
| 101 | + | func positionHintGrid(m *Monitor) ports.PositionHint { | |
| 102 | + | h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis} | |
| 103 | + | active, total := 0, 0 | |
| 104 | + | for w := range m.all() { | |
| 105 | + | if w == m.Current() { | |
| 106 | + | active = total | |
| 107 | + | } | |
| 108 | + | total++ | |
| 109 | + | } | |
| 110 | + | cap := ports.PositionHintMaxColumns | |
| 111 | + | if h.Vertical { | |
| 112 | + | cap = ports.PositionHintMaxRows | |
| 113 | + | } | |
| 114 | + | first := max(0, active-cap+1) | |
| 115 | + | if h.Vertical { | |
| 116 | + | h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first) | |
| 117 | + | return h | |
| 118 | + | } | |
| 119 | + | h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first) | |
| 120 | + | i := 0 | |
| 121 | + | for w := range m.all() { | |
| 122 | + | if i >= first && i < first+cap { | |
| 123 | + | n := 1 | |
| 124 | + | if w.Overflow == OverflowCascade { | |
| 125 | + | n, _ = w.cascadeBands() | |
| 126 | + | } | |
| 127 | + | h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows)) | |
| 128 | + | } | |
| 129 | + | i++ | |
| 130 | + | } | |
| 131 | + | w := m.Current() | |
| 132 | + | if w.Overflow == OverflowCascade { | |
| 133 | + | h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1)) | |
| 134 | + | } | |
| 135 | + | return h | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // positionHintFading drives fade frames through the shared scene publication, | |
| 139 | + | // like springs and the focus flash: each fade frame republishes the whole | |
| 140 | + | // scene, which allocates its slices. The hint state itself allocates nothing. | |
| 141 | + | func (c *Core) positionHintFading(sc *screen) bool { | |
| 142 | + | return sc.positionHint.live && sc.positionHint.fading | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // One timer wakes at the earliest hold boundary. Fade frames use the existing | |
| 146 | + | // flip/fallback scheduler; with no live hints no timer is armed or clock read. | |
| 147 | + | func (c *Core) armPositionHintTimer() { | |
| 148 | + | var earliest time.Time | |
| 149 | + | for _, sc := range c.screens { | |
| 150 | + | if sc.positionHint.live && !sc.positionHint.fading { | |
| 151 | + | end := sc.positionHint.start.Add(positionHintHold) | |
| 152 | + | if earliest.IsZero() || end.Before(earliest) { | |
| 153 | + | earliest = end | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | t := &c.positionHintTimer | |
| 158 | + | if earliest.IsZero() { | |
| 159 | + | c.stopPositionHintTimer() | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | d := max(earliest.Sub(c.now()), time.Nanosecond) | |
| 163 | + | if t.timer == nil { | |
| 164 | + | t.timer = newPortTimer(c.opts.Clock, d) | |
| 165 | + | } else { | |
| 166 | + | t.timer.Reset(d) | |
| 167 | + | } | |
| 168 | + | t.timerC = t.timer.C() | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (c *Core) stopPositionHintTimer() { | |
| 172 | + | t := &c.positionHintTimer | |
| 173 | + | if t.timerC != nil { | |
| 174 | + | t.timer.Stop() | |
| 175 | + | t.timerC = nil | |
| 176 | + | } | |
| 177 | + | } | |
| @@ -0,0 +1,177 @@ | |||||
| 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 | + | // Disabled hints skip the band walk; dropping the baseline keeps a | |||
| 45 | + | // later re-enable from announcing the position it finds. | |||
| 46 | + | if !c.cfg.Focus.PositionHint { | |||
| 47 | + | *h = positionHintState{} | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | w := sc.mon.Current() | |||
| 51 | + | band := 0 | |||
| 52 | + | if w.Overflow == OverflowCascade { | |||
| 53 | + | band = w.band(w.Focus) | |||
| 54 | + | } | |||
| 55 | + | focused := i == c.focusScreen | |||
| 56 | + | changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused) | |||
| 57 | + | // Transferring the current workspace changes the source's fallback | |||
| 58 | + | // position too, but only the receiving output should announce it. | |||
| 59 | + | if h.initialized && h.workspace != w { | |||
| 60 | + | for _, other := range c.screens { | |||
| 61 | + | if other != sc && other.mon.has(h.workspace) { | |||
| 62 | + | changed = false | |||
| 63 | + | break | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | h.workspace, h.band, h.focused, h.initialized = w, band, focused, true | |||
| 68 | + | if c.protectionRequested() || w.cover() != 0 { | |||
| 69 | + | h.live, h.sample = false, ports.PositionHint{} | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | if changed { | |||
| 73 | + | h.start, h.live, h.fading = c.now(), true, false | |||
| 74 | + | } | |||
| 75 | + | if !h.live { | |||
| 76 | + | continue | |||
| 77 | + | } | |||
| 78 | + | if !changed && only != nil && only != sc { | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | elapsed := c.now().Sub(h.start) | |||
| 82 | + | opacity := float32(1) | |||
| 83 | + | h.fading = elapsed >= positionHintHold | |||
| 84 | + | if h.fading { | |||
| 85 | + | if !c.animOn() || elapsed >= positionHintHold+positionHintFade { | |||
| 86 | + | h.live, h.sample = false, ports.PositionHint{} | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade) | |||
| 90 | + | } | |||
| 91 | + | h.sample = positionHintGrid(sc.mon) | |||
| 92 | + | h.sample.Opacity = opacity | |||
| 93 | + | } | |||
| 94 | + | c.armPositionHintTimer() | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Use ext-workspace's complete ordered list: numbered (including empty | |||
| 98 | + | // spares), then configured hidden named workspaces. Unlike overview cards, | |||
| 99 | + | // these remain addressable even when empty. Window the list at its active | |||
| 100 | + | // entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp. | |||
| 101 | + | func positionHintGrid(m *Monitor) ports.PositionHint { | |||
| 102 | + | h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis} | |||
| 103 | + | active, total := 0, 0 | |||
| 104 | + | for w := range m.all() { | |||
| 105 | + | if w == m.Current() { | |||
| 106 | + | active = total | |||
| 107 | + | } | |||
| 108 | + | total++ | |||
| 109 | + | } | |||
| 110 | + | cap := ports.PositionHintMaxColumns | |||
| 111 | + | if h.Vertical { | |||
| 112 | + | cap = ports.PositionHintMaxRows | |||
| 113 | + | } | |||
| 114 | + | first := max(0, active-cap+1) | |||
| 115 | + | if h.Vertical { | |||
| 116 | + | h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first) | |||
| 117 | + | return h | |||
| 118 | + | } | |||
| 119 | + | h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first) | |||
| 120 | + | i := 0 | |||
| 121 | + | for w := range m.all() { | |||
| 122 | + | if i >= first && i < first+cap { | |||
| 123 | + | n := 1 | |||
| 124 | + | if w.Overflow == OverflowCascade { | |||
| 125 | + | n, _ = w.cascadeBands() | |||
| 126 | + | } | |||
| 127 | + | h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows)) | |||
| 128 | + | } | |||
| 129 | + | i++ | |||
| 130 | + | } | |||
| 131 | + | w := m.Current() | |||
| 132 | + | if w.Overflow == OverflowCascade { | |||
| 133 | + | h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1)) | |||
| 134 | + | } | |||
| 135 | + | return h | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // positionHintFading drives fade frames through the shared scene publication, | |||
| 139 | + | // like springs and the focus flash: each fade frame republishes the whole | |||
| 140 | + | // scene, which allocates its slices. The hint state itself allocates nothing. | |||
| 141 | + | func (c *Core) positionHintFading(sc *screen) bool { | |||
| 142 | + | return sc.positionHint.live && sc.positionHint.fading | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // One timer wakes at the earliest hold boundary. Fade frames use the existing | |||
| 146 | + | // flip/fallback scheduler; with no live hints no timer is armed or clock read. | |||
| 147 | + | func (c *Core) armPositionHintTimer() { | |||
| 148 | + | var earliest time.Time | |||
| 149 | + | for _, sc := range c.screens { | |||
| 150 | + | if sc.positionHint.live && !sc.positionHint.fading { | |||
| 151 | + | end := sc.positionHint.start.Add(positionHintHold) | |||
| 152 | + | if earliest.IsZero() || end.Before(earliest) { | |||
| 153 | + | earliest = end | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | t := &c.positionHintTimer | |||
| 158 | + | if earliest.IsZero() { | |||
| 159 | + | c.stopPositionHintTimer() | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | d := max(earliest.Sub(c.now()), time.Nanosecond) | |||
| 163 | + | if t.timer == nil { | |||
| 164 | + | t.timer = newPortTimer(c.opts.Clock, d) | |||
| 165 | + | } else { | |||
| 166 | + | t.timer.Reset(d) | |||
| 167 | + | } | |||
| 168 | + | t.timerC = t.timer.C() | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (c *Core) stopPositionHintTimer() { | |||
| 172 | + | t := &c.positionHintTimer | |||
| 173 | + | if t.timerC != nil { | |||
| 174 | + | t.timer.Stop() | |||
| 175 | + | t.timerC = nil | |||
| 176 | + | } | |||
| 177 | + | } | |||
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{"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{"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 +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | const ( | |
| 4 | + | PositionHintMaxColumns = 16 | |
| 5 | + | PositionHintMaxRows = 16 | |
| 6 | + | PositionHintDiameter = 8 | |
| 7 | + | // PositionHintActiveDiameter fits in the padding and gap around its cell. | |
| 8 | + | PositionHintActiveDiameter = 10 | |
| 9 | + | PositionHintGap = 10 | |
| 10 | + | PositionHintPadding = 10 | |
| 11 | + | PositionHintBottom = 48 | |
| 12 | + | PositionHintRadius = 10 | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // PositionHint is a bounded, comparable snapshot. Scenes copy it by value; | |
| 16 | + | // neither fading nor a changed grid shares mutable storage with a renderer. | |
| 17 | + | type PositionHint struct { | |
| 18 | + | Opacity float32 | |
| 19 | + | Vertical bool | |
| 20 | + | Columns uint8 | |
| 21 | + | Counts [PositionHintMaxColumns]uint8 | |
| 22 | + | ActiveCol, ActiveRow uint8 | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (h PositionHint) Shown() bool { return h.Opacity > 0 && h.Columns > 0 } | |
| 26 | + | ||
| 27 | + | // Bounds is the hint's logical output-local background rectangle. | |
| 28 | + | func (h PositionHint) Bounds(width, height int) Rect { | |
| 29 | + | if !h.Shown() { | |
| 30 | + | return Rect{} | |
| 31 | + | } | |
| 32 | + | cols, rows := min(int(h.Columns), PositionHintMaxColumns), 1 | |
| 33 | + | for _, n := range h.Counts[:cols] { | |
| 34 | + | rows = max(rows, min(int(n), PositionHintMaxRows)) | |
| 35 | + | } | |
| 36 | + | w := 2*PositionHintPadding + cols*PositionHintDiameter + (cols-1)*PositionHintGap | |
| 37 | + | ht := 2*PositionHintPadding + rows*PositionHintDiameter + (rows-1)*PositionHintGap | |
| 38 | + | return Rect{X: (width - w) / 2, Y: height - PositionHintBottom - ht, W: w, H: ht} | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | const ( | |||
| 4 | + | PositionHintMaxColumns = 16 | |||
| 5 | + | PositionHintMaxRows = 16 | |||
| 6 | + | PositionHintDiameter = 8 | |||
| 7 | + | // PositionHintActiveDiameter fits in the padding and gap around its cell. | |||
| 8 | + | PositionHintActiveDiameter = 10 | |||
| 9 | + | PositionHintGap = 10 | |||
| 10 | + | PositionHintPadding = 10 | |||
| 11 | + | PositionHintBottom = 48 | |||
| 12 | + | PositionHintRadius = 10 | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // PositionHint is a bounded, comparable snapshot. Scenes copy it by value; | |||
| 16 | + | // neither fading nor a changed grid shares mutable storage with a renderer. | |||
| 17 | + | type PositionHint struct { | |||
| 18 | + | Opacity float32 | |||
| 19 | + | Vertical bool | |||
| 20 | + | Columns uint8 | |||
| 21 | + | Counts [PositionHintMaxColumns]uint8 | |||
| 22 | + | ActiveCol, ActiveRow uint8 | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (h PositionHint) Shown() bool { return h.Opacity > 0 && h.Columns > 0 } | |||
| 26 | + | ||||
| 27 | + | // Bounds is the hint's logical output-local background rectangle. | |||
| 28 | + | func (h PositionHint) Bounds(width, height int) Rect { | |||
| 29 | + | if !h.Shown() { | |||
| 30 | + | return Rect{} | |||
| 31 | + | } | |||
| 32 | + | cols, rows := min(int(h.Columns), PositionHintMaxColumns), 1 | |||
| 33 | + | for _, n := range h.Counts[:cols] { | |||
| 34 | + | rows = max(rows, min(int(n), PositionHintMaxRows)) | |||
| 35 | + | } | |||
| 36 | + | w := 2*PositionHintPadding + cols*PositionHintDiameter + (cols-1)*PositionHintGap | |||
| 37 | + | ht := 2*PositionHintPadding + rows*PositionHintDiameter + (rows-1)*PositionHintGap | |||
| 38 | + | return Rect{X: (width - w) / 2, Y: height - PositionHintBottom - ht, W: w, H: ht} | |||
| 39 | + | } | |||
M internal/ports/scene.go +7 -5
| @@ -77,10 +77,12 @@ 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 | - | // 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 | |
| 82 | - | // flip of the scene to core (OutputFrame), which moves the animation to | |
| 83 | - | // the next frame. A scene of a settled output carries false, so a flip | |
| 80 | + | // PositionHint is drawn on the physical output only, never in captures. | |
| 81 | + | PositionHint PositionHint | |
| 82 | + | // Animating is set by core when a spring, a focus flash or a position | |
| 83 | + | // hint fade runs on this output after this frame was computed: the | |
| 84 | + | // output reports the flip of the scene to core (OutputFrame), which | |
| 85 | + | // moves the animation to the next frame. A scene of a settled output carries false, so a flip | |
| 84 | 86 | // of it wakes nobody. It describes scheduling, not drawing: SameAs and | |
| 85 | 87 | // the renderer ignore it. | |
| 86 | 88 | Animating bool | |
| @@ -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,10 +77,12 @@ 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 | - | // Animating is set by core when a spring or a focus flash runs on | 80 | + | // PositionHint is drawn on the physical output only, never in captures. |
| 81 | - | // this output after this frame was computed: the output reports the | 81 | + | PositionHint PositionHint |
| 82 | - | // flip of the scene to core (OutputFrame), which moves the animation to | 82 | + | // Animating is set by core when a spring, a focus flash or a position |
| 83 | - | // the next frame. A scene of a settled output carries false, so a flip | 83 | + | // hint fade runs on this output after this frame was computed: the |
| 84 | + | // output reports the flip of the scene to core (OutputFrame), which | |||
| 85 | + | // moves the animation to the next frame. A scene of a settled output carries false, so a flip | |||
| 84 | // of it wakes nobody. It describes scheduling, not drawing: SameAs and | 86 | // of it wakes nobody. It describes scheduling, not drawing: SameAs and | ||
| 85 | // the renderer ignore it. | 87 | // the renderer ignore it. | ||
| 86 | Animating bool | 88 | Animating bool | ||
| @@ -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 | } | ||