detached e5ef8d7

Merge ba1d5fd7a20428d8eb45e2575730780e64b96283 into d5d679d9bbf4d05853071f869a5fc768109095c6

commit
e5ef8d7dca1a4224d5f768f1601ab8a9966714ec
author
ermite · · 2026-10-20 02:10 UTC
merge of
d5d679d + ba1d5fd · diff against the first parent
signature
unsigned
23 files · +787 -17
M docs/config.md +8 -0
@@ -50,6 +50,7 @@ See [examples/config](../examples/config) for every key with its default.
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 }

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