detached ba1d5fd

#8 feat(core): per-monitor position hint dots

state
merged · ba1d5fd · fast-forward
branch
feat/position-hint → main
opened
bnema ·
updated

Summary

Adds a per-monitor position hint: a small dot grid, bottom-centered, shown for 600 ms then faded when the current cascade band, workspace or focused monitor changes. It tells you where you are at a glance.

  • Cascade (horizontal overview axis): one column per workspace with one dot per band, top-aligned. Scroll/fixed: one column with one dot per workspace. The axis follows the current workspace, like the overview.
  • The active dot is white and larger; other bands of the current workspace are dimmer, and other workspaces are dimmest.
  • Triggers on actual position changes (keys, swipes, overview accept, clicks), on the destination output only. Nothing at startup, hotplug or config reload. Overview cancel announces nothing.
  • Hidden in overview, visible fullscreen (cover()) and protection. Never drawn in captures. DRM direct scanout and overlay planes are skipped only while it is visible.
  • Logical sizes scaled by the output scale. Damage is limited to the old and new hint rects.
  • Config: focus.position-hint = on|off (default on).

Performance

ports.Scene carries the hint as a comparable fixed-size value (max 16×16): there are no pointers or slices shared with the renderer. Hint state, sampling and drawing allocate nothing (AllocsPerRun == 0 tests, including a 16×16 grid). No timer runs while it is hidden, and disabled hints skip the band walk. Fade frames go through the shared scene publication, which allocates its slices as springs and the focus flash already do. A follow-up can make hint-only publications reuse the last scene.

Checks

  • make check, make race, make mocks-check, staticcheck ./...
  • Headless e2e: hint visible, fading and hidden; overview accept/cancel
  • Tested on a laptop (DRM, eDP-1) with cascade bands and workspaces
CommitSubjectAuthorDateSignatureStatus
ba1d5fdfix(ports): shrink the active position hint dot to 10 logical pixelsbriceGPG Verified
bbca21dfix(vulkan): draw the active position hint dot white and larger so the current band stands outbriceGPG Verified
a941335docs(core): note that position hint fade frames share scene publication allocationsbriceGPG Verified
a46ff66fix(core): skip position hint band walk when disabled and document hint fade schedulingbriceGPG Verified
392b5b6feat(core): show per-monitor position hint dots on band and workspace changesbriceGPG Verified

Checks 2

