detached 3ac0020

Merge cee25ed447b54217b537e2368bb78d10be018933 into 37f59a2137a9674de4682e4dfbb501bef42894b5

commit
3ac00205de34a31c58f12d5ec8dae2a2579a8e53
author
ermite · · 2026-10-21 08:22 UTC
merge of
37f59a2 + cee25ed · diff against the first parent
signature
unsigned
15 files · +594 -62
M internal/adapters/vulkan/position_hint.go +4 -15
@@ -2,7 +2,6 @@ package vulkan
22
33 import (
44 "image"
5- "math"
65
76 "github.com/bnema/neferwl/internal/ports"
87 )
@@ -38,20 +37,10 @@ func (w *sceneWalk) positionHint() {
3837 w.alpha = alpha
3938 }
4039
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.
40+// hintRound draws one rounded quad (SDF in the shader), so the pill and each
41+// dot cost a single draw whatever the scale. Fills are one translucent draw,
42+// so alpha stays exact.
4443 func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) {
4544 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- }
45+ w.fillRounded(r, color, float32(radius))
5746 }
@@ -2,7 +2,6 @@ package vulkan
2 2
3 import (3 import (
4 "image"4 "image"
5- "math"
6 5
7 "github.com/bnema/neferwl/internal/ports"6 "github.com/bnema/neferwl/internal/ports"
8 )7 )
@@ -38,20 +37,10 @@ func (w *sceneWalk) positionHint() {
38 w.alpha = alpha37 w.alpha = alpha
39 }38 }
40 39
41-// Rounded fills use the existing solid instances and batching pipeline. Each40+// hintRound draws one rounded quad (SDF in the shader), so the pill and each
42-// physical scanline is bounded by the scaled radius; no textures or per-frame41+// dot cost a single draw whatever the scale. Fills are one translucent draw,
43-// buffers are allocated. Non-overlapping strips preserve translucent alpha.42+// so alpha stays exact.
44 func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) {43 func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) {
45 radius = min(radius, r.Dx()/2, r.Dy()/2)44 radius = min(radius, r.Dx()/2, r.Dy()/2)
46- if radius <= 0 {45+ w.fillRounded(r, color, float32(radius))
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 }46 }
A internal/adapters/vulkan/position_hint_bench_test.go +35 -0
@@ -0,0 +1,35 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func hintGrid(cols, rows int) ports.PositionHint {
11+ h := ports.PositionHint{Opacity: 1, Columns: uint8(cols), ActiveCol: 1, ActiveRow: 1}
12+ for c := range cols {
13+ h.Counts[c] = uint8(rows)
14+ }
15+ return h
16+}
17+
18+func BenchmarkPositionHintDraws(b *testing.B) {
19+ for _, g := range []struct {
20+ name string
21+ cols, rows int
22+ }{{"5x3", 5, 3}, {"16x16", 16, 16}} {
23+ b.Run(g.name, func(b *testing.B) {
24+ r := &Renderer{width: 1920, height: 1080}
25+ s := ports.Scene{OutputWidth: 1920, OutputHeight: 1080, Scale: 1, PositionHint: hintGrid(g.cols, g.rows)}
26+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
27+ b.ReportAllocs()
28+ var n int
29+ for b.Loop() {
30+ n = len(r.draws(s, nil, dmg))
31+ }
32+ b.ReportMetric(float64(n), "draws")
33+ })
34+ }
35+}
@@ -0,0 +1,35 @@
1+package vulkan
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func hintGrid(cols, rows int) ports.PositionHint {
11+ h := ports.PositionHint{Opacity: 1, Columns: uint8(cols), ActiveCol: 1, ActiveRow: 1}
12+ for c := range cols {
13+ h.Counts[c] = uint8(rows)
14+ }
15+ return h
16+}
17+
18+func BenchmarkPositionHintDraws(b *testing.B) {
19+ for _, g := range []struct {
20+ name string
21+ cols, rows int
22+ }{{"5x3", 5, 3}, {"16x16", 16, 16}} {
23+ b.Run(g.name, func(b *testing.B) {
24+ r := &Renderer{width: 1920, height: 1080}
25+ s := ports.Scene{OutputWidth: 1920, OutputHeight: 1080, Scale: 1, PositionHint: hintGrid(g.cols, g.rows)}
26+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
27+ b.ReportAllocs()
28+ var n int
29+ for b.Loop() {
30+ n = len(r.draws(s, nil, dmg))
31+ }
32+ b.ReportMetric(float64(n), "draws")
33+ })
34+ }
35+}
M internal/adapters/vulkan/position_hint_test.go +91 -0
@@ -4,6 +4,7 @@ import (
44 "github.com/bnema/neferwl/internal/ports"
55 "github.com/stretchr/testify/require"
66 "image"
7+ "math"
78 "testing"
89 )
910
@@ -69,3 +70,93 @@ func TestPositionHintDamageTransitions(t *testing.T) {
6970 require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately")
7071 })
7172 }
73+
74+func TestPositionHintRoundedQuads(t *testing.T) {
75+ for _, scale := range []float64{1, 2} {
76+ r := &Renderer{width: int(640 * scale), height: int(480 * scale)}
77+ h := hintGrid(3, 2)
78+ s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: scale, PositionHint: h}
79+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
80+ draws := r.draws(s, nil, dmg)
81+ require.Len(t, draws, 1+6)
82+ w := sceneWalk{scale: scale}
83+ b := h.Bounds(640, 480)
84+ pill := w.physRect(b.X, b.Y, b.W, b.H)
85+ for i, d := range draws {
86+ require.NotZero(t, d.pc.misc[1]&flagRounded, "draw %d", i)
87+ require.Zero(t, d.pc.misc[1]&flagRing)
88+ require.Equal(t, roundRect(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))), d.round)
89+ }
90+ require.Equal(t, pill, image.Rect(int(draws[0].pc.rect[0]), int(draws[0].pc.rect[1]), int(draws[0].pc.rect[2]), int(draws[0].pc.rect[3])))
91+ require.Equal(t, float32(w.phys(ports.PositionHintRadius)), math.Float32frombits(draws[0].radii[0]))
92+ // Active dot (col 1,row 1) is the larger one: radius = phys(active diameter)/2.
93+ active := draws[1+1*2+1]
94+ require.Equal(t, float32(w.phys(ports.PositionHintActiveDiameter)/2), math.Float32frombits(active.radii[0]))
95+ other := draws[1]
96+ require.Equal(t, float32(w.phys(ports.PositionHintDiameter)/2), math.Float32frombits(other.radii[0]))
97+ }
98+}
99+
100+func TestPositionHintRoundedOrient(t *testing.T) {
101+ r := &Renderer{width: 640, height: 480}
102+ s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: 1, PositionHint: hintGrid(2, 2)}
103+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
104+ ds := r.draws(s, nil, dmg)
105+ r.orient(ds, ports.BufferTransform(1))
106+ for _, d := range ds {
107+ require.Equal(t, roundRect(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))), d.round)
108+ }
109+}
110+
111+func TestHintRoundDraws(t *testing.T) {
112+ bounds := image.Rect(0, 0, 100, 100)
113+ for _, tc := range []struct {
114+ name string
115+ rect image.Rectangle
116+ radius int
117+ wantDraws int
118+ wantRadius float32
119+ }{
120+ {"inside", image.Rect(10, 10, 50, 40), 6, 1, 6},
121+ {"clipped fully off output", image.Rect(200, 200, 240, 230), 6, 0, 0},
122+ {"clipped by bounds", image.Rect(90, 90, 130, 130), 6, 1, 6},
123+ {"tiny pill clamps the radius", image.Rect(10, 10, 16, 14), 100, 1, 2},
124+ {"zero radius is a plain fill", image.Rect(10, 10, 50, 40), 0, 1, 0},
125+ {"negative radius is a plain fill", image.Rect(10, 10, 50, 40), -3, 1, 0},
126+ } {
127+ t.Run(tc.name, func(t *testing.T) {
128+ w := sceneWalk{r: &Renderer{width: 100, height: 100}, alpha: 1, bounds: bounds}
129+ w.hintRound(tc.rect, tc.radius, [3]uint8{1, 2, 3})
130+ require.Len(t, w.draws, tc.wantDraws)
131+ for _, d := range w.draws {
132+ if tc.wantRadius == 0 {
133+ require.Zero(t, d.pc.misc[1]&flagRounded)
134+ continue
135+ }
136+ require.NotZero(t, d.pc.misc[1]&flagRounded)
137+ require.Equal(t, roundRect(tc.rect), d.round, "the clip is the requested rect")
138+ require.Equal(t, tc.wantRadius, math.Float32frombits(d.radii[0]))
139+ }
140+ })
141+ }
142+}
143+
144+func TestPositionHintRoundedRotatedOrient(t *testing.T) {
145+ for transform := ports.BufferTransform(1); transform < 8; transform++ {
146+ r := &Renderer{width: 640, height: 480}
147+ sw, sh := r.sceneSize(transform)
148+ s := ports.Scene{OutputWidth: sw, OutputHeight: sh, Scale: 1, Transform: transform, PositionHint: hintGrid(2, 2)}
149+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, sw, sh)))
150+ require.Len(t, ds, 1+4)
151+ sceneRects := make([]image.Rectangle, len(ds))
152+ for i, d := range ds {
153+ sceneRects[i] = image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))
154+ }
155+ r.orient(ds, transform)
156+ for i, d := range ds {
157+ want := transform.RectToBuffer(sceneRects[i], sw, sh)
158+ require.Equal(t, roundRect(want), d.round, "transform %d draw %d", transform, i)
159+ require.NotZero(t, d.pc.misc[1]&flagRounded)
160+ }
161+ }
162+}
@@ -4,6 +4,7 @@ import (
4 "github.com/bnema/neferwl/internal/ports"4 "github.com/bnema/neferwl/internal/ports"
5 "github.com/stretchr/testify/require"5 "github.com/stretchr/testify/require"
6 "image"6 "image"
7+ "math"
7 "testing"8 "testing"
8 )9 )
9 10
@@ -69,3 +70,93 @@ func TestPositionHintDamageTransitions(t *testing.T) {
69 require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately")70 require.False(t, ok, "scale change requires full target redraw; geometry scaling is checked separately")
70 })71 })
71 }72 }
73+
74+func TestPositionHintRoundedQuads(t *testing.T) {
75+ for _, scale := range []float64{1, 2} {
76+ r := &Renderer{width: int(640 * scale), height: int(480 * scale)}
77+ h := hintGrid(3, 2)
78+ s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: scale, PositionHint: h}
79+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
80+ draws := r.draws(s, nil, dmg)
81+ require.Len(t, draws, 1+6)
82+ w := sceneWalk{scale: scale}
83+ b := h.Bounds(640, 480)
84+ pill := w.physRect(b.X, b.Y, b.W, b.H)
85+ for i, d := range draws {
86+ require.NotZero(t, d.pc.misc[1]&flagRounded, "draw %d", i)
87+ require.Zero(t, d.pc.misc[1]&flagRing)
88+ require.Equal(t, roundRect(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))), d.round)
89+ }
90+ require.Equal(t, pill, image.Rect(int(draws[0].pc.rect[0]), int(draws[0].pc.rect[1]), int(draws[0].pc.rect[2]), int(draws[0].pc.rect[3])))
91+ require.Equal(t, float32(w.phys(ports.PositionHintRadius)), math.Float32frombits(draws[0].radii[0]))
92+ // Active dot (col 1,row 1) is the larger one: radius = phys(active diameter)/2.
93+ active := draws[1+1*2+1]
94+ require.Equal(t, float32(w.phys(ports.PositionHintActiveDiameter)/2), math.Float32frombits(active.radii[0]))
95+ other := draws[1]
96+ require.Equal(t, float32(w.phys(ports.PositionHintDiameter)/2), math.Float32frombits(other.radii[0]))
97+ }
98+}
99+
100+func TestPositionHintRoundedOrient(t *testing.T) {
101+ r := &Renderer{width: 640, height: 480}
102+ s := ports.Scene{OutputWidth: 640, OutputHeight: 480, Scale: 1, PositionHint: hintGrid(2, 2)}
103+ dmg := newDamage(&target{}, s, image.Rect(0, 0, r.width, r.height))
104+ ds := r.draws(s, nil, dmg)
105+ r.orient(ds, ports.BufferTransform(1))
106+ for _, d := range ds {
107+ require.Equal(t, roundRect(image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))), d.round)
108+ }
109+}
110+
111+func TestHintRoundDraws(t *testing.T) {
112+ bounds := image.Rect(0, 0, 100, 100)
113+ for _, tc := range []struct {
114+ name string
115+ rect image.Rectangle
116+ radius int
117+ wantDraws int
118+ wantRadius float32
119+ }{
120+ {"inside", image.Rect(10, 10, 50, 40), 6, 1, 6},
121+ {"clipped fully off output", image.Rect(200, 200, 240, 230), 6, 0, 0},
122+ {"clipped by bounds", image.Rect(90, 90, 130, 130), 6, 1, 6},
123+ {"tiny pill clamps the radius", image.Rect(10, 10, 16, 14), 100, 1, 2},
124+ {"zero radius is a plain fill", image.Rect(10, 10, 50, 40), 0, 1, 0},
125+ {"negative radius is a plain fill", image.Rect(10, 10, 50, 40), -3, 1, 0},
126+ } {
127+ t.Run(tc.name, func(t *testing.T) {
128+ w := sceneWalk{r: &Renderer{width: 100, height: 100}, alpha: 1, bounds: bounds}
129+ w.hintRound(tc.rect, tc.radius, [3]uint8{1, 2, 3})
130+ require.Len(t, w.draws, tc.wantDraws)
131+ for _, d := range w.draws {
132+ if tc.wantRadius == 0 {
133+ require.Zero(t, d.pc.misc[1]&flagRounded)
134+ continue
135+ }
136+ require.NotZero(t, d.pc.misc[1]&flagRounded)
137+ require.Equal(t, roundRect(tc.rect), d.round, "the clip is the requested rect")
138+ require.Equal(t, tc.wantRadius, math.Float32frombits(d.radii[0]))
139+ }
140+ })
141+ }
142+}
143+
144+func TestPositionHintRoundedRotatedOrient(t *testing.T) {
145+ for transform := ports.BufferTransform(1); transform < 8; transform++ {
146+ r := &Renderer{width: 640, height: 480}
147+ sw, sh := r.sceneSize(transform)
148+ s := ports.Scene{OutputWidth: sw, OutputHeight: sh, Scale: 1, Transform: transform, PositionHint: hintGrid(2, 2)}
149+ ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, sw, sh)))
150+ require.Len(t, ds, 1+4)
151+ sceneRects := make([]image.Rectangle, len(ds))
152+ for i, d := range ds {
153+ sceneRects[i] = image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3]))
154+ }
155+ r.orient(ds, transform)
156+ for i, d := range ds {
157+ want := transform.RectToBuffer(sceneRects[i], sw, sh)
158+ require.Equal(t, roundRect(want), d.round, "transform %d draw %d", transform, i)
159+ require.NotZero(t, d.pc.misc[1]&flagRounded)
160+ }
161+ }
162+}
M internal/adapters/vulkan/scene.go +18 -11
@@ -92,14 +92,11 @@ func (w *sceneWalk) focusFlash() {
9292 // radius, minus a hole inset by b with radius max(radius-b, 0).
9393 func (w *sceneWalk) roundedInnerBorder(t ports.Rect, b, radius int, col [3]uint8) {
9494 outer := w.physRect(t.X, t.Y, t.W, t.H)
95- n := len(w.draws)
96- w.fill(outer, col)
97- if len(w.draws) == n {
95+ r := float32(math.Round(float64(radius) * w.scale))
96+ dr := w.fillRounded(outer, col, r)
97+ if dr == nil {
9898 return
9999 }
100- r := float32(math.Round(float64(radius) * w.scale))
101- dr := &w.draws[n]
102- roundedDraw(dr, outer, r)
103100 dr.pc.misc[1] |= flagRing
104101 hole := w.physRect(t.X+b, t.Y+b, t.W-2*b, t.H-2*b)
105102 bPhys := min(hole.Min.X-outer.Min.X, hole.Min.Y-outer.Min.Y, outer.Max.X-hole.Max.X, outer.Max.Y-hole.Max.Y)
@@ -196,6 +193,20 @@ func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
196193 w.draws = append(w.draws, dr)
197194 }
198195
196+// fillRounded is fill with rounded corners of radius (physical pixels). It
197+// returns the new draw, or nil when fill emitted nothing or radius <= 0 (the
198+// plain fill is kept). The pointer is valid only until the next append.
199+func (w *sceneWalk) fillRounded(r image.Rectangle, c [3]uint8, radius float32) *draw {
200+ n := len(w.draws)
201+ w.fill(r, c)
202+ if len(w.draws) == n || radius <= 0 {
203+ return nil
204+ }
205+ dr := &w.draws[n]
206+ roundedDraw(dr, r, radius)
207+ return dr
208+}
209+
199210 // dim paints a translucent black quad over rect (physical), clipped to the
200211 // output, at alpha scaled by the walk's alpha. Damage clipping later limits
201212 // it to the region being repainted, just like other fills.
@@ -293,11 +304,7 @@ func (w *sceneWalk) windows() {
293304 bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y)
294305 innerRadius = max(radius-float32(max(bPhys, 0)), 0)
295306 if col := w.ringColor(win.ID); col != "" && b > 0 {
296- n := len(w.draws)
297- w.fill(outer, parseColor(col))
298- if len(w.draws) > n {
299- dr := &w.draws[n]
300- roundedDraw(dr, outer, radius)
307+ if dr := w.fillRounded(outer, parseColor(col), radius); dr != nil {
301308 dr.pc.misc[1] |= flagRing
302309 // Opaque, the ring runs 1px under the body so no seam
303310 // shows; faded, that overlap would double up: the hole
@@ -92,14 +92,11 @@ func (w *sceneWalk) focusFlash() {
92 // radius, minus a hole inset by b with radius max(radius-b, 0).92 // radius, minus a hole inset by b with radius max(radius-b, 0).
93 func (w *sceneWalk) roundedInnerBorder(t ports.Rect, b, radius int, col [3]uint8) {93 func (w *sceneWalk) roundedInnerBorder(t ports.Rect, b, radius int, col [3]uint8) {
94 outer := w.physRect(t.X, t.Y, t.W, t.H)94 outer := w.physRect(t.X, t.Y, t.W, t.H)
95- n := len(w.draws)95+ r := float32(math.Round(float64(radius) * w.scale))
96- w.fill(outer, col)96+ dr := w.fillRounded(outer, col, r)
97- if len(w.draws) == n {97+ if dr == nil {
98 return98 return
99 }99 }
100- r := float32(math.Round(float64(radius) * w.scale))
101- dr := &w.draws[n]
102- roundedDraw(dr, outer, r)
103 dr.pc.misc[1] |= flagRing100 dr.pc.misc[1] |= flagRing
104 hole := w.physRect(t.X+b, t.Y+b, t.W-2*b, t.H-2*b)101 hole := w.physRect(t.X+b, t.Y+b, t.W-2*b, t.H-2*b)
105 bPhys := min(hole.Min.X-outer.Min.X, hole.Min.Y-outer.Min.Y, outer.Max.X-hole.Max.X, outer.Max.Y-hole.Max.Y)102 bPhys := min(hole.Min.X-outer.Min.X, hole.Min.Y-outer.Min.Y, outer.Max.X-hole.Max.X, outer.Max.Y-hole.Max.Y)
@@ -196,6 +193,20 @@ func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) {
196 w.draws = append(w.draws, dr)193 w.draws = append(w.draws, dr)
197 }194 }
198 195
196+// fillRounded is fill with rounded corners of radius (physical pixels). It
197+// returns the new draw, or nil when fill emitted nothing or radius <= 0 (the
198+// plain fill is kept). The pointer is valid only until the next append.
199+func (w *sceneWalk) fillRounded(r image.Rectangle, c [3]uint8, radius float32) *draw {
200+ n := len(w.draws)
201+ w.fill(r, c)
202+ if len(w.draws) == n || radius <= 0 {
203+ return nil
204+ }
205+ dr := &w.draws[n]
206+ roundedDraw(dr, r, radius)
207+ return dr
208+}
209+
199 // dim paints a translucent black quad over rect (physical), clipped to the210 // dim paints a translucent black quad over rect (physical), clipped to the
200 // output, at alpha scaled by the walk's alpha. Damage clipping later limits211 // output, at alpha scaled by the walk's alpha. Damage clipping later limits
201 // it to the region being repainted, just like other fills.212 // it to the region being repainted, just like other fills.
@@ -293,11 +304,7 @@ func (w *sceneWalk) windows() {
293 bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y)304 bPhys := min(body.Min.X-outer.Min.X, body.Min.Y-outer.Min.Y, outer.Max.X-body.Max.X, outer.Max.Y-body.Max.Y)
294 innerRadius = max(radius-float32(max(bPhys, 0)), 0)305 innerRadius = max(radius-float32(max(bPhys, 0)), 0)
295 if col := w.ringColor(win.ID); col != "" && b > 0 {306 if col := w.ringColor(win.ID); col != "" && b > 0 {
296- n := len(w.draws)307+ if dr := w.fillRounded(outer, parseColor(col), radius); dr != nil {
297- w.fill(outer, parseColor(col))
298- if len(w.draws) > n {
299- dr := &w.draws[n]
300- roundedDraw(dr, outer, radius)
301 dr.pc.misc[1] |= flagRing308 dr.pc.misc[1] |= flagRing
302 // Opaque, the ring runs 1px under the body so no seam309 // Opaque, the ring runs 1px under the body so no seam
303 // shows; faded, that overlap would double up: the hole310 // shows; faded, that overlap would double up: the hole
M internal/core/border.go +23 -13
@@ -77,23 +77,26 @@ func pixelShare(pixels, total int) int {
7777 return (pixels*10000 + total - 1) / total
7878 }
7979
80-func (w *Workspace) setBorder(b border, pos int) {
80+// setBorder moves border b to pos and reports whether any span or share
81+// changed (stamping a lane or a column's shares counts as a change).
82+func (w *Workspace) setBorder(b border, pos int) bool {
8183 if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {
82- return
84+ return false
8385 }
8486 rs := w.columnRects()
8587 r := rs[b.col]
8688 if b.kind == borderRow {
8789 c := &w.Columns[b.col]
8890 if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {
89- return
91+ return false
9092 }
9193 rows := rowRects(r, *c, w.gap())
9294 avail := max(r.H-(len(rows)-1)*w.gap(), 0)
9395 if avail == 0 {
94- return
96+ return false
9597 }
96- if !validShares(*c) {
98+ stamped := !validShares(*c)
99+ if stamped {
97100 c.Shares = make([]int, len(rows))
98101 used := 0
99102 for i := range rows {
@@ -106,18 +109,19 @@ func (w *Workspace) setBorder(b border, pos int) {
106109 }
107110 pair := c.Shares[b.row] + c.Shares[b.row+1]
108111 if pair < 2*minShare {
109- return
112+ return stamped
110113 }
111114 target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
112115 target = min(max(target, minShare), pair-minShare)
113116 if b.row+1 < len(rows)-1 {
114117 target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
115118 }
119+ changed := stamped || c.Shares[b.row] != target
116120 c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
117- return
121+ return changed
118122 }
119123 if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {
120- return
124+ return false
121125 }
122126 first, last := w.laneBounds(w.laneOf(b.col))
123127 avail := max(w.Usable.W-2*w.gap(), 0)
@@ -125,9 +129,11 @@ func (w *Workspace) setBorder(b border, pos int) {
125129 avail = max(w.Usable.W-w.gap()*(last-first+2), 0)
126130 }
127131 if avail == 0 {
128- return
132+ return false
129133 }
130- if !w.validSpans(first, last) {
134+ stamped := !w.validSpans(first, last)
135+ if stamped {
136+ // Mutates spans, so it must not cache validity across this loop.
131137 w.spanSeq++
132138 used := 0
133139 for i := first; i <= last; i++ {
@@ -142,18 +148,22 @@ func (w *Workspace) setBorder(b border, pos int) {
142148 minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)
143149 target := pixelShare(pos-w.gap()/2-r.X, avail)
144150 if !w.policy().equalCells {
145- w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000)
146- return
151+ span := min(max(target, min(minimum, 10000)), 10000)
152+ changed := stamped || w.Columns[b.col].Span != span
153+ w.Columns[b.col].Span = span
154+ return changed
147155 }
148156 pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span
149157 if pair < 2*minimum {
150- return
158+ return stamped
151159 }
152160 target = min(max(target, minimum), pair-minimum)
153161 if b.col+1 < last {
154162 target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
155163 }
164+ changed := stamped || w.Columns[b.col].Span != target
156165 w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
166+ return changed
157167 }
158168
159169 func (w *Workspace) resetBorder(b border) {
@@ -77,23 +77,26 @@ func pixelShare(pixels, total int) int {
77 return (pixels*10000 + total - 1) / total77 return (pixels*10000 + total - 1) / total
78 }78 }
79 79
80-func (w *Workspace) setBorder(b border, pos int) {80+// setBorder moves border b to pos and reports whether any span or share
81+// changed (stamping a lane or a column's shares counts as a change).
82+func (w *Workspace) setBorder(b border, pos int) bool {
81 if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {83 if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 {
82- return84+ return false
83 }85 }
84 rs := w.columnRects()86 rs := w.columnRects()
85 r := rs[b.col]87 r := rs[b.col]
86 if b.kind == borderRow {88 if b.kind == borderRow {
87 c := &w.Columns[b.col]89 c := &w.Columns[b.col]
88 if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {90 if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 {
89- return91+ return false
90 }92 }
91 rows := rowRects(r, *c, w.gap())93 rows := rowRects(r, *c, w.gap())
92 avail := max(r.H-(len(rows)-1)*w.gap(), 0)94 avail := max(r.H-(len(rows)-1)*w.gap(), 0)
93 if avail == 0 {95 if avail == 0 {
94- return96+ return false
95 }97 }
96- if !validShares(*c) {98+ stamped := !validShares(*c)
99+ if stamped {
97 c.Shares = make([]int, len(rows))100 c.Shares = make([]int, len(rows))
98 used := 0101 used := 0
99 for i := range rows {102 for i := range rows {
@@ -106,18 +109,19 @@ func (w *Workspace) setBorder(b border, pos int) {
106 }109 }
107 pair := c.Shares[b.row] + c.Shares[b.row+1]110 pair := c.Shares[b.row] + c.Shares[b.row+1]
108 if pair < 2*minShare {111 if pair < 2*minShare {
109- return112+ return stamped
110 }113 }
111 target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)114 target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
112 target = min(max(target, minShare), pair-minShare)115 target = min(max(target, minShare), pair-minShare)
113 if b.row+1 < len(rows)-1 {116 if b.row+1 < len(rows)-1 {
114 target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)117 target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
115 }118 }
119+ changed := stamped || c.Shares[b.row] != target
116 c.Shares[b.row], c.Shares[b.row+1] = target, pair-target120 c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
117- return121+ return changed
118 }122 }
119 if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {123 if b.kind != borderColumn || b.col+1 >= len(w.Columns) || w.laneOf(b.col) != w.laneOf(b.col+1) || w.Overflow == OverflowFixed && !w.laneRow() || w.Columns[b.col].FullWidth || w.Columns[b.col+1].FullWidth {
120- return124+ return false
121 }125 }
122 first, last := w.laneBounds(w.laneOf(b.col))126 first, last := w.laneBounds(w.laneOf(b.col))
123 avail := max(w.Usable.W-2*w.gap(), 0)127 avail := max(w.Usable.W-2*w.gap(), 0)
@@ -125,9 +129,11 @@ func (w *Workspace) setBorder(b border, pos int) {
125 avail = max(w.Usable.W-w.gap()*(last-first+2), 0)129 avail = max(w.Usable.W-w.gap()*(last-first+2), 0)
126 }130 }
127 if avail == 0 {131 if avail == 0 {
128- return132+ return false
129 }133 }
130- if !w.validSpans(first, last) {134+ stamped := !w.validSpans(first, last)
135+ if stamped {
136+ // Mutates spans, so it must not cache validity across this loop.
131 w.spanSeq++137 w.spanSeq++
132 used := 0138 used := 0
133 for i := first; i <= last; i++ {139 for i := first; i <= last; i++ {
@@ -142,18 +148,22 @@ func (w *Workspace) setBorder(b border, pos int) {
142 minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)148 minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail)
143 target := pixelShare(pos-w.gap()/2-r.X, avail)149 target := pixelShare(pos-w.gap()/2-r.X, avail)
144 if !w.policy().equalCells {150 if !w.policy().equalCells {
145- w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000)151+ span := min(max(target, min(minimum, 10000)), 10000)
146- return152+ changed := stamped || w.Columns[b.col].Span != span
153+ w.Columns[b.col].Span = span
154+ return changed
147 }155 }
148 pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span156 pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span
149 if pair < 2*minimum {157 if pair < 2*minimum {
150- return158+ return stamped
151 }159 }
152 target = min(max(target, minimum), pair-minimum)160 target = min(max(target, minimum), pair-minimum)
153 if b.col+1 < last {161 if b.col+1 < last {
154 target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)162 target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
155 }163 }
164+ changed := stamped || w.Columns[b.col].Span != target
156 w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target165 w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
166+ return changed
157 }167 }
158 168
159 func (w *Workspace) resetBorder(b border) {169 func (w *Workspace) resetBorder(b border) {
M internal/core/border_resize.go +3 -1
@@ -67,7 +67,9 @@ func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
6767 if g.border.kind == borderRow {
6868 pos = int(c.cursorY) - g.screen.y
6969 }
70- g.ws.setBorder(g.border, pos)
70+ if !g.ws.setBorder(g.border, pos) {
71+ return nil
72+ }
7173 return c.publish(ctx)
7274 }
7375
@@ -67,7 +67,9 @@ func (g *borderResize) motion(ctx context.Context, _ time.Duration) error {
67 if g.border.kind == borderRow {67 if g.border.kind == borderRow {
68 pos = int(c.cursorY) - g.screen.y68 pos = int(c.cursorY) - g.screen.y
69 }69 }
70- g.ws.setBorder(g.border, pos)70+ if !g.ws.setBorder(g.border, pos) {
71+ return nil
72+ }
71 return c.publish(ctx)73 return c.publish(ctx)
72 }74 }
73 75
M internal/core/border_test.go +94 -0
@@ -3,6 +3,7 @@ package core
33 import (
44 "slices"
55 "testing"
6+ "time"
67 )
78
89 func TestBorderAt(t *testing.T) {
@@ -228,3 +229,96 @@ func TestBorderStampIgnoresMaximizedWidth(t *testing.T) {
228229 })
229230 }
230231 }
232+
233+func TestSetBorderReportsChange(t *testing.T) {
234+ for _, mode := range []Overflow{OverflowScroll, OverflowFixed} {
235+ w := spanWorkspace(mode, 2)
236+ b := border{kind: borderColumn, col: 0}
237+ x := w.columnRects()[0]
238+ pos := x.X + x.W + 5 + 40
239+ if !w.setBorder(b, pos) {
240+ t.Fatal(mode, "first move must report a change")
241+ }
242+ if w.setBorder(b, pos) {
243+ t.Fatal(mode, "same position reported a change")
244+ }
245+ w.setBorder(b, -10000)
246+ if w.setBorder(b, -20000) {
247+ t.Fatal(mode, "clamped at the minimum reported a change")
248+ }
249+ }
250+ w := spanWorkspace(OverflowFixed, 2)
251+ w.Columns[0].Windows = []WindowID{1, 3}
252+ b := border{kind: borderRow, col: 0}
253+ r := w.columnRects()[0]
254+ pos := rowRects(r, w.Columns[0], w.gap())[0].Y + 200
255+ if !w.setBorder(b, pos) || w.setBorder(b, pos) {
256+ t.Fatal("row border change reporting")
257+ }
258+ if w.setBorder(border{kind: borderColumn, col: 9}, 0) {
259+ t.Fatal("invalid border reported a change")
260+ }
261+}
262+
263+func TestBorderMotionPublishesOnlyChanges(t *testing.T) {
264+ c, w := borderCore(t, false)
265+ grabButton(t, c, btnRight, true, time.Second)
266+ grabCommands(c)
267+ grabMove(t, c, c.cursorX+150, c.cursorY)
268+ if len(grabCommands(c)) == 0 {
269+ t.Fatal("a resizing motion must publish")
270+ }
271+ spans := []int{w.Columns[0].Span, w.Columns[1].Span}
272+ grabMove(t, c, c.cursorX, c.cursorY)
273+ if cmds := grabCommands(c); len(cmds) != 0 {
274+ t.Fatalf("unchanged motion published %+v", cmds)
275+ }
276+ if spans[0] != w.Columns[0].Span || spans[1] != w.Columns[1].Span {
277+ t.Fatal("spans changed")
278+ }
279+ grabMove(t, c, -10000, c.cursorY)
280+ grabCommands(c)
281+ grabMove(t, c, -20000, c.cursorY)
282+ if cmds := grabCommands(c); len(cmds) != 0 {
283+ t.Fatalf("motion clamped at the minimum published %+v", cmds)
284+ }
285+}
286+
287+func TestSetBorderUndersizedPairReportsStampOnce(t *testing.T) {
288+ t.Run("row", func(t *testing.T) {
289+ w := spanWorkspace(OverflowFixed, 1)
290+ w.Columns[0].Windows = []WindowID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
291+ // Ten rows is the most that fit; pixel rounding leaves the last pair
292+ // just under twice the minimum share.
293+ b := border{kind: borderRow, col: 0, row: 8}
294+ rows := rowRects(w.columnRects()[0], w.Columns[0], w.gap())
295+ pos := rows[8].Y + 1
296+ if !w.setBorder(b, pos) {
297+ t.Fatal("stamping an undersized row pair must report a change")
298+ }
299+ if c := w.Columns[0]; c.Shares[8]+c.Shares[9] >= 2*minShare {
300+ t.Fatal("row pair not undersized", c.Shares)
301+ }
302+ if w.setBorder(b, pos) {
303+ t.Fatal("repeat on an undersized row pair reported a change")
304+ }
305+ })
306+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
307+ t.Run(string(mode), func(t *testing.T) {
308+ w := spanWorkspace(mode, 3)
309+ w.border = 300
310+ b := border{kind: borderColumn, col: 0}
311+ pos := w.columnRects()[0].X + 100
312+ if !w.setBorder(b, pos) {
313+ t.Fatal("stamping an undersized column pair must report a change")
314+ }
315+ avail := w.Usable.W - w.gap()*4
316+ if pair := w.Columns[0].Span + w.Columns[1].Span; pair >= 2*pixelShare(4*w.border, avail) {
317+ t.Fatal("column pair not undersized", pair)
318+ }
319+ if w.setBorder(b, pos) {
320+ t.Fatal("repeat on an undersized column pair reported a change")
321+ }
322+ })
323+ }
324+}
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "slices"4 "slices"
5 "testing"5 "testing"
6+ "time"
6 )7 )
7 8
8 func TestBorderAt(t *testing.T) {9 func TestBorderAt(t *testing.T) {
@@ -228,3 +229,96 @@ func TestBorderStampIgnoresMaximizedWidth(t *testing.T) {
228 })229 })
229 }230 }
230 }231 }
232+
233+func TestSetBorderReportsChange(t *testing.T) {
234+ for _, mode := range []Overflow{OverflowScroll, OverflowFixed} {
235+ w := spanWorkspace(mode, 2)
236+ b := border{kind: borderColumn, col: 0}
237+ x := w.columnRects()[0]
238+ pos := x.X + x.W + 5 + 40
239+ if !w.setBorder(b, pos) {
240+ t.Fatal(mode, "first move must report a change")
241+ }
242+ if w.setBorder(b, pos) {
243+ t.Fatal(mode, "same position reported a change")
244+ }
245+ w.setBorder(b, -10000)
246+ if w.setBorder(b, -20000) {
247+ t.Fatal(mode, "clamped at the minimum reported a change")
248+ }
249+ }
250+ w := spanWorkspace(OverflowFixed, 2)
251+ w.Columns[0].Windows = []WindowID{1, 3}
252+ b := border{kind: borderRow, col: 0}
253+ r := w.columnRects()[0]
254+ pos := rowRects(r, w.Columns[0], w.gap())[0].Y + 200
255+ if !w.setBorder(b, pos) || w.setBorder(b, pos) {
256+ t.Fatal("row border change reporting")
257+ }
258+ if w.setBorder(border{kind: borderColumn, col: 9}, 0) {
259+ t.Fatal("invalid border reported a change")
260+ }
261+}
262+
263+func TestBorderMotionPublishesOnlyChanges(t *testing.T) {
264+ c, w := borderCore(t, false)
265+ grabButton(t, c, btnRight, true, time.Second)
266+ grabCommands(c)
267+ grabMove(t, c, c.cursorX+150, c.cursorY)
268+ if len(grabCommands(c)) == 0 {
269+ t.Fatal("a resizing motion must publish")
270+ }
271+ spans := []int{w.Columns[0].Span, w.Columns[1].Span}
272+ grabMove(t, c, c.cursorX, c.cursorY)
273+ if cmds := grabCommands(c); len(cmds) != 0 {
274+ t.Fatalf("unchanged motion published %+v", cmds)
275+ }
276+ if spans[0] != w.Columns[0].Span || spans[1] != w.Columns[1].Span {
277+ t.Fatal("spans changed")
278+ }
279+ grabMove(t, c, -10000, c.cursorY)
280+ grabCommands(c)
281+ grabMove(t, c, -20000, c.cursorY)
282+ if cmds := grabCommands(c); len(cmds) != 0 {
283+ t.Fatalf("motion clamped at the minimum published %+v", cmds)
284+ }
285+}
286+
287+func TestSetBorderUndersizedPairReportsStampOnce(t *testing.T) {
288+ t.Run("row", func(t *testing.T) {
289+ w := spanWorkspace(OverflowFixed, 1)
290+ w.Columns[0].Windows = []WindowID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
291+ // Ten rows is the most that fit; pixel rounding leaves the last pair
292+ // just under twice the minimum share.
293+ b := border{kind: borderRow, col: 0, row: 8}
294+ rows := rowRects(w.columnRects()[0], w.Columns[0], w.gap())
295+ pos := rows[8].Y + 1
296+ if !w.setBorder(b, pos) {
297+ t.Fatal("stamping an undersized row pair must report a change")
298+ }
299+ if c := w.Columns[0]; c.Shares[8]+c.Shares[9] >= 2*minShare {
300+ t.Fatal("row pair not undersized", c.Shares)
301+ }
302+ if w.setBorder(b, pos) {
303+ t.Fatal("repeat on an undersized row pair reported a change")
304+ }
305+ })
306+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade} {
307+ t.Run(string(mode), func(t *testing.T) {
308+ w := spanWorkspace(mode, 3)
309+ w.border = 300
310+ b := border{kind: borderColumn, col: 0}
311+ pos := w.columnRects()[0].X + 100
312+ if !w.setBorder(b, pos) {
313+ t.Fatal("stamping an undersized column pair must report a change")
314+ }
315+ avail := w.Usable.W - w.gap()*4
316+ if pair := w.Columns[0].Span + w.Columns[1].Span; pair >= 2*pixelShare(4*w.border, avail) {
317+ t.Fatal("column pair not undersized", pair)
318+ }
319+ if w.setBorder(b, pos) {
320+ t.Fatal("repeat on an undersized column pair reported a change")
321+ }
322+ })
323+ }
324+}
M internal/core/cascade.go +8 -5
@@ -80,8 +80,9 @@ func (w *Workspace) cellX(i int) int {
8080 first, last := w.laneBounds(w.laneOf(i))
8181 S, u, sum := w.laneStats(first, last)
8282 x := w.gap()
83+ valid := w.validSpans(first, last)
8384 for j := first; j < i; j++ {
84- x += w.laneWidth(j, first, last, S, u, sum) + w.gap()
85+ x += w.laneWidth(j, first, last, S, u, sum, valid) + w.gap()
8586 }
8687 return x
8788 }
@@ -98,7 +99,7 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
9899 func (w *Workspace) cellWidth(i int) int {
99100 first, last := w.laneBounds(w.laneOf(i))
100101 S, u, sum := w.laneStats(first, last)
101- return w.laneWidth(i, first, last, S, u, sum)
102+ return w.laneWidth(i, first, last, S, u, sum, w.validSpans(first, last))
102103 }
103104
104105 // laneStats measures the lane first..last: S is the sum of the cells of
@@ -142,8 +143,9 @@ func (w *Workspace) laneWeight(i, S, u int) int {
142143 // laneWidth is the width of column i of the lane first..last with the
143144 // stats of laneStats: the usable width after gaps, split by weight. The
144145 // last column receives the pixel remainder, just as for stamped spans.
145-func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146- if w.validSpans(first, last) {
146+// The caller passes the lane's validSpans so a loop over the lane checks it once.
147+func (w *Workspace) laneWidth(i, first, last, S, u, sum int, spansValid bool) int {
148+ if spansValid {
147149 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148150 if i == last {
149151 used := 0
@@ -205,8 +207,9 @@ func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int)
205207 w.walkBands(func(lane, first, last int) bool {
206208 S, u, sum := w.laneStats(first, last)
207209 x := g
210+ valid := w.validSpans(first, last)
208211 for i := first; i <= last; i++ {
209- cw := w.laneWidth(i, first, last, S, u, sum)
212+ cw := w.laneWidth(i, first, last, S, u, sum, valid)
210213 r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
211214 if w.Columns[i].FullWidth && !ignoreFullWidth {
212215 r.X = w.Usable.X + g
@@ -80,8 +80,9 @@ func (w *Workspace) cellX(i int) int {
80 first, last := w.laneBounds(w.laneOf(i))80 first, last := w.laneBounds(w.laneOf(i))
81 S, u, sum := w.laneStats(first, last)81 S, u, sum := w.laneStats(first, last)
82 x := w.gap()82 x := w.gap()
83+ valid := w.validSpans(first, last)
83 for j := first; j < i; j++ {84 for j := first; j < i; j++ {
84- x += w.laneWidth(j, first, last, S, u, sum) + w.gap()85+ x += w.laneWidth(j, first, last, S, u, sum, valid) + w.gap()
85 }86 }
86 return x87 return x
87 }88 }
@@ -98,7 +99,7 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
98 func (w *Workspace) cellWidth(i int) int {99 func (w *Workspace) cellWidth(i int) int {
99 first, last := w.laneBounds(w.laneOf(i))100 first, last := w.laneBounds(w.laneOf(i))
100 S, u, sum := w.laneStats(first, last)101 S, u, sum := w.laneStats(first, last)
101- return w.laneWidth(i, first, last, S, u, sum)102+ return w.laneWidth(i, first, last, S, u, sum, w.validSpans(first, last))
102 }103 }
103 104
104 // laneStats measures the lane first..last: S is the sum of the cells of105 // laneStats measures the lane first..last: S is the sum of the cells of
@@ -142,8 +143,9 @@ func (w *Workspace) laneWeight(i, S, u int) int {
142 // laneWidth is the width of column i of the lane first..last with the143 // laneWidth is the width of column i of the lane first..last with the
143 // stats of laneStats: the usable width after gaps, split by weight. The144 // stats of laneStats: the usable width after gaps, split by weight. The
144 // last column receives the pixel remainder, just as for stamped spans.145 // last column receives the pixel remainder, just as for stamped spans.
145-func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {146+// The caller passes the lane's validSpans so a loop over the lane checks it once.
146- if w.validSpans(first, last) {147+func (w *Workspace) laneWidth(i, first, last, S, u, sum int, spansValid bool) int {
148+ if spansValid {
147 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)149 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148 if i == last {150 if i == last {
149 used := 0151 used := 0
@@ -205,8 +207,9 @@ func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int)
205 w.walkBands(func(lane, first, last int) bool {207 w.walkBands(func(lane, first, last int) bool {
206 S, u, sum := w.laneStats(first, last)208 S, u, sum := w.laneStats(first, last)
207 x := g209 x := g
210+ valid := w.validSpans(first, last)
208 for i := first; i <= last; i++ {211 for i := first; i <= last; i++ {
209- cw := w.laneWidth(i, first, last, S, u, sum)212+ cw := w.laneWidth(i, first, last, S, u, sum, valid)
210 r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}213 r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
211 if w.Columns[i].FullWidth && !ignoreFullWidth {214 if w.Columns[i].FullWidth && !ignoreFullWidth {
212 r.X = w.Usable.X + g215 r.X = w.Usable.X + g
M internal/core/drag.go +6 -5
@@ -559,20 +559,21 @@ func (c *Core) edgeScroll(t time.Duration) bool {
559559 func (w *Workspace) scrollBy(dir int) bool {
560560 g := w.gap()
561561 n := len(w.Columns)
562- maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)
562+ xs, ws := w.columnEdges()
563+ maxView := max(xs[n-1]+ws[n-1]+g-(w.Usable.X+w.Usable.W), 0)
563564 view := w.View
564565 if dir < 0 {
565566 // The first column not fully on screen at the left.
566567 for i := n - 1; i >= 0; i-- {
567- if w.columnX(i)-view < w.Usable.X+g {
568- view = w.columnX(i) - w.Usable.X - g
568+ if xs[i]-view < w.Usable.X+g {
569+ view = xs[i] - w.Usable.X - g
569570 break
570571 }
571572 }
572573 } else {
573574 for i := range n {
574- if w.columnX(i)+w.columnWidth(i)-view > w.Usable.X+w.Usable.W-g {
575- view = w.columnX(i) + w.columnWidth(i) + g - w.Usable.X - w.Usable.W
575+ if xs[i]+ws[i]-view > w.Usable.X+w.Usable.W-g {
576+ view = xs[i] + ws[i] + g - w.Usable.X - w.Usable.W
576577 break
577578 }
578579 }
@@ -559,20 +559,21 @@ func (c *Core) edgeScroll(t time.Duration) bool {
559 func (w *Workspace) scrollBy(dir int) bool {559 func (w *Workspace) scrollBy(dir int) bool {
560 g := w.gap()560 g := w.gap()
561 n := len(w.Columns)561 n := len(w.Columns)
562- maxView := max(w.columnX(n-1)+w.columnWidth(n-1)+g-(w.Usable.X+w.Usable.W), 0)562+ xs, ws := w.columnEdges()
563+ maxView := max(xs[n-1]+ws[n-1]+g-(w.Usable.X+w.Usable.W), 0)
563 view := w.View564 view := w.View
564 if dir < 0 {565 if dir < 0 {
565 // The first column not fully on screen at the left.566 // The first column not fully on screen at the left.
566 for i := n - 1; i >= 0; i-- {567 for i := n - 1; i >= 0; i-- {
567- if w.columnX(i)-view < w.Usable.X+g {568+ if xs[i]-view < w.Usable.X+g {
568- view = w.columnX(i) - w.Usable.X - g569+ view = xs[i] - w.Usable.X - g
569 break570 break
570 }571 }
571 }572 }
572 } else {573 } else {
573 for i := range n {574 for i := range n {
574- if w.columnX(i)+w.columnWidth(i)-view > w.Usable.X+w.Usable.W-g {575+ if xs[i]+ws[i]-view > w.Usable.X+w.Usable.W-g {
575- view = w.columnX(i) + w.columnWidth(i) + g - w.Usable.X - w.Usable.W576+ view = xs[i] + ws[i] + g - w.Usable.X - w.Usable.W
576 break577 break
577 }578 }
578 }579 }
M internal/core/flash.go +4 -1
@@ -196,7 +196,10 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
196196 }
197197 sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Radius: f.radius, Color: color, Alpha: f.value}
198198 if f.published == nil || *f.published != sample {
199- f.published = &sample
199+ // Allocate only for a changed sample; sample itself stays on the stack.
200+ published := new(ports.FocusFlash)
201+ *published = sample
202+ f.published = published
200203 }
201204 return f.published
202205 }
@@ -196,7 +196,10 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash {
196 }196 }
197 sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Radius: f.radius, Color: color, Alpha: f.value}197 sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Radius: f.radius, Color: color, Alpha: f.value}
198 if f.published == nil || *f.published != sample {198 if f.published == nil || *f.published != sample {
199- f.published = &sample199+ // Allocate only for a changed sample; sample itself stays on the stack.
200+ published := new(ports.FocusFlash)
201+ *published = sample
202+ f.published = published
200 }203 }
201 return f.published204 return f.published
202 }205 }
M internal/core/layout.go +41 -5
@@ -1649,7 +1649,24 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
16491649 if w.policy().wraps || w.laneRow() {
16501650 return w.cellWidth(i)
16511651 }
1652- return w.scrollWidth(i)
1652+ return w.scrollWidth(i, w.scrollSpansValid())
1653+}
1654+
1655+// scrollSpansValid is the lane validity scrollWidth needs. It costs O(n):
1656+// loops over columns compute it once and pass it down.
1657+func (w *Workspace) scrollSpansValid() bool {
1658+ return !w.policy().equalCells && w.validSpans(0, len(w.Columns)-1)
1659+}
1660+
1661+// columnWidthWith is columnWidthFor with the scroll lane's validity given.
1662+func (w *Workspace) columnWidthWith(i int, ignoreFullWidth, spansValid bool) int {
1663+ if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1664+ return width
1665+ }
1666+ if w.policy().wraps || w.laneRow() {
1667+ return w.cellWidth(i)
1668+ }
1669+ return w.scrollWidth(i, spansValid)
16531670 }
16541671
16551672 // specialWidth is the width of a maximized or fullscreen column.
@@ -1673,13 +1690,13 @@ func (w *Workspace) laneRow() bool {
16731690 // scrollWidth is the width of column i when it is sized on its own: auto
16741691 // columns share the usable width as the visible ones would, the others take
16751692 // their cells (at most MaxColumns) and the gaps between them.
1676-func (w *Workspace) scrollWidth(i int) int {
1693+func (w *Workspace) scrollWidth(i int, spansValid bool) int {
16771694 k := max(w.MaxColumns, 1)
16781695 if w.policy().equalCells {
16791696 // Fixed overflow past MaxColumns: the spiral's slots.
16801697 return w.equalShare(k)
16811698 }
1682- if w.validSpans(0, len(w.Columns)-1) {
1699+ if spansValid {
16831700 return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
16841701 }
16851702 c := w.Columns[i].Cells
@@ -1689,10 +1706,28 @@ func (w *Workspace) scrollWidth(i int) int {
16891706 c = min(c, k)
16901707 return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
16911708 }
1709+
1710+// columnEdges returns the x and width of every column in one pass, the
1711+// prefix sums columnX(i) and columnWidth(i) would each recompute in O(i).
1712+// Both slices share one backing array.
1713+func (w *Workspace) columnEdges() (xs, widths []int) {
1714+ n := len(w.Columns)
1715+ buf := make([]int, 2*n)
1716+ xs, widths = buf[:n:n], buf[n:]
1717+ valid := w.scrollSpansValid()
1718+ x := w.Usable.X + w.gap()
1719+ for i := range w.Columns {
1720+ xs[i], widths[i] = x, w.columnWidthWith(i, false, valid)
1721+ x += widths[i] + w.gap()
1722+ }
1723+ return xs, widths
1724+}
1725+
16921726 func (w *Workspace) columnX(i int) int {
1727+ valid := w.scrollSpansValid()
16931728 x := w.Usable.X + w.gap()
16941729 for j := 0; j < i; j++ {
1695- x += w.columnWidth(j) + w.gap()
1730+ x += w.columnWidthWith(j, false, valid) + w.gap()
16961731 }
16971732 return x
16981733 }
@@ -1756,8 +1791,9 @@ func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) [
17561791 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
17571792 view := w.View + shift
17581793 x := w.Usable.X + g
1794+ valid := w.scrollSpansValid()
17591795 for i := range w.Columns {
1760- width := w.columnWidthFor(i, ignoreFullWidth)
1796+ width := w.columnWidthWith(i, ignoreFullWidth, valid)
17611797 rects[i] = Rect{X: x - view, Y: y, W: width, H: h}
17621798 x += width + g
17631799 }
@@ -1649,7 +1649,24 @@ func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1649 if w.policy().wraps || w.laneRow() {1649 if w.policy().wraps || w.laneRow() {
1650 return w.cellWidth(i)1650 return w.cellWidth(i)
1651 }1651 }
1652- return w.scrollWidth(i)1652+ return w.scrollWidth(i, w.scrollSpansValid())
1653+}
1654+
1655+// scrollSpansValid is the lane validity scrollWidth needs. It costs O(n):
1656+// loops over columns compute it once and pass it down.
1657+func (w *Workspace) scrollSpansValid() bool {
1658+ return !w.policy().equalCells && w.validSpans(0, len(w.Columns)-1)
1659+}
1660+
1661+// columnWidthWith is columnWidthFor with the scroll lane's validity given.
1662+func (w *Workspace) columnWidthWith(i int, ignoreFullWidth, spansValid bool) int {
1663+ if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1664+ return width
1665+ }
1666+ if w.policy().wraps || w.laneRow() {
1667+ return w.cellWidth(i)
1668+ }
1669+ return w.scrollWidth(i, spansValid)
1653 }1670 }
1654 1671
1655 // specialWidth is the width of a maximized or fullscreen column.1672 // specialWidth is the width of a maximized or fullscreen column.
@@ -1673,13 +1690,13 @@ func (w *Workspace) laneRow() bool {
1673 // scrollWidth is the width of column i when it is sized on its own: auto1690 // scrollWidth is the width of column i when it is sized on its own: auto
1674 // columns share the usable width as the visible ones would, the others take1691 // columns share the usable width as the visible ones would, the others take
1675 // their cells (at most MaxColumns) and the gaps between them.1692 // their cells (at most MaxColumns) and the gaps between them.
1676-func (w *Workspace) scrollWidth(i int) int {1693+func (w *Workspace) scrollWidth(i int, spansValid bool) int {
1677 k := max(w.MaxColumns, 1)1694 k := max(w.MaxColumns, 1)
1678 if w.policy().equalCells {1695 if w.policy().equalCells {
1679 // Fixed overflow past MaxColumns: the spiral's slots.1696 // Fixed overflow past MaxColumns: the spiral's slots.
1680 return w.equalShare(k)1697 return w.equalShare(k)
1681 }1698 }
1682- if w.validSpans(0, len(w.Columns)-1) {1699+ if spansValid {
1683 return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 100001700 return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1684 }1701 }
1685 c := w.Columns[i].Cells1702 c := w.Columns[i].Cells
@@ -1689,10 +1706,28 @@ func (w *Workspace) scrollWidth(i int) int {
1689 c = min(c, k)1706 c = min(c, k)
1690 return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)1707 return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
1691 }1708 }
1709+
1710+// columnEdges returns the x and width of every column in one pass, the
1711+// prefix sums columnX(i) and columnWidth(i) would each recompute in O(i).
1712+// Both slices share one backing array.
1713+func (w *Workspace) columnEdges() (xs, widths []int) {
1714+ n := len(w.Columns)
1715+ buf := make([]int, 2*n)
1716+ xs, widths = buf[:n:n], buf[n:]
1717+ valid := w.scrollSpansValid()
1718+ x := w.Usable.X + w.gap()
1719+ for i := range w.Columns {
1720+ xs[i], widths[i] = x, w.columnWidthWith(i, false, valid)
1721+ x += widths[i] + w.gap()
1722+ }
1723+ return xs, widths
1724+}
1725+
1692 func (w *Workspace) columnX(i int) int {1726 func (w *Workspace) columnX(i int) int {
1727+ valid := w.scrollSpansValid()
1693 x := w.Usable.X + w.gap()1728 x := w.Usable.X + w.gap()
1694 for j := 0; j < i; j++ {1729 for j := 0; j < i; j++ {
1695- x += w.columnWidth(j) + w.gap()1730+ x += w.columnWidthWith(j, false, valid) + w.gap()
1696 }1731 }
1697 return x1732 return x
1698 }1733 }
@@ -1756,8 +1791,9 @@ func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) [
1756 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]1791 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
1757 view := w.View + shift1792 view := w.View + shift
1758 x := w.Usable.X + g1793 x := w.Usable.X + g
1794+ valid := w.scrollSpansValid()
1759 for i := range w.Columns {1795 for i := range w.Columns {
1760- width := w.columnWidthFor(i, ignoreFullWidth)1796+ width := w.columnWidthWith(i, ignoreFullWidth, valid)
1761 rects[i] = Rect{X: x - view, Y: y, W: width, H: h}1797 rects[i] = Rect{X: x - view, Y: y, W: width, H: h}
1762 x += width + g1798 x += width + g
1763 }1799 }
A internal/core/layout_bench_test.go +114 -0
@@ -0,0 +1,114 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func benchLane(n int, stamped bool) *Workspace {
12+ w := spanWorkspace(OverflowScroll, n)
13+ if stamped {
14+ stampSpans(w, 0, n-1)
15+ }
16+ w.Focus = n / 2
17+ return w
18+}
19+
20+func benchLanes(b *testing.B, f func(b *testing.B, w *Workspace)) {
21+ for _, n := range []int{16, 64} {
22+ for _, stamped := range []bool{false, true} {
23+ b.Run(fmt.Sprintf("cols=%d/stamped=%t", n, stamped), func(b *testing.B) {
24+ w := benchLane(n, stamped)
25+ b.ReportAllocs()
26+ f(b, w)
27+ })
28+ }
29+ }
30+}
31+
32+func BenchmarkSnapPoints(b *testing.B) {
33+ benchLanes(b, func(b *testing.B, w *Workspace) {
34+ for b.Loop() {
35+ _ = w.snapPoints()
36+ }
37+ })
38+}
39+
40+func BenchmarkLayout(b *testing.B) {
41+ benchLanes(b, func(b *testing.B, w *Workspace) {
42+ for b.Loop() {
43+ _ = w.Layout()
44+ }
45+ })
46+}
47+
48+func BenchmarkColumnRects(b *testing.B) {
49+ benchLanes(b, func(b *testing.B, w *Workspace) {
50+ var buf []Rect
51+ for b.Loop() {
52+ buf = w.columnRectsInto(buf, false)
53+ }
54+ })
55+}
56+
57+func BenchmarkScroll(b *testing.B) {
58+ benchLanes(b, func(b *testing.B, w *Workspace) {
59+ for b.Loop() {
60+ w.scroll()
61+ }
62+ })
63+}
64+
65+// BenchmarkBorderMotion is one pointer motion of a border resize with a
66+// publish; "same" repeats a position, so nothing changes after the first.
67+func BenchmarkBorderMotion(b *testing.B) {
68+ for _, mode := range []string{"moving", "same"} {
69+ b.Run(mode, func(b *testing.B) {
70+ var cfg ports.Config
71+ cfg.Keyboard.CmdKey = "super"
72+ cfg.Layout.MaxColumns = 3
73+ scenes, cmds := make(chan []ports.Scene, 1), make(chan ports.ClientCommand, 1024)
74+ c, err := New(cfg, Channels{Scenes: scenes, Commands: cmds}, Options{})
75+ if err != nil {
76+ b.Fatal(err)
77+ }
78+ c.addScreen(ports.OutputInfo{Name: "A", Width: 1920, Height: 1080})
79+ sc := c.cur()
80+ w := sc.mon.Current()
81+ w.Overflow = OverflowScroll
82+ for id := WindowID(1); id <= 12; id++ {
83+ w.AddWindow(id)
84+ }
85+ sc.arrange()
86+ ctx := context.Background()
87+ if err := c.publish(ctx); err != nil {
88+ b.Fatal(err)
89+ }
90+ rs := w.columnRects()
91+ g := newBorderResize(c, sc, w, border{kind: borderColumn, col: 0}, btnRight)
92+ x := float64(rs[0].X + rs[0].W)
93+ c.cursorY = 100
94+ n := 0
95+ b.ReportAllocs()
96+ for b.Loop() {
97+ c.cursorX = x
98+ if mode == "moving" {
99+ c.cursorX += float64(n%2*8) + 8
100+ n++
101+ }
102+ for len(scenes) > 0 {
103+ <-scenes
104+ }
105+ for len(cmds) > 0 {
106+ <-cmds
107+ }
108+ if err := g.motion(ctx, 0); err != nil {
109+ b.Fatal(err)
110+ }
111+ }
112+ })
113+ }
114+}
@@ -0,0 +1,114 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+func benchLane(n int, stamped bool) *Workspace {
12+ w := spanWorkspace(OverflowScroll, n)
13+ if stamped {
14+ stampSpans(w, 0, n-1)
15+ }
16+ w.Focus = n / 2
17+ return w
18+}
19+
20+func benchLanes(b *testing.B, f func(b *testing.B, w *Workspace)) {
21+ for _, n := range []int{16, 64} {
22+ for _, stamped := range []bool{false, true} {
23+ b.Run(fmt.Sprintf("cols=%d/stamped=%t", n, stamped), func(b *testing.B) {
24+ w := benchLane(n, stamped)
25+ b.ReportAllocs()
26+ f(b, w)
27+ })
28+ }
29+ }
30+}
31+
32+func BenchmarkSnapPoints(b *testing.B) {
33+ benchLanes(b, func(b *testing.B, w *Workspace) {
34+ for b.Loop() {
35+ _ = w.snapPoints()
36+ }
37+ })
38+}
39+
40+func BenchmarkLayout(b *testing.B) {
41+ benchLanes(b, func(b *testing.B, w *Workspace) {
42+ for b.Loop() {
43+ _ = w.Layout()
44+ }
45+ })
46+}
47+
48+func BenchmarkColumnRects(b *testing.B) {
49+ benchLanes(b, func(b *testing.B, w *Workspace) {
50+ var buf []Rect
51+ for b.Loop() {
52+ buf = w.columnRectsInto(buf, false)
53+ }
54+ })
55+}
56+
57+func BenchmarkScroll(b *testing.B) {
58+ benchLanes(b, func(b *testing.B, w *Workspace) {
59+ for b.Loop() {
60+ w.scroll()
61+ }
62+ })
63+}
64+
65+// BenchmarkBorderMotion is one pointer motion of a border resize with a
66+// publish; "same" repeats a position, so nothing changes after the first.
67+func BenchmarkBorderMotion(b *testing.B) {
68+ for _, mode := range []string{"moving", "same"} {
69+ b.Run(mode, func(b *testing.B) {
70+ var cfg ports.Config
71+ cfg.Keyboard.CmdKey = "super"
72+ cfg.Layout.MaxColumns = 3
73+ scenes, cmds := make(chan []ports.Scene, 1), make(chan ports.ClientCommand, 1024)
74+ c, err := New(cfg, Channels{Scenes: scenes, Commands: cmds}, Options{})
75+ if err != nil {
76+ b.Fatal(err)
77+ }
78+ c.addScreen(ports.OutputInfo{Name: "A", Width: 1920, Height: 1080})
79+ sc := c.cur()
80+ w := sc.mon.Current()
81+ w.Overflow = OverflowScroll
82+ for id := WindowID(1); id <= 12; id++ {
83+ w.AddWindow(id)
84+ }
85+ sc.arrange()
86+ ctx := context.Background()
87+ if err := c.publish(ctx); err != nil {
88+ b.Fatal(err)
89+ }
90+ rs := w.columnRects()
91+ g := newBorderResize(c, sc, w, border{kind: borderColumn, col: 0}, btnRight)
92+ x := float64(rs[0].X + rs[0].W)
93+ c.cursorY = 100
94+ n := 0
95+ b.ReportAllocs()
96+ for b.Loop() {
97+ c.cursorX = x
98+ if mode == "moving" {
99+ c.cursorX += float64(n%2*8) + 8
100+ n++
101+ }
102+ for len(scenes) > 0 {
103+ <-scenes
104+ }
105+ for len(cmds) > 0 {
106+ <-cmds
107+ }
108+ if err := g.motion(ctx, 0); err != nil {
109+ b.Fatal(err)
110+ }
111+ }
112+ })
113+ }
114+}
M internal/core/layout_policy.go +11 -0
@@ -86,6 +86,17 @@ func (w *Workspace) validSpans(first, last int) bool {
8686 // validity before clearing any stamps, so shared identities cannot become
8787 // valid merely because an earlier invalid lane was cleared.
8888 func (w *Workspace) dropStaleSpans() {
89+ stamped := false
90+ for _, c := range w.Columns {
91+ if c.Span != 0 || c.spanStamp != 0 {
92+ stamped = true
93+ break
94+ }
95+ }
96+ if !stamped {
97+ return
98+ }
99+ // A nil stale slice does not allocate, so valid lanes cost nothing here.
89100 var stale []int
90101 for first := 0; first < len(w.Columns); {
91102 _, last := w.laneBounds(w.laneOf(first))
@@ -86,6 +86,17 @@ func (w *Workspace) validSpans(first, last int) bool {
86 // validity before clearing any stamps, so shared identities cannot become86 // validity before clearing any stamps, so shared identities cannot become
87 // valid merely because an earlier invalid lane was cleared.87 // valid merely because an earlier invalid lane was cleared.
88 func (w *Workspace) dropStaleSpans() {88 func (w *Workspace) dropStaleSpans() {
89+ stamped := false
90+ for _, c := range w.Columns {
91+ if c.Span != 0 || c.spanStamp != 0 {
92+ stamped = true
93+ break
94+ }
95+ }
96+ if !stamped {
97+ return
98+ }
99+ // A nil stale slice does not allocate, so valid lanes cost nothing here.
89 var stale []int100 var stale []int
90 for first := 0; first < len(w.Columns); {101 for first := 0; first < len(w.Columns); {
91 _, last := w.laneBounds(w.laneOf(first))102 _, last := w.laneBounds(w.laneOf(first))
M internal/core/layout_policy_test.go +134 -0
@@ -27,3 +27,137 @@ func TestWorkspaceStepFollowsWorkspaceAxis(t *testing.T) {
2727 })
2828 }
2929 }
30+
31+// oldValidSpans is the reference implementation validSpans must match.
32+func oldValidSpans(w *Workspace, first, last int) bool {
33+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
34+ return false
35+ }
36+ stamp := w.Columns[first].spanStamp
37+ if stamp == 0 {
38+ return false
39+ }
40+ total, count := 0, 0
41+ for i := first; i <= last; i++ {
42+ if w.Columns[i].Span <= 0 || w.Columns[i].spanStamp != stamp {
43+ return false
44+ }
45+ total += w.Columns[i].Span
46+ }
47+ for _, c := range w.Columns {
48+ if c.spanStamp == stamp {
49+ count++
50+ }
51+ }
52+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
53+}
54+
55+func TestScrollWidthsMatchPerColumnValidation(t *testing.T) {
56+ for _, n := range []int{1, 2, 5, 9} {
57+ for _, state := range []string{"unstamped", "stamped", "stale", "foreign", "zero"} {
58+ w := spanWorkspace(OverflowScroll, n)
59+ switch state {
60+ case "stamped":
61+ stampSpans(w, 0, n-1)
62+ case "stale":
63+ stampSpans(w, 0, n-1)
64+ w.Columns[n-1].spanStamp = 99
65+ case "foreign":
66+ stampSpans(w, 0, n-1)
67+ w.Columns = append(w.Columns, Column{Windows: []WindowID{100}, Span: 5, spanStamp: w.Columns[0].spanStamp})
68+ case "zero":
69+ stampSpans(w, 0, n-1)
70+ w.Columns[0].Span = 0
71+ }
72+ last := len(w.Columns) - 1
73+ if got, want := w.validSpans(0, last), oldValidSpans(w, 0, last); got != want {
74+ t.Fatal(n, state, got, want)
75+ }
76+ valid := w.scrollSpansValid()
77+ xs, widths := w.columnEdges()
78+ rs := w.columnRects()
79+ for i := range w.Columns {
80+ if widths[i] != w.scrollWidth(i, oldValidSpans(w, 0, last)) || valid != oldValidSpans(w, 0, last) {
81+ t.Fatal(n, state, i, "width")
82+ }
83+ if xs[i] != w.columnX(i) || widths[i] != w.columnWidth(i) || rs[i].W != widths[i] {
84+ t.Fatal(n, state, i, "edges")
85+ }
86+ }
87+ }
88+ }
89+}
90+
91+func TestDropStaleSpansKeepsValidLanes(t *testing.T) {
92+ w := spanWorkspace(OverflowScroll, 4)
93+ // An unstamped workspace returns before any lane is measured.
94+ if n := testing.AllocsPerRun(10, w.dropStaleSpans); n != 0 {
95+ t.Fatalf("unstamped workspace allocates %v", n)
96+ }
97+ if w.Columns[0].Span != 0 || w.Columns[0].spanStamp != 0 {
98+ t.Fatal("unstamped workspace changed")
99+ }
100+ stampSpans(w, 0, 3)
101+ spans := []int{w.Columns[0].Span, w.Columns[3].Span}
102+ if n := testing.AllocsPerRun(10, w.dropStaleSpans); n != 0 {
103+ t.Fatalf("valid lane allocates %v", n)
104+ }
105+ if w.Columns[0].Span != spans[0] || w.Columns[3].Span != spans[1] {
106+ t.Fatal("valid lane dropped")
107+ }
108+ w.Columns[2].spanStamp = 77
109+ w.dropStaleSpans()
110+ for i, c := range w.Columns {
111+ if c.Span != 0 || c.spanStamp != 0 {
112+ t.Fatal("stale lane kept", i)
113+ }
114+ }
115+}
116+
117+// TestColumnEdgesMatchColumnRects checks columnEdges against columnRects, an
118+// independent path that builds the same row with the view shift applied.
119+func TestColumnEdgesMatchColumnRects(t *testing.T) {
120+ for _, tc := range []struct {
121+ name string
122+ mode Overflow
123+ setup func(w *Workspace)
124+ // xs is compared where the rects keep the row's x (not cascade bands,
125+ // nor the focused maximized column of fixed overflow).
126+ xs bool
127+ }{
128+ {"scroll plain", OverflowScroll, func(w *Workspace) {}, true},
129+ {"scroll stamped", OverflowScroll, func(w *Workspace) { stampSpans(w, 0, len(w.Columns)-1) }, true},
130+ {"scroll full width", OverflowScroll, func(w *Workspace) { w.Columns[1].FullWidth = true }, true},
131+ {"scroll fullscreen", OverflowScroll, func(w *Workspace) { w.SetFullscreen(w.Columns[2].Windows[0], true) }, true},
132+ {"scroll viewed", OverflowScroll, func(w *Workspace) { w.View = 137 }, true},
133+ {"fixed", OverflowFixed, func(w *Workspace) {}, true},
134+ {"fixed full width", OverflowFixed, func(w *Workspace) { w.Columns[1].FullWidth = true; w.Focus = 0 }, true},
135+ {"fixed focused full width", OverflowFixed, func(w *Workspace) { w.Columns[1].FullWidth = true; w.Focus = 1 }, false},
136+ {"fixed fullscreen", OverflowFixed, func(w *Workspace) { w.SetFullscreen(w.Columns[2].Windows[0], true) }, true},
137+ {"fixed stamped", OverflowFixed, func(w *Workspace) { stampSpans(w, 0, len(w.Columns)-1) }, true},
138+ {"cascade", OverflowCascade, func(w *Workspace) {}, false},
139+ {"cascade full width", OverflowCascade, func(w *Workspace) { w.Columns[1].FullWidth = true }, false},
140+ {"cascade stamped", OverflowCascade, func(w *Workspace) { stampSpans(w, 0, len(w.Columns)-1) }, false},
141+ } {
142+ t.Run(tc.name, func(t *testing.T) {
143+ w := spanWorkspace(tc.mode, 3)
144+ tc.setup(w)
145+ if tc.mode != OverflowScroll && w.scrollSpansValid() {
146+ t.Fatal("equal-cell workspace must not take the scroll span path")
147+ }
148+ xs, ws := w.columnEdges()
149+ rs := w.columnRectsAt(nil, false, 0)
150+ if len(xs) != len(rs) || len(ws) != len(rs) {
151+ t.Fatal("length", len(xs), len(ws), len(rs))
152+ }
153+ for i, r := range rs {
154+ if ws[i] != r.W {
155+ t.Errorf("column %d width %d, rect %d", i, ws[i], r.W)
156+ }
157+ if tc.xs && xs[i] != r.X+w.View {
158+ t.Errorf("column %d x %d, rect %d + view %d", i, xs[i], r.X, w.View)
159+ }
160+ }
161+ })
162+ }
163+}
@@ -27,3 +27,137 @@ func TestWorkspaceStepFollowsWorkspaceAxis(t *testing.T) {
27 })27 })
28 }28 }
29 }29 }
30+
31+// oldValidSpans is the reference implementation validSpans must match.
32+func oldValidSpans(w *Workspace, first, last int) bool {
33+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
34+ return false
35+ }
36+ stamp := w.Columns[first].spanStamp
37+ if stamp == 0 {
38+ return false
39+ }
40+ total, count := 0, 0
41+ for i := first; i <= last; i++ {
42+ if w.Columns[i].Span <= 0 || w.Columns[i].spanStamp != stamp {
43+ return false
44+ }
45+ total += w.Columns[i].Span
46+ }
47+ for _, c := range w.Columns {
48+ if c.spanStamp == stamp {
49+ count++
50+ }
51+ }
52+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
53+}
54+
55+func TestScrollWidthsMatchPerColumnValidation(t *testing.T) {
56+ for _, n := range []int{1, 2, 5, 9} {
57+ for _, state := range []string{"unstamped", "stamped", "stale", "foreign", "zero"} {
58+ w := spanWorkspace(OverflowScroll, n)
59+ switch state {
60+ case "stamped":
61+ stampSpans(w, 0, n-1)
62+ case "stale":
63+ stampSpans(w, 0, n-1)
64+ w.Columns[n-1].spanStamp = 99
65+ case "foreign":
66+ stampSpans(w, 0, n-1)
67+ w.Columns = append(w.Columns, Column{Windows: []WindowID{100}, Span: 5, spanStamp: w.Columns[0].spanStamp})
68+ case "zero":
69+ stampSpans(w, 0, n-1)
70+ w.Columns[0].Span = 0
71+ }
72+ last := len(w.Columns) - 1
73+ if got, want := w.validSpans(0, last), oldValidSpans(w, 0, last); got != want {
74+ t.Fatal(n, state, got, want)
75+ }
76+ valid := w.scrollSpansValid()
77+ xs, widths := w.columnEdges()
78+ rs := w.columnRects()
79+ for i := range w.Columns {
80+ if widths[i] != w.scrollWidth(i, oldValidSpans(w, 0, last)) || valid != oldValidSpans(w, 0, last) {
81+ t.Fatal(n, state, i, "width")
82+ }
83+ if xs[i] != w.columnX(i) || widths[i] != w.columnWidth(i) || rs[i].W != widths[i] {
84+ t.Fatal(n, state, i, "edges")
85+ }
86+ }
87+ }
88+ }
89+}
90+
91+func TestDropStaleSpansKeepsValidLanes(t *testing.T) {
92+ w := spanWorkspace(OverflowScroll, 4)
93+ // An unstamped workspace returns before any lane is measured.
94+ if n := testing.AllocsPerRun(10, w.dropStaleSpans); n != 0 {
95+ t.Fatalf("unstamped workspace allocates %v", n)
96+ }
97+ if w.Columns[0].Span != 0 || w.Columns[0].spanStamp != 0 {
98+ t.Fatal("unstamped workspace changed")
99+ }
100+ stampSpans(w, 0, 3)
101+ spans := []int{w.Columns[0].Span, w.Columns[3].Span}
102+ if n := testing.AllocsPerRun(10, w.dropStaleSpans); n != 0 {
103+ t.Fatalf("valid lane allocates %v", n)
104+ }
105+ if w.Columns[0].Span != spans[0] || w.Columns[3].Span != spans[1] {
106+ t.Fatal("valid lane dropped")
107+ }
108+ w.Columns[2].spanStamp = 77
109+ w.dropStaleSpans()
110+ for i, c := range w.Columns {
111+ if c.Span != 0 || c.spanStamp != 0 {
112+ t.Fatal("stale lane kept", i)
113+ }
114+ }
115+}
116+
117+// TestColumnEdgesMatchColumnRects checks columnEdges against columnRects, an
118+// independent path that builds the same row with the view shift applied.
119+func TestColumnEdgesMatchColumnRects(t *testing.T) {
120+ for _, tc := range []struct {
121+ name string
122+ mode Overflow
123+ setup func(w *Workspace)
124+ // xs is compared where the rects keep the row's x (not cascade bands,
125+ // nor the focused maximized column of fixed overflow).
126+ xs bool
127+ }{
128+ {"scroll plain", OverflowScroll, func(w *Workspace) {}, true},
129+ {"scroll stamped", OverflowScroll, func(w *Workspace) { stampSpans(w, 0, len(w.Columns)-1) }, true},
130+ {"scroll full width", OverflowScroll, func(w *Workspace) { w.Columns[1].FullWidth = true }, true},
131+ {"scroll fullscreen", OverflowScroll, func(w *Workspace) { w.SetFullscreen(w.Columns[2].Windows[0], true) }, true},
132+ {"scroll viewed", OverflowScroll, func(w *Workspace) { w.View = 137 }, true},
133+ {"fixed", OverflowFixed, func(w *Workspace) {}, true},
134+ {"fixed full width", OverflowFixed, func(w *Workspace) { w.Columns[1].FullWidth = true; w.Focus = 0 }, true},
135+ {"fixed focused full width", OverflowFixed, func(w *Workspace) { w.Columns[1].FullWidth = true; w.Focus = 1 }, false},
136+ {"fixed fullscreen", OverflowFixed, func(w *Workspace) { w.SetFullscreen(w.Columns[2].Windows[0], true) }, true},
137+ {"fixed stamped", OverflowFixed, func(w *Workspace) { stampSpans(w, 0, len(w.Columns)-1) }, true},
138+ {"cascade", OverflowCascade, func(w *Workspace) {}, false},
139+ {"cascade full width", OverflowCascade, func(w *Workspace) { w.Columns[1].FullWidth = true }, false},
140+ {"cascade stamped", OverflowCascade, func(w *Workspace) { stampSpans(w, 0, len(w.Columns)-1) }, false},
141+ } {
142+ t.Run(tc.name, func(t *testing.T) {
143+ w := spanWorkspace(tc.mode, 3)
144+ tc.setup(w)
145+ if tc.mode != OverflowScroll && w.scrollSpansValid() {
146+ t.Fatal("equal-cell workspace must not take the scroll span path")
147+ }
148+ xs, ws := w.columnEdges()
149+ rs := w.columnRectsAt(nil, false, 0)
150+ if len(xs) != len(rs) || len(ws) != len(rs) {
151+ t.Fatal("length", len(xs), len(ws), len(rs))
152+ }
153+ for i, r := range rs {
154+ if ws[i] != r.W {
155+ t.Errorf("column %d width %d, rect %d", i, ws[i], r.W)
156+ }
157+ if tc.xs && xs[i] != r.X+w.View {
158+ t.Errorf("column %d x %d, rect %d + view %d", i, xs[i], r.X, w.View)
159+ }
160+ }
161+ })
162+ }
163+}
M internal/core/slide.go +8 -6
@@ -84,11 +84,12 @@ func (w *Workspace) snapPoints() []float64 {
8484 g := w.gap()
8585 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
8686 last := len(w.Columns) - 1
87- lo := w.columnX(0) - minX
88- hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo)
87+ xs, widths := w.columnEdges()
88+ lo := xs[0] - minX
89+ hi := max(xs[last]+widths[last]-maxX, lo)
8990 views := make([]int, 0, 2*len(w.Columns))
9091 for i := range w.Columns {
91- views = append(views, min(max(w.columnX(i)-minX, lo), hi), min(max(w.columnX(i)+w.columnWidth(i)-maxX, lo), hi))
92+ views = append(views, min(max(xs[i]-minX, lo), hi), min(max(xs[i]+widths[i]-maxX, lo), hi))
9293 }
9394 slices.Sort(views)
9495 minStep := float64(w.Usable.W) * snapSpacing
@@ -121,9 +122,10 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
121122 }
122123 g := w.gap()
123124 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
125+ xs, widths := w.columnEdges()
124126 full := func(i int) bool {
125- x := w.columnX(i) - view
126- return x >= minX && x+w.columnWidth(i) <= maxX
127+ x := xs[i] - view
128+ return x >= minX && x+widths[i] <= maxX
127129 }
128130 if full(w.Focus) && view == w.View {
129131 return w.Focus
@@ -138,7 +140,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
138140 // Columns wider than the view: the one at the left edge.
139141 focus = 0
140142 for i := range w.Columns {
141- if w.columnX(i)-view <= minX {
143+ if xs[i]-view <= minX {
142144 focus = i
143145 }
144146 }
@@ -84,11 +84,12 @@ func (w *Workspace) snapPoints() []float64 {
84 g := w.gap()84 g := w.gap()
85 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g85 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
86 last := len(w.Columns) - 186 last := len(w.Columns) - 1
87- lo := w.columnX(0) - minX87+ xs, widths := w.columnEdges()
88- hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo)88+ lo := xs[0] - minX
89+ hi := max(xs[last]+widths[last]-maxX, lo)
89 views := make([]int, 0, 2*len(w.Columns))90 views := make([]int, 0, 2*len(w.Columns))
90 for i := range w.Columns {91 for i := range w.Columns {
91- views = append(views, min(max(w.columnX(i)-minX, lo), hi), min(max(w.columnX(i)+w.columnWidth(i)-maxX, lo), hi))92+ views = append(views, min(max(xs[i]-minX, lo), hi), min(max(xs[i]+widths[i]-maxX, lo), hi))
92 }93 }
93 slices.Sort(views)94 slices.Sort(views)
94 minStep := float64(w.Usable.W) * snapSpacing95 minStep := float64(w.Usable.W) * snapSpacing
@@ -121,9 +122,10 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
121 }122 }
122 g := w.gap()123 g := w.gap()
123 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g124 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
125+ xs, widths := w.columnEdges()
124 full := func(i int) bool {126 full := func(i int) bool {
125- x := w.columnX(i) - view127+ x := xs[i] - view
126- return x >= minX && x+w.columnWidth(i) <= maxX128+ return x >= minX && x+widths[i] <= maxX
127 }129 }
128 if full(w.Focus) && view == w.View {130 if full(w.Focus) && view == w.View {
129 return w.Focus131 return w.Focus
@@ -138,7 +140,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
138 // Columns wider than the view: the one at the left edge.140 // Columns wider than the view: the one at the left edge.
139 focus = 0141 focus = 0
140 for i := range w.Columns {142 for i := range w.Columns {
141- if w.columnX(i)-view <= minX {143+ if xs[i]-view <= minX {
142 focus = i144 focus = i
143 }145 }
144 }146 }

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