detached 392b5b6

feat(core): show per-monitor position hint dots on band and workspace changes

A bottom-centered dot grid appears for 600ms then fades on the output whose workspace, cascade band or monitor focus changed. It follows the overview axis, scales with the output, stays out of captures, damages only its rect and allocates nothing in steady state. Configured by focus.position-hint (default on).
commit
392b5b6bc2ae798098eff7bbbedc5982777f5af4
author
brice · · 2026-10-19 20:23 UTC
parent
d5d679d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +765 -11
M docs/config.md +8 -0
@@ -50,6 +50,7 @@ See [examples/config](../examples/config) for every key with its default.
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 +54 -0
@@ -0,0 +1,54 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func (w *sceneWalk) positionHint() {
11+ h := w.s.PositionHint
12+ if !h.Shown() {
13+ return
14+ }
15+ b := h.Bounds(w.s.OutputWidth, w.s.OutputHeight)
16+ alpha := w.alpha
17+ w.alpha = h.Opacity * 0.8
18+ w.hintRound(w.physRect(b.X, b.Y, b.W, b.H), w.phys(ports.PositionHintRadius), [3]uint8{30, 30, 38})
19+ active := parseColor(w.s.Border.Active)
20+ for col := range min(int(h.Columns), ports.PositionHintMaxColumns) {
21+ for row := range min(int(h.Counts[col]), ports.PositionHintMaxRows) {
22+ color, opacity := [3]uint8{190, 190, 200}, float32(0.3)
23+ if !h.Vertical && col == int(h.ActiveCol) {
24+ opacity = 0.65
25+ }
26+ if col == int(h.ActiveCol) && row == int(h.ActiveRow) {
27+ color, opacity = active, 1
28+ }
29+ w.alpha = h.Opacity * opacity
30+ x := b.X + ports.PositionHintPadding + col*(ports.PositionHintDiameter+ports.PositionHintGap)
31+ y := b.Y + ports.PositionHintPadding + row*(ports.PositionHintDiameter+ports.PositionHintGap)
32+ w.hintRound(w.physRect(x, y, ports.PositionHintDiameter, ports.PositionHintDiameter), w.phys(ports.PositionHintDiameter)/2, color)
33+ }
34+ }
35+ w.alpha = alpha
36+}
37+
38+// Rounded fills use the existing solid instances and batching pipeline. Each
39+// physical scanline is bounded by the scaled radius; no textures or per-frame
40+// buffers are allocated. Non-overlapping strips preserve translucent alpha.
41+func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) {
42+ radius = min(radius, r.Dx()/2, r.Dy()/2)
43+ if radius <= 0 {
44+ w.fill(r, color)
45+ return
46+ }
47+ w.fill(image.Rect(r.Min.X, r.Min.Y+radius, r.Max.X, r.Max.Y-radius), color)
48+ for y := range radius {
49+ dy := float64(radius-y) - 0.5
50+ inset := radius - int(math.Sqrt(float64(radius*radius)-dy*dy)+0.5)
51+ w.fill(image.Rect(r.Min.X+inset, r.Min.Y+y, r.Max.X-inset, r.Min.Y+y+1), color)
52+ w.fill(image.Rect(r.Min.X+inset, r.Max.Y-y-1, r.Max.X-inset, r.Max.Y-y), color)
53+ }
54+}
@@ -0,0 +1,54 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "math"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func (w *sceneWalk) positionHint() {
11+ h := w.s.PositionHint
12+ if !h.Shown() {
13+ return
14+ }
15+ b := h.Bounds(w.s.OutputWidth, w.s.OutputHeight)
16+ alpha := w.alpha
17+ w.alpha = h.Opacity * 0.8
18+ w.hintRound(w.physRect(b.X, b.Y, b.W, b.H), w.phys(ports.PositionHintRadius), [3]uint8{30, 30, 38})
19+ active := parseColor(w.s.Border.Active)
20+ for col := range min(int(h.Columns), ports.PositionHintMaxColumns) {
21+ for row := range min(int(h.Counts[col]), ports.PositionHintMaxRows) {
22+ color, opacity := [3]uint8{190, 190, 200}, float32(0.3)
23+ if !h.Vertical && col == int(h.ActiveCol) {
24+ opacity = 0.65
25+ }
26+ if col == int(h.ActiveCol) && row == int(h.ActiveRow) {
27+ color, opacity = active, 1
28+ }
29+ w.alpha = h.Opacity * opacity
30+ x := b.X + ports.PositionHintPadding + col*(ports.PositionHintDiameter+ports.PositionHintGap)
31+ y := b.Y + ports.PositionHintPadding + row*(ports.PositionHintDiameter+ports.PositionHintGap)
32+ w.hintRound(w.physRect(x, y, ports.PositionHintDiameter, ports.PositionHintDiameter), w.phys(ports.PositionHintDiameter)/2, color)
33+ }
34+ }
35+ w.alpha = alpha
36+}
37+
38+// Rounded fills use the existing solid instances and batching pipeline. Each
39+// physical scanline is bounded by the scaled radius; no textures or per-frame
40+// buffers are allocated. Non-overlapping strips preserve translucent alpha.
41+func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) {
42+ radius = min(radius, r.Dx()/2, r.Dy()/2)
43+ if radius <= 0 {
44+ w.fill(r, color)
45+ return
46+ }
47+ w.fill(image.Rect(r.Min.X, r.Min.Y+radius, r.Max.X, r.Max.Y-radius), color)
48+ for y := range radius {
49+ dy := float64(radius-y) - 0.5
50+ inset := radius - int(math.Sqrt(float64(radius*radius)-dy*dy)+0.5)
51+ w.fill(image.Rect(r.Min.X+inset, r.Min.Y+y, r.Max.X-inset, r.Min.Y+y+1), color)
52+ w.fill(image.Rect(r.Min.X+inset, r.Max.Y-y-1, r.Max.X-inset, r.Max.Y-y), color)
53+ }
54+}
A internal/adapters/vulkan/position_hint_test.go +71 -0
@@ -0,0 +1,71 @@
1+package vulkan
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "image"
7+ "testing"
8+)
9+
10+func TestPositionHintGeometryDamageAllocations(t *testing.T) {
11+ for _, scale := range []float64{1, 1.5, 2} {
12+ r := &Renderer{width: int(640 * scale), height: int(480 * scale)}
13+ s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: scale, Border: ports.Border{Active: "#abcdef"}, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}, ActiveRow: 1}}
14+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
15+ draws := r.draws(s, nil, dmg)
16+ require.NotEmpty(t, draws)
17+ w := sceneWalk{scale: scale}
18+ b := s.PositionHint.Bounds(640, 480)
19+ want := w.physRect(b.X, b.Y, b.W, b.H)
20+ var area image.Rectangle
21+ for _, d := range draws {
22+ area = area.Union(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3])))
23+ }
24+ require.Equal(t, want, area)
25+ require.Zero(t, testing.AllocsPerRun(100, func() { r.draws(s, nil, dmg) }))
26+ old := s
27+ old.PositionHint = ports.PositionHint{}
28+ phys := func(b ports.Rect) image.Rectangle { return w.physRect(b.X, b.Y, b.W, b.H) }
29+ got, ok := sceneDelta(old, s, phys, r.width*r.height)
30+ require.True(t, ok)
31+ require.Equal(t, want, got)
32+ s.PositionHint.Opacity = 0.5
33+ got, ok = sceneDelta(old, s, phys, r.width*r.height)
34+ require.True(t, ok)
35+ require.Equal(t, want, got)
36+ }
37+}
38+
39+func TestPositionHintDamageTransitions(t *testing.T) {
40+ base := ports.Scene{Scale: 1, OutputWidth: 640, OutputHeight: 480, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}}}
41+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
42+ bounds := func(s ports.Scene) image.Rectangle { return phys(s.PositionHint.Bounds(s.OutputWidth, s.OutputHeight)) }
43+ t.Run("disappear", func(t *testing.T) {
44+ cur := base
45+ cur.PositionHint = ports.PositionHint{}
46+ got, ok := sceneDelta(base, cur, phys, 640*480)
47+ require.True(t, ok)
48+ require.Equal(t, bounds(base), got)
49+ })
50+ t.Run("shape", func(t *testing.T) {
51+ cur := base
52+ cur.PositionHint.Columns = 3
53+ cur.PositionHint.Counts[2] = 4
54+ got, ok := sceneDelta(base, cur, phys, 640*480)
55+ require.True(t, ok)
56+ require.Equal(t, bounds(base).Union(bounds(cur)), got)
57+ })
58+ t.Run("fade", func(t *testing.T) {
59+ cur := base
60+ cur.PositionHint.Opacity = 0.5
61+ got, ok := sceneDelta(base, cur, phys, 640*480)
62+ require.True(t, ok)
63+ require.Equal(t, bounds(base), got)
64+ })
65+ t.Run("scale", func(t *testing.T) {
66+ cur := base
67+ cur.Scale = 2
68+ _, ok := sceneDelta(base, cur, phys, 640*480)
69+ require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately")
70+ })
71+}
@@ -0,0 +1,71 @@
1+package vulkan
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/stretchr/testify/require"
6+ "image"
7+ "testing"
8+)
9+
10+func TestPositionHintGeometryDamageAllocations(t *testing.T) {
11+ for _, scale := range []float64{1, 1.5, 2} {
12+ r := &Renderer{width: int(640 * scale), height: int(480 * scale)}
13+ s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: scale, Border: ports.Border{Active: "#abcdef"}, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}, ActiveRow: 1}}
14+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
15+ draws := r.draws(s, nil, dmg)
16+ require.NotEmpty(t, draws)
17+ w := sceneWalk{scale: scale}
18+ b := s.PositionHint.Bounds(640, 480)
19+ want := w.physRect(b.X, b.Y, b.W, b.H)
20+ var area image.Rectangle
21+ for _, d := range draws {
22+ area = area.Union(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3])))
23+ }
24+ require.Equal(t, want, area)
25+ require.Zero(t, testing.AllocsPerRun(100, func() { r.draws(s, nil, dmg) }))
26+ old := s
27+ old.PositionHint = ports.PositionHint{}
28+ phys := func(b ports.Rect) image.Rectangle { return w.physRect(b.X, b.Y, b.W, b.H) }
29+ got, ok := sceneDelta(old, s, phys, r.width*r.height)
30+ require.True(t, ok)
31+ require.Equal(t, want, got)
32+ s.PositionHint.Opacity = 0.5
33+ got, ok = sceneDelta(old, s, phys, r.width*r.height)
34+ require.True(t, ok)
35+ require.Equal(t, want, got)
36+ }
37+}
38+
39+func TestPositionHintDamageTransitions(t *testing.T) {
40+ base := ports.Scene{Scale: 1, OutputWidth: 640, OutputHeight: 480, PositionHint: ports.PositionHint{Opacity: 1, Columns: 2, Counts: [16]uint8{2, 1}}}
41+ phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
42+ bounds := func(s ports.Scene) image.Rectangle { return phys(s.PositionHint.Bounds(s.OutputWidth, s.OutputHeight)) }
43+ t.Run("disappear", func(t *testing.T) {
44+ cur := base
45+ cur.PositionHint = ports.PositionHint{}
46+ got, ok := sceneDelta(base, cur, phys, 640*480)
47+ require.True(t, ok)
48+ require.Equal(t, bounds(base), got)
49+ })
50+ t.Run("shape", func(t *testing.T) {
51+ cur := base
52+ cur.PositionHint.Columns = 3
53+ cur.PositionHint.Counts[2] = 4
54+ got, ok := sceneDelta(base, cur, phys, 640*480)
55+ require.True(t, ok)
56+ require.Equal(t, bounds(base).Union(bounds(cur)), got)
57+ })
58+ t.Run("fade", func(t *testing.T) {
59+ cur := base
60+ cur.PositionHint.Opacity = 0.5
61+ got, ok := sceneDelta(base, cur, phys, 640*480)
62+ require.True(t, ok)
63+ require.Equal(t, bounds(base), got)
64+ })
65+ t.Run("scale", func(t *testing.T) {
66+ cur := base
67+ cur.Scale = 2
68+ _, ok := sceneDelta(base, cur, phys, 640*480)
69+ require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately")
70+ })
71+}
M internal/adapters/vulkan/scene.go +1 -0
@@ -60,6 +60,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
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 +3 -3
@@ -589,7 +589,7 @@ func (c *Core) animate(now time.Time, only *screen) {
589589 // sliding reports whether a spring or focus flash runs on the named output.
590590 func (c *Core) sliding(output string) bool {
591591 i := c.screenIndex(output)
592- return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]))
592+ return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]) || c.positionHintFading(c.screens[i]))
593593 }
594594
595595 // animating reports whether a spring or focus flash runs on any output.
@@ -598,7 +598,7 @@ func (c *Core) animating() bool {
598598 return true
599599 }
600600 for _, sc := range c.screens {
601- if sc.springing() {
601+ if sc.springing() || c.positionHintFading(sc) {
602602 return true
603603 }
604604 }
@@ -622,7 +622,7 @@ func (m *Monitor) springing() bool {
622622 func (c *Core) frameFallback() time.Duration {
623623 refresh := 60000
624624 for _, sc := range c.screens {
625- if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) {
625+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) {
626626 refresh = min(refresh, r)
627627 }
628628 }
@@ -589,7 +589,7 @@ func (c *Core) animate(now time.Time, only *screen) {
589 // sliding reports whether a spring or focus flash runs on the named output.589 // sliding reports whether a spring or focus flash runs on the named output.
590 func (c *Core) sliding(output string) bool {590 func (c *Core) sliding(output string) bool {
591 i := c.screenIndex(output)591 i := c.screenIndex(output)
592- return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]))592+ return i >= 0 && (c.screens[i].springing() || c.flashing(c.screens[i]) || c.positionHintFading(c.screens[i]))
593 }593 }
594 594
595 // animating reports whether a spring or focus flash runs on any output.595 // animating reports whether a spring or focus flash runs on any output.
@@ -598,7 +598,7 @@ func (c *Core) animating() bool {
598 return true598 return true
599 }599 }
600 for _, sc := range c.screens {600 for _, sc := range c.screens {
601- if sc.springing() {601+ if sc.springing() || c.positionHintFading(sc) {
602 return true602 return true
603 }603 }
604 }604 }
@@ -622,7 +622,7 @@ func (m *Monitor) springing() bool {
622 func (c *Core) frameFallback() time.Duration {622 func (c *Core) frameFallback() time.Duration {
623 refresh := 60000623 refresh := 60000
624 for _, sc := range c.screens {624 for _, sc := range c.screens {
625- if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc)) {625+ if r := sc.info.RefreshMilli; r > 0 && (sc.springing() || c.flashing(sc) || c.positionHintFading(sc)) {
626 refresh = min(refresh, r)626 refresh = min(refresh, r)
627 }627 }
628 }628 }
A internal/core/position_hint.go +168 -0
@@ -0,0 +1,168 @@
1+package core
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+const positionHintHold = 600 * time.Millisecond
10+const positionHintFade = 150 * time.Millisecond
11+
12+// positionHintState belongs exclusively to the core owner. Published samples
13+// are fixed-size values, so no buffer recycling or renderer acknowledgement is
14+// needed. The timer is retained (but stopped when idle) and Reset on reuse.
15+type positionHintState struct {
16+ workspace *Workspace
17+ band int
18+ focused bool
19+ initialized bool
20+ start time.Time
21+ live bool
22+ fading bool
23+ sample ports.PositionHint
24+}
25+
26+type positionHintTimer struct {
27+ timer ports.Timer
28+ timerC <-chan time.Time
29+}
30+
31+// updatePositionHints compares actual positions at the publication boundary,
32+// independent of which input/action changed them. Initial output discovery is
33+// not a move. Other outputs retain their own start time and sample.
34+func (c *Core) updatePositionHints(only *screen) {
35+ for i, sc := range c.screens {
36+ h := &sc.positionHint
37+ // Overview selection is provisional. Preserve the last committed
38+ // baseline until closing, so accept compares against entry and cancel
39+ // cannot announce intermediate browsing positions.
40+ if sc.mon.ov.open {
41+ h.live, h.sample = false, ports.PositionHint{}
42+ continue
43+ }
44+ w := sc.mon.Current()
45+ band := 0
46+ if w.Overflow == OverflowCascade {
47+ band = w.band(w.Focus)
48+ }
49+ focused := i == c.focusScreen
50+ changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused)
51+ // Transferring the current workspace changes the source's fallback
52+ // position too, but only the receiving output should announce it.
53+ if h.initialized && h.workspace != w {
54+ for _, other := range c.screens {
55+ if other != sc && other.mon.has(h.workspace) {
56+ changed = false
57+ break
58+ }
59+ }
60+ }
61+ h.workspace, h.band, h.focused, h.initialized = w, band, focused, true
62+ if !c.cfg.Focus.PositionHint || c.protectionRequested() || w.cover() != 0 {
63+ h.live, h.sample = false, ports.PositionHint{}
64+ continue
65+ }
66+ if changed {
67+ h.start, h.live, h.fading = c.now(), true, false
68+ }
69+ if !h.live {
70+ continue
71+ }
72+ if !changed && only != nil && only != sc {
73+ continue
74+ }
75+ elapsed := c.now().Sub(h.start)
76+ opacity := float32(1)
77+ h.fading = elapsed >= positionHintHold
78+ if h.fading {
79+ if !c.animOn() || elapsed >= positionHintHold+positionHintFade {
80+ h.live, h.sample = false, ports.PositionHint{}
81+ continue
82+ }
83+ opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade)
84+ }
85+ h.sample = positionHintGrid(sc.mon)
86+ h.sample.Opacity = opacity
87+ }
88+ c.armPositionHintTimer()
89+}
90+
91+// Use ext-workspace's complete ordered list: numbered (including empty
92+// spares), then configured hidden named workspaces. Unlike overview cards,
93+// these remain addressable even when empty. Window the list at its active
94+// entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp.
95+func positionHintGrid(m *Monitor) ports.PositionHint {
96+ h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis}
97+ active, total := 0, 0
98+ for w := range m.all() {
99+ if w == m.Current() {
100+ active = total
101+ }
102+ total++
103+ }
104+ cap := ports.PositionHintMaxColumns
105+ if h.Vertical {
106+ cap = ports.PositionHintMaxRows
107+ }
108+ first := max(0, active-cap+1)
109+ if h.Vertical {
110+ h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first)
111+ return h
112+ }
113+ h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first)
114+ i := 0
115+ for w := range m.all() {
116+ if i >= first && i < first+cap {
117+ n := 1
118+ if w.Overflow == OverflowCascade {
119+ n, _ = w.cascadeBands()
120+ }
121+ h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows))
122+ }
123+ i++
124+ }
125+ w := m.Current()
126+ if w.Overflow == OverflowCascade {
127+ h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1))
128+ }
129+ return h
130+}
131+
132+func (c *Core) positionHintFading(sc *screen) bool {
133+ return sc.positionHint.live && sc.positionHint.fading
134+}
135+
136+// One timer wakes at the earliest hold boundary. Fade frames use the existing
137+// flip/fallback scheduler; with no live hints no timer is armed or clock read.
138+func (c *Core) armPositionHintTimer() {
139+ var earliest time.Time
140+ for _, sc := range c.screens {
141+ if sc.positionHint.live && !sc.positionHint.fading {
142+ end := sc.positionHint.start.Add(positionHintHold)
143+ if earliest.IsZero() || end.Before(earliest) {
144+ earliest = end
145+ }
146+ }
147+ }
148+ t := &c.positionHintTimer
149+ if earliest.IsZero() {
150+ c.stopPositionHintTimer()
151+ return
152+ }
153+ d := max(earliest.Sub(c.now()), time.Nanosecond)
154+ if t.timer == nil {
155+ t.timer = newPortTimer(c.opts.Clock, d)
156+ } else {
157+ t.timer.Reset(d)
158+ }
159+ t.timerC = t.timer.C()
160+}
161+
162+func (c *Core) stopPositionHintTimer() {
163+ t := &c.positionHintTimer
164+ if t.timerC != nil {
165+ t.timer.Stop()
166+ t.timerC = nil
167+ }
168+}
@@ -0,0 +1,168 @@
1+package core
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+const positionHintHold = 600 * time.Millisecond
10+const positionHintFade = 150 * time.Millisecond
11+
12+// positionHintState belongs exclusively to the core owner. Published samples
13+// are fixed-size values, so no buffer recycling or renderer acknowledgement is
14+// needed. The timer is retained (but stopped when idle) and Reset on reuse.
15+type positionHintState struct {
16+ workspace *Workspace
17+ band int
18+ focused bool
19+ initialized bool
20+ start time.Time
21+ live bool
22+ fading bool
23+ sample ports.PositionHint
24+}
25+
26+type positionHintTimer struct {
27+ timer ports.Timer
28+ timerC <-chan time.Time
29+}
30+
31+// updatePositionHints compares actual positions at the publication boundary,
32+// independent of which input/action changed them. Initial output discovery is
33+// not a move. Other outputs retain their own start time and sample.
34+func (c *Core) updatePositionHints(only *screen) {
35+ for i, sc := range c.screens {
36+ h := &sc.positionHint
37+ // Overview selection is provisional. Preserve the last committed
38+ // baseline until closing, so accept compares against entry and cancel
39+ // cannot announce intermediate browsing positions.
40+ if sc.mon.ov.open {
41+ h.live, h.sample = false, ports.PositionHint{}
42+ continue
43+ }
44+ w := sc.mon.Current()
45+ band := 0
46+ if w.Overflow == OverflowCascade {
47+ band = w.band(w.Focus)
48+ }
49+ focused := i == c.focusScreen
50+ changed := h.initialized && (h.workspace != w || h.band != band || focused && !h.focused)
51+ // Transferring the current workspace changes the source's fallback
52+ // position too, but only the receiving output should announce it.
53+ if h.initialized && h.workspace != w {
54+ for _, other := range c.screens {
55+ if other != sc && other.mon.has(h.workspace) {
56+ changed = false
57+ break
58+ }
59+ }
60+ }
61+ h.workspace, h.band, h.focused, h.initialized = w, band, focused, true
62+ if !c.cfg.Focus.PositionHint || c.protectionRequested() || w.cover() != 0 {
63+ h.live, h.sample = false, ports.PositionHint{}
64+ continue
65+ }
66+ if changed {
67+ h.start, h.live, h.fading = c.now(), true, false
68+ }
69+ if !h.live {
70+ continue
71+ }
72+ if !changed && only != nil && only != sc {
73+ continue
74+ }
75+ elapsed := c.now().Sub(h.start)
76+ opacity := float32(1)
77+ h.fading = elapsed >= positionHintHold
78+ if h.fading {
79+ if !c.animOn() || elapsed >= positionHintHold+positionHintFade {
80+ h.live, h.sample = false, ports.PositionHint{}
81+ continue
82+ }
83+ opacity = 1 - float32(elapsed-positionHintHold)/float32(positionHintFade)
84+ }
85+ h.sample = positionHintGrid(sc.mon)
86+ h.sample.Opacity = opacity
87+ }
88+ c.armPositionHintTimer()
89+}
90+
91+// Use ext-workspace's complete ordered list: numbered (including empty
92+// spares), then configured hidden named workspaces. Unlike overview cards,
93+// these remain addressable even when empty. Window the list at its active
94+// entry when it exceeds the cap. Bands stay top-aligned; excess rows clamp.
95+func positionHintGrid(m *Monitor) ports.PositionHint {
96+ h := ports.PositionHint{Vertical: m.overviewAxis() == verticalAxis}
97+ active, total := 0, 0
98+ for w := range m.all() {
99+ if w == m.Current() {
100+ active = total
101+ }
102+ total++
103+ }
104+ cap := ports.PositionHintMaxColumns
105+ if h.Vertical {
106+ cap = ports.PositionHintMaxRows
107+ }
108+ first := max(0, active-cap+1)
109+ if h.Vertical {
110+ h.Columns, h.Counts[0], h.ActiveRow = 1, uint8(min(total, cap)), uint8(active-first)
111+ return h
112+ }
113+ h.Columns, h.ActiveCol = uint8(min(total, cap)), uint8(active-first)
114+ i := 0
115+ for w := range m.all() {
116+ if i >= first && i < first+cap {
117+ n := 1
118+ if w.Overflow == OverflowCascade {
119+ n, _ = w.cascadeBands()
120+ }
121+ h.Counts[i-first] = uint8(min(n, ports.PositionHintMaxRows))
122+ }
123+ i++
124+ }
125+ w := m.Current()
126+ if w.Overflow == OverflowCascade {
127+ h.ActiveRow = uint8(min(w.band(w.Focus), ports.PositionHintMaxRows-1))
128+ }
129+ return h
130+}
131+
132+func (c *Core) positionHintFading(sc *screen) bool {
133+ return sc.positionHint.live && sc.positionHint.fading
134+}
135+
136+// One timer wakes at the earliest hold boundary. Fade frames use the existing
137+// flip/fallback scheduler; with no live hints no timer is armed or clock read.
138+func (c *Core) armPositionHintTimer() {
139+ var earliest time.Time
140+ for _, sc := range c.screens {
141+ if sc.positionHint.live && !sc.positionHint.fading {
142+ end := sc.positionHint.start.Add(positionHintHold)
143+ if earliest.IsZero() || end.Before(earliest) {
144+ earliest = end
145+ }
146+ }
147+ }
148+ t := &c.positionHintTimer
149+ if earliest.IsZero() {
150+ c.stopPositionHintTimer()
151+ return
152+ }
153+ d := max(earliest.Sub(c.now()), time.Nanosecond)
154+ if t.timer == nil {
155+ t.timer = newPortTimer(c.opts.Clock, d)
156+ } else {
157+ t.timer.Reset(d)
158+ }
159+ t.timerC = t.timer.C()
160+}
161+
162+func (c *Core) stopPositionHintTimer() {
163+ t := &c.positionHintTimer
164+ if t.timerC != nil {
165+ t.timer.Stop()
166+ t.timerC = nil
167+ }
168+}
A internal/core/position_hint_test.go +344 -0
@@ -0,0 +1,344 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPositionHintChangesAndTiming(t *testing.T) {
16+ c, clock := indicatorCore(t)
17+ c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true
18+ a := c.cur()
19+ a.mon.Current().Overflow = OverflowCascade
20+ a.mon.Current().MaxColumns = 1
21+ a.mon.AddWindow(1)
22+ a.mon.AddWindow(2)
23+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
24+ b := c.screens[1]
25+ a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1
26+ a.mon.Current().Focus = 1
27+ c.updatePositionHints(nil)
28+ require.False(t, a.positionHint.live)
29+ a.mon.Current().Focus = 0
30+ c.updatePositionHints(nil)
31+ require.True(t, a.positionHint.sample.Shown())
32+ require.False(t, b.positionHint.live)
33+ start := a.positionHint.start
34+ clock.now = clock.now.Add(100 * time.Millisecond)
35+ c.updatePositionHints(nil)
36+ require.Equal(t, start, a.positionHint.start, "no-op restarted timer")
37+ a.mon.Current().Focus = 1
38+ c.updatePositionHints(nil)
39+ require.Equal(t, clock.now, a.positionHint.start)
40+ clock.now = clock.now.Add(positionHintHold)
41+ c.updatePositionHints(nil)
42+ require.True(t, c.positionHintFading(a), "hold boundary must start frame scheduling")
43+ clock.now = clock.now.Add(positionHintFade / 2)
44+ c.updatePositionHints(nil)
45+ require.Equal(t, float32(0.5), a.positionHint.sample.Opacity)
46+ saved := a.positionHint.sample
47+ c.focusScreen = 1
48+ c.updatePositionHints(b)
49+ require.Equal(t, saved, a.positionHint.sample)
50+ require.True(t, b.positionHint.live)
51+ clock.now = clock.now.Add(positionHintFade / 2)
52+ c.updatePositionHints(a)
53+ require.False(t, a.positionHint.live)
54+ require.True(t, b.positionHint.live)
55+}
56+
57+func TestPositionHintWorkspaceAndSuppression(t *testing.T) {
58+ for _, mode := range []string{"workspace", "overview", "fullscreen", "off", "no-animation"} {
59+ t.Run(mode, func(t *testing.T) {
60+ c, clock := indicatorCore(t)
61+ c.cfg.Focus.PositionHint = true
62+ sc := c.cur()
63+ sc.mon.AddWindow(1)
64+ c.updatePositionHints(nil)
65+ sc.mon.show(sc.mon.Workspaces[1])
66+ c.updatePositionHints(nil)
67+ require.True(t, sc.positionHint.live)
68+ switch mode {
69+ case "overview":
70+ sc.mon.ov.open = true
71+ case "fullscreen":
72+ sc.mon.Current().Overflow = OverflowFixed
73+ sc.mon.Current().fullscreen = 1
74+ case "off":
75+ c.cfg.Focus.PositionHint = false
76+ default:
77+ clock.now = clock.now.Add(positionHintHold)
78+ }
79+ c.updatePositionHints(nil)
80+ require.False(t, sc.positionHint.sample.Shown())
81+ require.Nil(t, c.positionHintTimer.timerC)
82+ })
83+ }
84+}
85+
86+func TestPositionHintGrid(t *testing.T) {
87+ c, _ := indicatorCore(t)
88+ m := c.cur().mon
89+ w := m.Current()
90+ w.Overflow, w.MaxColumns = OverflowCascade, 1
91+ m.AddWindow(1)
92+ m.AddWindow(2)
93+ h := positionHintGrid(m)
94+ require.False(t, h.Vertical)
95+ require.Equal(t, uint8(2), h.Counts[0])
96+ require.Equal(t, uint8(1), h.Counts[1], "empty noncascade workspace")
97+ require.Equal(t, uint8(1), h.ActiveRow)
98+ m.show(m.Workspaces[1])
99+ h = positionHintGrid(m)
100+ require.True(t, h.Vertical, "current workspace decides axis")
101+ require.Equal(t, uint8(1), h.Columns)
102+ require.Equal(t, uint8(2), h.Counts[0])
103+ require.Equal(t, uint8(1), h.ActiveRow)
104+}
105+
106+func TestPositionHintAllocations(t *testing.T) {
107+ // Real clock avoids measuring generated mock bookkeeping, as publishRig does.
108+ c := publishRig(t, 1)
109+ c.cfg.Focus.PositionHint = true
110+ sc := c.cur()
111+ w := sc.mon.Current()
112+ w.Overflow, w.MaxColumns = OverflowCascade, 1
113+ c.updatePositionHints(nil)
114+ defer c.stopPositionHintTimer()
115+ for name, run := range map[string]func(){
116+ "trigger": func() { w.Focus = (w.Focus + 1) % 2; c.updatePositionHints(nil) },
117+ "fade": func() {
118+ sc.positionHint.live = true
119+ sc.positionHint.start = c.now().Add(-positionHintHold - 50*time.Millisecond)
120+ c.updatePositionHints(nil)
121+ },
122+ "inactive": func() { sc.positionHint.live = false; c.updatePositionHints(nil) },
123+ } {
124+ t.Run(name, func(t *testing.T) { run(); require.Zero(t, testing.AllocsPerRun(100, run)) })
125+ }
126+}
127+
128+func TestPositionHintMoveWorkspaceDestination(t *testing.T) {
129+ c, _ := indicatorCore(t)
130+ c.cfg.Focus.PositionHint = true
131+ c.cur().mon.AddWindow(1)
132+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
133+ c.updatePositionHints(nil)
134+ source := c.cur()
135+ moved := source.mon.Current()
136+ c.moveWorkspace(dirRight)
137+ c.updatePositionHints(nil)
138+ require.Same(t, moved, c.cur().mon.Current())
139+ require.False(t, source.positionHint.live)
140+ require.True(t, c.cur().positionHint.sample.Shown())
141+}
142+
143+func TestPositionHintCapsAndProtection(t *testing.T) {
144+ c, _ := indicatorCore(t)
145+ c.cfg.Focus.PositionHint = true
146+ m := c.cur().mon
147+ for range 20 {
148+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
149+ }
150+ m.Active = 20
151+ h := positionHintGrid(m)
152+ require.Equal(t, uint8(16), h.Counts[0])
153+ require.Equal(t, uint8(15), h.ActiveRow)
154+ m.Current().Overflow = OverflowCascade
155+ h = positionHintGrid(m)
156+ require.Equal(t, uint8(16), h.Columns)
157+ require.Equal(t, uint8(15), h.ActiveCol)
158+ c.updatePositionHints(nil)
159+ c.cur().positionHint.live = true
160+ c.security.Protected = true
161+ c.updatePositionHints(nil)
162+ require.False(t, c.cur().positionHint.live)
163+}
164+
165+func TestPositionHintIncludesNamedEmptyAndMixedOverflow(t *testing.T) {
166+ c, _ := indicatorCore(t)
167+ m := c.cur().mon
168+ w := m.Current()
169+ w.Overflow, w.MaxColumns = OverflowCascade, 1
170+ m.AddWindow(1)
171+ m.AddWindow(2)
172+ named := m.newWorkspace()
173+ named.Name, named.Overflow = "hidden", OverflowScroll
174+ named.Columns = []Column{{Windows: []WindowID{3}}, {Windows: []WindowID{4}, BandStart: true}}
175+ m.hidden = append(m.hidden, named)
176+ empty := m.newWorkspace()
177+ empty.Name = "empty"
178+ m.hidden = append(m.hidden, empty)
179+ h := positionHintGrid(m)
180+ require.Equal(t, uint8(4), h.Columns)
181+ require.Equal(t, [16]uint8{2, 1, 1, 1}, h.Counts)
182+ require.Equal(t, uint8(0), h.ActiveCol)
183+}
184+
185+func TestPositionHintOverviewCommittedBaseline(t *testing.T) {
186+ for _, cancel := range []bool{false, true} {
187+ t.Run(map[bool]string{false: "accept", true: "cancel"}[cancel], func(t *testing.T) {
188+ c, _ := indicatorCore(t)
189+ c.cfg.Focus.PositionHint = true
190+ m := c.cur().mon
191+ m.AddWindow(1)
192+ origin := m.Current()
193+ other := m.Workspaces[1]
194+ other.AddWindow(2)
195+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
196+ m.ToggleOverview()
197+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
198+ m.showOverview(other)
199+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
200+ require.Same(t, origin, c.cur().positionHint.workspace)
201+ m.showOverview(origin)
202+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
203+ m.showOverview(other)
204+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
205+ if cancel {
206+ m.CancelOverview()
207+ } else {
208+ m.ToggleOverview()
209+ }
210+ require.Equal(t, !cancel, indicatorScene(t, c).PositionHint.Shown())
211+ })
212+ }
213+}
214+
215+func TestPositionHintScrolledAwayFullscreen(t *testing.T) {
216+ c, _ := indicatorCore(t)
217+ c.cfg.Focus.PositionHint = true
218+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
219+ a := c.screens[0]
220+ w := a.mon.Current()
221+ w.Overflow = OverflowScroll
222+ a.mon.AddWindow(1)
223+ a.mon.AddWindow(2)
224+ w.fullscreen = 1
225+ w.View = w.columnX(1)
226+ require.NotZero(t, w.fullscreen)
227+ require.Zero(t, w.cover())
228+ c.focusScreen = 1
229+ c.updatePositionHints(nil)
230+ c.focusScreen = 0
231+ c.updatePositionHints(nil)
232+ require.True(t, a.positionHint.sample.Shown())
233+}
234+
235+func TestPositionHintStartupHotplugReload(t *testing.T) {
236+ c, _ := indicatorCore(t)
237+ c.cfg.Focus.PositionHint = true
238+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
239+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
240+ for _, s := range stepScenes(t, c, nil) {
241+ require.False(t, s.PositionHint.Shown())
242+ }
243+ require.NoError(t, c.apply(c.cfg))
244+ for _, s := range stepScenes(t, c, nil) {
245+ require.False(t, s.PositionHint.Shown())
246+ }
247+ c.focusScreen = 1
248+ require.True(t, stepScenes(t, c, nil)["B"].PositionHint.Shown())
249+}
250+
251+func TestPositionHintMaxGridTransitionAllocations(t *testing.T) {
252+ c := publishRig(t, 1)
253+ c.cfg.Focus.PositionHint = true
254+ m := c.cur().mon
255+ for len(m.Workspaces) < 16 {
256+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
257+ }
258+ for _, w := range m.Workspaces {
259+ w.Overflow, w.MaxColumns = OverflowCascade, 1
260+ w.Columns = make([]Column, 16)
261+ for i := range w.Columns {
262+ w.Columns[i] = Column{Windows: []WindowID{WindowID(i + 100)}, BandStart: true}
263+ }
264+ }
265+ c.updatePositionHints(nil)
266+ defer c.stopPositionHintTimer()
267+ run := func() { m.Active = (m.Active + 1) % 16; c.updatePositionHints(nil) }
268+ run()
269+ require.Equal(t, [16]uint8{16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, c.cur().positionHint.sample.Counts)
270+ require.Zero(t, testing.AllocsPerRun(100, run))
271+}
272+
273+func TestPositionHintRunTimerLifecycle(t *testing.T) {
274+ synctest.Test(t, func(t *testing.T) {
275+ c := publishRig(t, 2)
276+ c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true
277+ c.ch.Commands = nil
278+ now := time.Unix(1000, 0)
279+ clock := portsmocks.NewMockClock(t)
280+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
281+ timer := portsmocks.NewMockTimer(t)
282+ tick := make(chan time.Time, 1)
283+ timer.EXPECT().C().Return((<-chan time.Time)(tick)).Maybe()
284+ timer.EXPECT().Reset(500 * time.Millisecond).Return(true).Once()
285+ timer.EXPECT().Reset(100 * time.Millisecond).Return(true).Once()
286+ timer.EXPECT().Stop().Return(true).Once()
287+ timer.EXPECT().Reset(positionHintHold).Return(true).Once()
288+ clock.EXPECT().NewTimer(positionHintHold).Return(timer).Once()
289+ // The second timer is the existing animation fallback, not another hint timer.
290+ frame := portsmocks.NewMockTimer(t)
291+ frame.EXPECT().C().Return(make(chan time.Time)).Maybe()
292+ frame.EXPECT().Stop().Return(true).Maybe()
293+ frame.EXPECT().Reset(mock.Anything).Return(true).Maybe()
294+ clock.EXPECT().NewTimer(mock.Anything).Return(frame).Maybe()
295+ c.opts.Clock = clock
296+ a, b := c.screens[0], c.screens[1]
297+ a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1
298+ a.mon.Current().Focus = 1
299+ c.updatePositionHints(nil)
300+ a.mon.Current().Focus = 0
301+ c.updatePositionHints(nil)
302+ now = now.Add(100 * time.Millisecond)
303+ c.focusScreen = 1
304+ c.updatePositionHints(nil)
305+ require.Same(t, timer, c.positionHintTimer.timer)
306+ frames := make(chan ports.OutputFrame)
307+ c.ch.Frames = frames
308+ ctx, cancel := context.WithCancel(context.Background())
309+ done := make(chan error, 1)
310+ go func() { done <- c.Run(ctx) }()
311+ synctest.Wait()
312+ now = now.Add(500 * time.Millisecond)
313+ tick <- now
314+ synctest.Wait()
315+ require.True(t, a.positionHint.fading)
316+ require.False(t, b.positionHint.fading)
317+ // B still holds for 100ms; the same timer selects its deadline.
318+ now = now.Add(100 * time.Millisecond)
319+ tick <- now
320+ synctest.Wait()
321+ require.True(t, b.positionHint.fading)
322+ require.Nil(t, c.positionHintTimer.timerC)
323+ now = now.Add(positionHintFade)
324+ frames <- ports.OutputFrame{Output: "A"}
325+ synctest.Wait()
326+ frames <- ports.OutputFrame{Output: "B"}
327+ synctest.Wait()
328+ require.False(t, a.positionHint.sample.Shown())
329+ require.False(t, b.positionHint.sample.Shown())
330+ require.False(t, c.animating())
331+ require.Nil(t, c.positionHintTimer.timerC)
332+ // Owner is durably blocked: disabling a newly restarted hint stops
333+ // the retained timer instead of leaving an idle wakeup armed.
334+ c.focusScreen = 0
335+ c.updatePositionHints(nil)
336+ require.NotNil(t, c.positionHintTimer.timerC)
337+ c.cfg.Focus.PositionHint = false
338+ c.updatePositionHints(nil)
339+ require.Nil(t, c.positionHintTimer.timerC)
340+ cancel()
341+ synctest.Wait()
342+ require.NoError(t, <-done)
343+ })
344+}
@@ -0,0 +1,344 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "testing/synctest"
7+ "time"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPositionHintChangesAndTiming(t *testing.T) {
16+ c, clock := indicatorCore(t)
17+ c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true
18+ a := c.cur()
19+ a.mon.Current().Overflow = OverflowCascade
20+ a.mon.Current().MaxColumns = 1
21+ a.mon.AddWindow(1)
22+ a.mon.AddWindow(2)
23+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
24+ b := c.screens[1]
25+ a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1
26+ a.mon.Current().Focus = 1
27+ c.updatePositionHints(nil)
28+ require.False(t, a.positionHint.live)
29+ a.mon.Current().Focus = 0
30+ c.updatePositionHints(nil)
31+ require.True(t, a.positionHint.sample.Shown())
32+ require.False(t, b.positionHint.live)
33+ start := a.positionHint.start
34+ clock.now = clock.now.Add(100 * time.Millisecond)
35+ c.updatePositionHints(nil)
36+ require.Equal(t, start, a.positionHint.start, "no-op restarted timer")
37+ a.mon.Current().Focus = 1
38+ c.updatePositionHints(nil)
39+ require.Equal(t, clock.now, a.positionHint.start)
40+ clock.now = clock.now.Add(positionHintHold)
41+ c.updatePositionHints(nil)
42+ require.True(t, c.positionHintFading(a), "hold boundary must start frame scheduling")
43+ clock.now = clock.now.Add(positionHintFade / 2)
44+ c.updatePositionHints(nil)
45+ require.Equal(t, float32(0.5), a.positionHint.sample.Opacity)
46+ saved := a.positionHint.sample
47+ c.focusScreen = 1
48+ c.updatePositionHints(b)
49+ require.Equal(t, saved, a.positionHint.sample)
50+ require.True(t, b.positionHint.live)
51+ clock.now = clock.now.Add(positionHintFade / 2)
52+ c.updatePositionHints(a)
53+ require.False(t, a.positionHint.live)
54+ require.True(t, b.positionHint.live)
55+}
56+
57+func TestPositionHintWorkspaceAndSuppression(t *testing.T) {
58+ for _, mode := range []string{"workspace", "overview", "fullscreen", "off", "no-animation"} {
59+ t.Run(mode, func(t *testing.T) {
60+ c, clock := indicatorCore(t)
61+ c.cfg.Focus.PositionHint = true
62+ sc := c.cur()
63+ sc.mon.AddWindow(1)
64+ c.updatePositionHints(nil)
65+ sc.mon.show(sc.mon.Workspaces[1])
66+ c.updatePositionHints(nil)
67+ require.True(t, sc.positionHint.live)
68+ switch mode {
69+ case "overview":
70+ sc.mon.ov.open = true
71+ case "fullscreen":
72+ sc.mon.Current().Overflow = OverflowFixed
73+ sc.mon.Current().fullscreen = 1
74+ case "off":
75+ c.cfg.Focus.PositionHint = false
76+ default:
77+ clock.now = clock.now.Add(positionHintHold)
78+ }
79+ c.updatePositionHints(nil)
80+ require.False(t, sc.positionHint.sample.Shown())
81+ require.Nil(t, c.positionHintTimer.timerC)
82+ })
83+ }
84+}
85+
86+func TestPositionHintGrid(t *testing.T) {
87+ c, _ := indicatorCore(t)
88+ m := c.cur().mon
89+ w := m.Current()
90+ w.Overflow, w.MaxColumns = OverflowCascade, 1
91+ m.AddWindow(1)
92+ m.AddWindow(2)
93+ h := positionHintGrid(m)
94+ require.False(t, h.Vertical)
95+ require.Equal(t, uint8(2), h.Counts[0])
96+ require.Equal(t, uint8(1), h.Counts[1], "empty noncascade workspace")
97+ require.Equal(t, uint8(1), h.ActiveRow)
98+ m.show(m.Workspaces[1])
99+ h = positionHintGrid(m)
100+ require.True(t, h.Vertical, "current workspace decides axis")
101+ require.Equal(t, uint8(1), h.Columns)
102+ require.Equal(t, uint8(2), h.Counts[0])
103+ require.Equal(t, uint8(1), h.ActiveRow)
104+}
105+
106+func TestPositionHintAllocations(t *testing.T) {
107+ // Real clock avoids measuring generated mock bookkeeping, as publishRig does.
108+ c := publishRig(t, 1)
109+ c.cfg.Focus.PositionHint = true
110+ sc := c.cur()
111+ w := sc.mon.Current()
112+ w.Overflow, w.MaxColumns = OverflowCascade, 1
113+ c.updatePositionHints(nil)
114+ defer c.stopPositionHintTimer()
115+ for name, run := range map[string]func(){
116+ "trigger": func() { w.Focus = (w.Focus + 1) % 2; c.updatePositionHints(nil) },
117+ "fade": func() {
118+ sc.positionHint.live = true
119+ sc.positionHint.start = c.now().Add(-positionHintHold - 50*time.Millisecond)
120+ c.updatePositionHints(nil)
121+ },
122+ "inactive": func() { sc.positionHint.live = false; c.updatePositionHints(nil) },
123+ } {
124+ t.Run(name, func(t *testing.T) { run(); require.Zero(t, testing.AllocsPerRun(100, run)) })
125+ }
126+}
127+
128+func TestPositionHintMoveWorkspaceDestination(t *testing.T) {
129+ c, _ := indicatorCore(t)
130+ c.cfg.Focus.PositionHint = true
131+ c.cur().mon.AddWindow(1)
132+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
133+ c.updatePositionHints(nil)
134+ source := c.cur()
135+ moved := source.mon.Current()
136+ c.moveWorkspace(dirRight)
137+ c.updatePositionHints(nil)
138+ require.Same(t, moved, c.cur().mon.Current())
139+ require.False(t, source.positionHint.live)
140+ require.True(t, c.cur().positionHint.sample.Shown())
141+}
142+
143+func TestPositionHintCapsAndProtection(t *testing.T) {
144+ c, _ := indicatorCore(t)
145+ c.cfg.Focus.PositionHint = true
146+ m := c.cur().mon
147+ for range 20 {
148+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
149+ }
150+ m.Active = 20
151+ h := positionHintGrid(m)
152+ require.Equal(t, uint8(16), h.Counts[0])
153+ require.Equal(t, uint8(15), h.ActiveRow)
154+ m.Current().Overflow = OverflowCascade
155+ h = positionHintGrid(m)
156+ require.Equal(t, uint8(16), h.Columns)
157+ require.Equal(t, uint8(15), h.ActiveCol)
158+ c.updatePositionHints(nil)
159+ c.cur().positionHint.live = true
160+ c.security.Protected = true
161+ c.updatePositionHints(nil)
162+ require.False(t, c.cur().positionHint.live)
163+}
164+
165+func TestPositionHintIncludesNamedEmptyAndMixedOverflow(t *testing.T) {
166+ c, _ := indicatorCore(t)
167+ m := c.cur().mon
168+ w := m.Current()
169+ w.Overflow, w.MaxColumns = OverflowCascade, 1
170+ m.AddWindow(1)
171+ m.AddWindow(2)
172+ named := m.newWorkspace()
173+ named.Name, named.Overflow = "hidden", OverflowScroll
174+ named.Columns = []Column{{Windows: []WindowID{3}}, {Windows: []WindowID{4}, BandStart: true}}
175+ m.hidden = append(m.hidden, named)
176+ empty := m.newWorkspace()
177+ empty.Name = "empty"
178+ m.hidden = append(m.hidden, empty)
179+ h := positionHintGrid(m)
180+ require.Equal(t, uint8(4), h.Columns)
181+ require.Equal(t, [16]uint8{2, 1, 1, 1}, h.Counts)
182+ require.Equal(t, uint8(0), h.ActiveCol)
183+}
184+
185+func TestPositionHintOverviewCommittedBaseline(t *testing.T) {
186+ for _, cancel := range []bool{false, true} {
187+ t.Run(map[bool]string{false: "accept", true: "cancel"}[cancel], func(t *testing.T) {
188+ c, _ := indicatorCore(t)
189+ c.cfg.Focus.PositionHint = true
190+ m := c.cur().mon
191+ m.AddWindow(1)
192+ origin := m.Current()
193+ other := m.Workspaces[1]
194+ other.AddWindow(2)
195+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
196+ m.ToggleOverview()
197+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
198+ m.showOverview(other)
199+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
200+ require.Same(t, origin, c.cur().positionHint.workspace)
201+ m.showOverview(origin)
202+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
203+ m.showOverview(other)
204+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
205+ if cancel {
206+ m.CancelOverview()
207+ } else {
208+ m.ToggleOverview()
209+ }
210+ require.Equal(t, !cancel, indicatorScene(t, c).PositionHint.Shown())
211+ })
212+ }
213+}
214+
215+func TestPositionHintScrolledAwayFullscreen(t *testing.T) {
216+ c, _ := indicatorCore(t)
217+ c.cfg.Focus.PositionHint = true
218+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
219+ a := c.screens[0]
220+ w := a.mon.Current()
221+ w.Overflow = OverflowScroll
222+ a.mon.AddWindow(1)
223+ a.mon.AddWindow(2)
224+ w.fullscreen = 1
225+ w.View = w.columnX(1)
226+ require.NotZero(t, w.fullscreen)
227+ require.Zero(t, w.cover())
228+ c.focusScreen = 1
229+ c.updatePositionHints(nil)
230+ c.focusScreen = 0
231+ c.updatePositionHints(nil)
232+ require.True(t, a.positionHint.sample.Shown())
233+}
234+
235+func TestPositionHintStartupHotplugReload(t *testing.T) {
236+ c, _ := indicatorCore(t)
237+ c.cfg.Focus.PositionHint = true
238+ require.False(t, indicatorScene(t, c).PositionHint.Shown())
239+ c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
240+ for _, s := range stepScenes(t, c, nil) {
241+ require.False(t, s.PositionHint.Shown())
242+ }
243+ require.NoError(t, c.apply(c.cfg))
244+ for _, s := range stepScenes(t, c, nil) {
245+ require.False(t, s.PositionHint.Shown())
246+ }
247+ c.focusScreen = 1
248+ require.True(t, stepScenes(t, c, nil)["B"].PositionHint.Shown())
249+}
250+
251+func TestPositionHintMaxGridTransitionAllocations(t *testing.T) {
252+ c := publishRig(t, 1)
253+ c.cfg.Focus.PositionHint = true
254+ m := c.cur().mon
255+ for len(m.Workspaces) < 16 {
256+ m.Workspaces = append(m.Workspaces, m.newWorkspace())
257+ }
258+ for _, w := range m.Workspaces {
259+ w.Overflow, w.MaxColumns = OverflowCascade, 1
260+ w.Columns = make([]Column, 16)
261+ for i := range w.Columns {
262+ w.Columns[i] = Column{Windows: []WindowID{WindowID(i + 100)}, BandStart: true}
263+ }
264+ }
265+ c.updatePositionHints(nil)
266+ defer c.stopPositionHintTimer()
267+ run := func() { m.Active = (m.Active + 1) % 16; c.updatePositionHints(nil) }
268+ run()
269+ require.Equal(t, [16]uint8{16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, c.cur().positionHint.sample.Counts)
270+ require.Zero(t, testing.AllocsPerRun(100, run))
271+}
272+
273+func TestPositionHintRunTimerLifecycle(t *testing.T) {
274+ synctest.Test(t, func(t *testing.T) {
275+ c := publishRig(t, 2)
276+ c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true
277+ c.ch.Commands = nil
278+ now := time.Unix(1000, 0)
279+ clock := portsmocks.NewMockClock(t)
280+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
281+ timer := portsmocks.NewMockTimer(t)
282+ tick := make(chan time.Time, 1)
283+ timer.EXPECT().C().Return((<-chan time.Time)(tick)).Maybe()
284+ timer.EXPECT().Reset(500 * time.Millisecond).Return(true).Once()
285+ timer.EXPECT().Reset(100 * time.Millisecond).Return(true).Once()
286+ timer.EXPECT().Stop().Return(true).Once()
287+ timer.EXPECT().Reset(positionHintHold).Return(true).Once()
288+ clock.EXPECT().NewTimer(positionHintHold).Return(timer).Once()
289+ // The second timer is the existing animation fallback, not another hint timer.
290+ frame := portsmocks.NewMockTimer(t)
291+ frame.EXPECT().C().Return(make(chan time.Time)).Maybe()
292+ frame.EXPECT().Stop().Return(true).Maybe()
293+ frame.EXPECT().Reset(mock.Anything).Return(true).Maybe()
294+ clock.EXPECT().NewTimer(mock.Anything).Return(frame).Maybe()
295+ c.opts.Clock = clock
296+ a, b := c.screens[0], c.screens[1]
297+ a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1
298+ a.mon.Current().Focus = 1
299+ c.updatePositionHints(nil)
300+ a.mon.Current().Focus = 0
301+ c.updatePositionHints(nil)
302+ now = now.Add(100 * time.Millisecond)
303+ c.focusScreen = 1
304+ c.updatePositionHints(nil)
305+ require.Same(t, timer, c.positionHintTimer.timer)
306+ frames := make(chan ports.OutputFrame)
307+ c.ch.Frames = frames
308+ ctx, cancel := context.WithCancel(context.Background())
309+ done := make(chan error, 1)
310+ go func() { done <- c.Run(ctx) }()
311+ synctest.Wait()
312+ now = now.Add(500 * time.Millisecond)
313+ tick <- now
314+ synctest.Wait()
315+ require.True(t, a.positionHint.fading)
316+ require.False(t, b.positionHint.fading)
317+ // B still holds for 100ms; the same timer selects its deadline.
318+ now = now.Add(100 * time.Millisecond)
319+ tick <- now
320+ synctest.Wait()
321+ require.True(t, b.positionHint.fading)
322+ require.Nil(t, c.positionHintTimer.timerC)
323+ now = now.Add(positionHintFade)
324+ frames <- ports.OutputFrame{Output: "A"}
325+ synctest.Wait()
326+ frames <- ports.OutputFrame{Output: "B"}
327+ synctest.Wait()
328+ require.False(t, a.positionHint.sample.Shown())
329+ require.False(t, b.positionHint.sample.Shown())
330+ require.False(t, c.animating())
331+ require.Nil(t, c.positionHintTimer.timerC)
332+ // Owner is durably blocked: disabling a newly restarted hint stops
333+ // the retained timer instead of leaving an idle wakeup armed.
334+ c.focusScreen = 0
335+ c.updatePositionHints(nil)
336+ require.NotNil(t, c.positionHintTimer.timerC)
337+ c.cfg.Focus.PositionHint = false
338+ c.updatePositionHints(nil)
339+ require.Nil(t, c.positionHintTimer.timerC)
340+ cancel()
341+ synctest.Wait()
342+ require.NoError(t, <-done)
343+ })
344+}
M internal/core/scene.go +1 -0
@@ -121,6 +121,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
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 +37 -0
@@ -0,0 +1,37 @@
1+package ports
2+
3+const (
4+ PositionHintMaxColumns = 16
5+ PositionHintMaxRows = 16
6+ PositionHintDiameter = 8
7+ PositionHintGap = 10
8+ PositionHintPadding = 10
9+ PositionHintBottom = 48
10+ PositionHintRadius = 10
11+)
12+
13+// PositionHint is a bounded, comparable snapshot. Scenes copy it by value;
14+// neither fading nor a changed grid shares mutable storage with a renderer.
15+type PositionHint struct {
16+ Opacity float32
17+ Vertical bool
18+ Columns uint8
19+ Counts [PositionHintMaxColumns]uint8
20+ ActiveCol, ActiveRow uint8
21+}
22+
23+func (h PositionHint) Shown() bool { return h.Opacity > 0 && h.Columns > 0 }
24+
25+// Bounds is the hint's logical output-local background rectangle.
26+func (h PositionHint) Bounds(width, height int) Rect {
27+ if !h.Shown() {
28+ return Rect{}
29+ }
30+ cols, rows := min(int(h.Columns), PositionHintMaxColumns), 1
31+ for _, n := range h.Counts[:cols] {
32+ rows = max(rows, min(int(n), PositionHintMaxRows))
33+ }
34+ w := 2*PositionHintPadding + cols*PositionHintDiameter + (cols-1)*PositionHintGap
35+ ht := 2*PositionHintPadding + rows*PositionHintDiameter + (rows-1)*PositionHintGap
36+ return Rect{X: (width - w) / 2, Y: height - PositionHintBottom - ht, W: w, H: ht}
37+}
@@ -0,0 +1,37 @@
1+package ports
2+
3+const (
4+ PositionHintMaxColumns = 16
5+ PositionHintMaxRows = 16
6+ PositionHintDiameter = 8
7+ PositionHintGap = 10
8+ PositionHintPadding = 10
9+ PositionHintBottom = 48
10+ PositionHintRadius = 10
11+)
12+
13+// PositionHint is a bounded, comparable snapshot. Scenes copy it by value;
14+// neither fading nor a changed grid shares mutable storage with a renderer.
15+type PositionHint struct {
16+ Opacity float32
17+ Vertical bool
18+ Columns uint8
19+ Counts [PositionHintMaxColumns]uint8
20+ ActiveCol, ActiveRow uint8
21+}
22+
23+func (h PositionHint) Shown() bool { return h.Opacity > 0 && h.Columns > 0 }
24+
25+// Bounds is the hint's logical output-local background rectangle.
26+func (h PositionHint) Bounds(width, height int) Rect {
27+ if !h.Shown() {
28+ return Rect{}
29+ }
30+ cols, rows := min(int(h.Columns), PositionHintMaxColumns), 1
31+ for _, n := range h.Counts[:cols] {
32+ rows = max(rows, min(int(n), PositionHintMaxRows))
33+ }
34+ w := 2*PositionHintPadding + cols*PositionHintDiameter + (cols-1)*PositionHintGap
35+ ht := 2*PositionHintPadding + rows*PositionHintDiameter + (rows-1)*PositionHintGap
36+ return Rect{X: (width - w) / 2, Y: height - PositionHintBottom - ht, W: w, H: ht}
37+}
M internal/ports/scene.go +3 -1
@@ -77,6 +77,8 @@ type Scene struct {
7777 // Flash is the focus flash drawn over everything of this output; nil
7878 // draws nothing.
7979 Flash *FocusFlash
80+ // PositionHint is drawn on the physical output only, never in captures.
81+ PositionHint PositionHint
8082 // Animating is set by core when a spring or a focus flash runs on
8183 // this output after this frame was computed: the output reports the
8284 // flip of the scene to core (OutputFrame), which moves the animation to
@@ -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,6 +77,8 @@ 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+ // PositionHint is drawn on the physical output only, never in captures.
81+ PositionHint PositionHint
80 // Animating is set by core when a spring or a focus flash runs on82 // 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 the83 // this output after this frame was computed: the output reports the
82 // flip of the scene to core (OutputFrame), which moves the animation to84 // flip of the scene to core (OutputFrame), which moves the animation to
@@ -92,7 +94,7 @@ type Scene struct {
92 // is not compared). Add every new Scene field here;94 // is not compared). Add every new Scene field here;
93 // TestSceneSameAsCoversEveryField fails until the field count is updated.95 // TestSceneSameAsCoversEveryField fails until the field count is updated.
94 func (s Scene) SameAs(o Scene) bool {96 func (s Scene) SameAs(o Scene) bool {
95- if s.Security != o.Security || s.Output != o.Output ||97+ if s.PositionHint != o.PositionHint || s.Security != o.Security || s.Output != o.Output ||
96 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||98 s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
97 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||99 s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
98 s.Background != o.Background || s.Border != o.Border ||100 s.Background != o.Background || s.Border != o.Border ||
M internal/ports/scene_same_test.go +2 -1
@@ -38,6 +38,7 @@ func TestSceneSameAs(t *testing.T) {
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 }

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close