23 files · +787 -17
2/2
M docs/config.md +8 -0
@@ -50,6 +50,7 @@ See [examples/config](../examples/config) for every key with its default.
5050 | `animations` | `off` | **Experimental.** Animate layout changes and make swipes follow the fingers |
5151 | `animations.speed` | `normal` | `slow`, `normal`, `fast`, or a factor from `0.1` to `10` |
5252 | `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 |
5354 | `focus.flash` | `on` | Briefly draw an inner border on the window, column or output an action changed |
5455 | `focus.flash-width` | `2` | Flash border width in logical pixels, `1` to `8` |
5556 | `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.
8384
8485 `<output>` is a connector such as `DP-2`, or a monitor key (`make model serial`).
8586
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+
8694 The focus flash marks the window, column or output an action changes with a brief
8795 inner border. When a flash-triggering action crosses screens, it marks the whole
8896 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 brief94 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 whole95 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 workspace96 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 {
188188 // Animations are experimental: off unless the config turns them on.
189189 c.Animations.On = false
190190 c.Animations.Slowdown = normalSlowdown
191+ c.Focus.PositionHint = true
191192 c.Focus.Flash = true
192193 c.Focus.FlashWidth = 2
193194 c.Focus.FlashOpacity = 0.6
@@ -845,6 +846,12 @@ func set(c *ports.Config, key, v string) error {
845846 return err
846847 }
847848 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
848855 case "focus.flash":
849856 b, err := onOff(v)
850857 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 = false189 c.Animations.On = false
190 c.Animations.Slowdown = normalSlowdown190 c.Animations.Slowdown = normalSlowdown
191+ c.Focus.PositionHint = true
191 c.Focus.Flash = true192 c.Focus.Flash = true
192 c.Focus.FlashWidth = 2193 c.Focus.FlashWidth = 2
193 c.Focus.FlashOpacity = 0.6194 c.Focus.FlashOpacity = 0.6
@@ -845,6 +846,12 @@ func set(c *ports.Config, key, v string) error {
845 return err846 return err
846 }847 }
847 c.Focus.FollowMove = b848 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) {
10451045 t.Fatal(c.Hint, w)
10461046 }
10471047 }
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 {
6767 // composed, never put on the plane.
6868 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) {
6969 // 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+ }
7073 if s.Flash != nil {
7174 return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "focus_flash"
7275 }
@@ -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 {
9191 // it is never put on a plane (nil: none is).
9292 func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) {
9393 // A hardware plane would bypass the compositor-owned flash border.
94+ if s.PositionHint.Shown() {
95+ return ports.SurfaceContent{}, "position_hint"
96+ }
9497 if s.Flash != nil {
9598 return ports.SurfaceContent{}, "focus_flash"
9699 }
@@ -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 {
6868 // renderer redraws the whole target instead of reusing a displayed frame.
6969 func plain(s ports.Scene) ports.Scene {
7070 s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
71+ s.PositionHint = ports.PositionHint{}
7172 return s
7273 }
7374
@@ -85,7 +86,7 @@ func plain(s ports.Scene) ports.Scene {
8586 // the plain render reads client buffers even when the requests are rejected,
8687 // so the caller owns it (hold or wait it) whatever happened, error included.
8788 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() {
8990 return false, nil, nil
9091 }
9192 done, err = r.Render(plain(s), surfaces)
@@ -164,7 +165,7 @@ func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, ex
164165 }
165166 clear(reqs[n:])
166167 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()
168169 group := func(q ports.CaptureRequest) int {
169170 switch {
170171 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, 070 s.Capture, s.CaptureIndicators, s.HintLabels, s.Seq = nil, nil, nil, 0
71+ s.PositionHint = ports.PositionHint{}
71 return s72 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, nil90 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) > 0168+ 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) {
195195 require.ErrorIs(t, awaitCapture(t, replies).Err, ErrWorkspaceMoved)
196196 }
197197 }
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
288288 return image.Rectangle{}, false
289289 }
290290 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+ }
291299 if (old.Flash == nil) != (cur.Flash == nil) || (old.Flash != nil && cur.Flash != nil && *old.Flash != *cur.Flash) {
292300 if old.Flash != nil {
293301 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{}, false288 return image.Rectangle{}, false
289 }289 }
290 var area image.Rectangle290 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 (
1919 func TestSceneFieldCount(t *testing.T) {
2020 // 25 with Animating, which sceneDelta intentionally ignores: it only
2121 // schedules the next frame, it draws nothing.
22- const fields = 25
22+ const fields = 26
2323 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2424 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2525 }
@@ -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 only20 // 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 = 2522+ 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
6060 w.popups(true)
6161 w.focusFlash()
6262 w.captureIndicators()
63+ w.positionHint()
6364 w.hintLabels()
6465 r.scratchDraws = w.draws
6566 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.draws65 r.scratchDraws = w.draws
65 return w.draws66 return w.draws
M internal/core/core.go +12 -3
@@ -164,8 +164,9 @@ type Core struct {
164164 motionTime time.Duration
165165 pointerAt [2]float64
166166 // capt is the capture state (capture.go, capindicator.go).
167- capt captureState
168- flash focusFlash
167+ capt captureState
168+ flash focusFlash
169+ positionHintTimer positionHintTimer
169170 // hint is keyboard hint mode (hint.go).
170171 hint hintState
171172 // 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 {
611612 return errSecurityChanged
612613 }
613614 c.syncSecurity()
615+ c.updatePositionHints(only)
614616 c.hintValidate()
615617 if c.security.Protected {
616618 return c.publishProtected(ctx)
@@ -719,7 +721,7 @@ func (c *Core) step(ctx context.Context, only *screen) error {
719721 // other than only.
720722 func (c *Core) animatingOther(only *screen) bool {
721723 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)) {
723725 return true
724726 }
725727 }
@@ -1068,6 +1070,7 @@ func (c *Core) Run(ctx context.Context) error {
10681070 defer c.stopFrame()
10691071 defer c.stopCaptureTimer()
10701072 defer c.stopFlashTimer()
1073+ defer c.stopPositionHintTimer()
10711074 defer c.cancelSwitcher()
10721075 defer c.stopRepeat()
10731076 // Startup commands run once per session. Full launcher queues retain a
@@ -1142,6 +1145,12 @@ func (c *Core) Run(ctx context.Context) error {
11421145 return nil
11431146 }
11441147 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
11451154 case <-c.flash.timerC:
11461155 if c.securityCheckpoint(ctx) != nil {
11471156 return nil
@@ -164,8 +164,9 @@ type Core struct {
164 motionTime time.Duration164 motionTime time.Duration
165 pointerAt [2]float64165 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 captureState167+ capt captureState
168- flash focusFlash168+ flash focusFlash
169+ positionHintTimer positionHintTimer
169 // hint is keyboard hint mode (hint.go).170 // hint is keyboard hint mode (hint.go).
170 hint hintState171 hint hintState
171 // keyState is the real modifier state of the latest key event, swallowed172 // 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 errSecurityChanged612 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 true725 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 a1076 // 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 nil1145 return nil
1143 }1146 }
1144 continue1147 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 nil1156 return nil
M internal/core/gesture.go +7 -5
@@ -586,19 +586,21 @@ func (c *Core) animate(now time.Time, only *screen) {
586586 }
587587 }
588588
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.
590591 func (c *Core) sliding(output string) bool {
591592 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]))
593594 }
594595
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.
596598 func (c *Core) animating() bool {
597599 if c.flash.running != nil {
598600 return true
599601 }
600602 for _, sc := range c.screens {
601- if sc.springing() {
603+ if sc.springing() || c.positionHintFading(sc) {
602604 return true
603605 }
604606 }
@@ -622,7 +624,7 @@ func (m *Monitor) springing() bool {
622624 func (c *Core) frameFallback() time.Duration {
623625 refresh := 60000
624626 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)) {
626628 refresh = min(refresh, r)
627629 }
628630 }
@@ -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 true600 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 true604 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 := 60000625 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
121121 // The drag icon is over everything, popups and layers included.
122122 scene.Windows = append(scene.Windows, icon)
123123 }
124+ scene.PositionHint = sc.positionHint.sample
124125 scene.Flash = c.flashFor(sc)
125126 scene.CaptureIndicators = c.captureIndicators(sc)
126127 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 (
1010 // place in the global layout (ADR 011). Outputs are ordered by config
1111 // (output.<name>.*), then by connection; order places them (placeOutputs).
1212 type screen struct {
13+ positionHint positionHintState
1314 info ports.OutputInfo
1415 mon *Monitor
1516 scale, cfgScale float64
@@ -10,6 +10,7 @@ import (
10 // place in the global layout (ADR 011). Outputs are ordered by config10 // 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.OutputInfo14 info ports.OutputInfo
14 mon *Monitor15 mon *Monitor
15 scale, cfgScale float6416 scale, cfgScale float64
M internal/core/security.go +4 -0
@@ -48,6 +48,10 @@ func (c *Core) syncSecurity() bool {
4848 c.gesture = clientGesture{}
4949 c.stopAnimations()
5050 c.stopFlash()
51+ c.stopPositionHintTimer()
52+ for _, sc := range c.screens {
53+ sc.positionHint = positionHintState{}
54+ }
5155 c.cancelSwitcher()
5256 c.stopRepeat()
5357 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 {
155155 // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
156156 // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or
157157 // empty for Border.Active.
158+ PositionHint bool
158159 Flash bool
159160 FlashWidth int
160161 FlashOpacity float64
@@ -155,6 +155,7 @@ type Config struct {
155 // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade155 // FlashOpacity the starting opacity (0.1 to 1), FlashDuration the fade
156 // (50ms to 1s, not scaled by Animations.Slowdown), FlashColor #rrggbb or156 // (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 bool159 Flash bool
159 FlashWidth int160 FlashWidth int
160 FlashOpacity float64161 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 {
7777 // Flash is the focus flash drawn over everything of this output; nil
7878 // draws nothing.
7979 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
8486 // of it wakes nobody. It describes scheduling, not drawing: SameAs and
8587 // the renderer ignore it.
8688 Animating bool
@@ -92,7 +94,7 @@ type Scene struct {
9294 // is not compared). Add every new Scene field here;
9395 // TestSceneSameAsCoversEveryField fails until the field count is updated.
9496 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 ||
9698 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
9799 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
98100 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; nil77 // Flash is the focus flash drawn over everything of this output; nil
78 // draws nothing.78 // draws nothing.
79 Flash *FocusFlash79 Flash *FocusFlash
80- // Animating is set by core when a spring or a focus flash runs on80+ // PositionHint is drawn on the physical output only, never in captures.
81- // this output after this frame was computed: the output reports the81+ PositionHint PositionHint
82- // flip of the scene to core (OutputFrame), which moves the animation to82+ // 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 flip83+ // 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 and86 // of it wakes nobody. It describes scheduling, not drawing: SameAs and
85 // the renderer ignore it.87 // the renderer ignore it.
86 Animating bool88 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) {
3838 assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
3939
4040 for name, edit := range map[string]func(*Scene){
41+ "position hint": func(s *Scene) { s.PositionHint.Opacity = 1 },
4142 "flash nil": func(s *Scene) { s.Flash = nil },
4243 "flash rect": func(s *Scene) { s.Flash.Rect.X++ },
4344 "flash width": func(s *Scene) { s.Flash.Width++ },
@@ -93,7 +94,7 @@ func TestSceneSameAs(t *testing.T) {
9394 func TestSceneSameAsCoversEveryField(t *testing.T) {
9495 // 25 with Animating, which SameAs intentionally does not compare: it
9596 // only schedules the next frame, it draws nothing.
96- const fields = 25
97+ const fields = 26
9798 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
9899 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
99100 }
@@ -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: it95 // 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 = 2597+ 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 }

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close