Merge cee25ed447b54217b537e2368bb78d10be018933 into 37f59a2137a9674de4682e4dfbb501bef42894b5
15 files · +594 -62
M internal/adapters/vulkan/position_hint.go +4 -15
| @@ -2,7 +2,6 @@ package vulkan | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | - | "math" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 7 | ) | |
| @@ -38,20 +37,10 @@ func (w *sceneWalk) positionHint() { | |||
| 38 | 37 | w.alpha = alpha | |
| 39 | 38 | } | |
| 40 | 39 | ||
| 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. | |
| 44 | 43 | func (w *sceneWalk) hintRound(r image.Rectangle, radius int, color [3]uint8) { | |
| 45 | 44 | 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)) | |
| 57 | 46 | } | |
| @@ -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 = alpha | 37 | w.alpha = alpha | ||
| 39 | } | 38 | } | ||
| 40 | 39 | ||||
| 41 | - | // Rounded fills use the existing solid instances and batching pipeline. Each | 40 | + | // 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-frame | 41 | + | // 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 ( | |||
| 4 | 4 | "github.com/bnema/neferwl/internal/ports" | |
| 5 | 5 | "github.com/stretchr/testify/require" | |
| 6 | 6 | "image" | |
| 7 | + | "math" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -69,3 +70,93 @@ func TestPositionHintDamageTransitions(t *testing.T) { | |||
| 69 | 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 | + | } | |
| @@ -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() { | |||
| 92 | 92 | // radius, minus a hole inset by b with radius max(radius-b, 0). | |
| 93 | 93 | func (w *sceneWalk) roundedInnerBorder(t ports.Rect, b, radius int, col [3]uint8) { | |
| 94 | 94 | 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 { | |
| 98 | 98 | return | |
| 99 | 99 | } | |
| 100 | - | r := float32(math.Round(float64(radius) * w.scale)) | |
| 101 | - | dr := &w.draws[n] | |
| 102 | - | roundedDraw(dr, outer, r) | |
| 103 | 100 | dr.pc.misc[1] |= flagRing | |
| 104 | 101 | hole := w.physRect(t.X+b, t.Y+b, t.W-2*b, t.H-2*b) | |
| 105 | 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 | 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 | 210 | // dim paints a translucent black quad over rect (physical), clipped to the | |
| 200 | 211 | // output, at alpha scaled by the walk's alpha. Damage clipping later limits | |
| 201 | 212 | // it to the region being repainted, just like other fills. | |
| @@ -293,11 +304,7 @@ func (w *sceneWalk) windows() { | |||
| 293 | 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 | 305 | innerRadius = max(radius-float32(max(bPhys, 0)), 0) | |
| 295 | 306 | 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 { | |
| 301 | 308 | dr.pc.misc[1] |= flagRing | |
| 302 | 309 | // Opaque, the ring runs 1px under the body so no seam | |
| 303 | 310 | // 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 | return | 98 | 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] |= flagRing | 100 | 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 the | 210 | // 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 limits | 211 | // 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] |= flagRing | 308 | dr.pc.misc[1] |= flagRing | ||
| 302 | // Opaque, the ring runs 1px under the body so no seam | 309 | // Opaque, the ring runs 1px under the body so no seam | ||
| 303 | // shows; faded, that overlap would double up: the hole | 310 | // 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 { | |||
| 77 | 77 | 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 | 83 | if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 { | |
| 82 | - | return | |
| 84 | + | return false | |
| 83 | 85 | } | |
| 84 | 86 | rs := w.columnRects() | |
| 85 | 87 | r := rs[b.col] | |
| 86 | 88 | if b.kind == borderRow { | |
| 87 | 89 | c := &w.Columns[b.col] | |
| 88 | 90 | if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 { | |
| 89 | - | return | |
| 91 | + | return false | |
| 90 | 92 | } | |
| 91 | 93 | rows := rowRects(r, *c, w.gap()) | |
| 92 | 94 | avail := max(r.H-(len(rows)-1)*w.gap(), 0) | |
| 93 | 95 | if avail == 0 { | |
| 94 | - | return | |
| 96 | + | return false | |
| 95 | 97 | } | |
| 96 | - | if !validShares(*c) { | |
| 98 | + | stamped := !validShares(*c) | |
| 99 | + | if stamped { | |
| 97 | 100 | c.Shares = make([]int, len(rows)) | |
| 98 | 101 | used := 0 | |
| 99 | 102 | for i := range rows { | |
| @@ -106,18 +109,19 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 106 | 109 | } | |
| 107 | 110 | pair := c.Shares[b.row] + c.Shares[b.row+1] | |
| 108 | 111 | if pair < 2*minShare { | |
| 109 | - | return | |
| 112 | + | return stamped | |
| 110 | 113 | } | |
| 111 | 114 | target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail) | |
| 112 | 115 | target = min(max(target, minShare), pair-minShare) | |
| 113 | 116 | if b.row+1 < len(rows)-1 { | |
| 114 | 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 | 120 | c.Shares[b.row], c.Shares[b.row+1] = target, pair-target | |
| 117 | - | return | |
| 121 | + | return changed | |
| 118 | 122 | } | |
| 119 | 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 | - | return | |
| 124 | + | return false | |
| 121 | 125 | } | |
| 122 | 126 | first, last := w.laneBounds(w.laneOf(b.col)) | |
| 123 | 127 | avail := max(w.Usable.W-2*w.gap(), 0) | |
| @@ -125,9 +129,11 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 125 | 129 | avail = max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| 126 | 130 | } | |
| 127 | 131 | if avail == 0 { | |
| 128 | - | return | |
| 132 | + | 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 | 137 | w.spanSeq++ | |
| 132 | 138 | used := 0 | |
| 133 | 139 | for i := first; i <= last; i++ { | |
| @@ -142,18 +148,22 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 142 | 148 | minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail) | |
| 143 | 149 | target := pixelShare(pos-w.gap()/2-r.X, avail) | |
| 144 | 150 | 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 | |
| 147 | 155 | } | |
| 148 | 156 | pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span | |
| 149 | 157 | if pair < 2*minimum { | |
| 150 | - | return | |
| 158 | + | return stamped | |
| 151 | 159 | } | |
| 152 | 160 | target = min(max(target, minimum), pair-minimum) | |
| 153 | 161 | if b.col+1 < last { | |
| 154 | 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 | 165 | w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target | |
| 166 | + | return changed | |
| 157 | 167 | } | |
| 158 | 168 | ||
| 159 | 169 | func (w *Workspace) resetBorder(b border) { | |
| @@ -77,23 +77,26 @@ func pixelShare(pixels, total int) int { | |||||
| 77 | return (pixels*10000 + total - 1) / total | 77 | 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 | - | return | 84 | + | 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 | - | return | 91 | + | 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 | - | return | 96 | + | 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 := 0 | 101 | 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 | - | return | 112 | + | 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-target | 120 | c.Shares[b.row], c.Shares[b.row+1] = target, pair-target | ||
| 117 | - | return | 121 | + | 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 | - | return | 124 | + | 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 | - | return | 132 | + | 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 := 0 | 138 | 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 | - | return | 152 | + | 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].Span | 156 | pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span | ||
| 149 | if pair < 2*minimum { | 157 | if pair < 2*minimum { | ||
| 150 | - | return | 158 | + | 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-target | 165 | 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 { | |||
| 67 | 67 | if g.border.kind == borderRow { | |
| 68 | 68 | 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 | 73 | return c.publish(ctx) | |
| 72 | 74 | } | |
| 73 | 75 | ||
| @@ -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.y | 68 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "slices" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 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 | + | } | |
| @@ -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 { | |||
| 80 | 80 | first, last := w.laneBounds(w.laneOf(i)) | |
| 81 | 81 | S, u, sum := w.laneStats(first, last) | |
| 82 | 82 | x := w.gap() | |
| 83 | + | valid := w.validSpans(first, last) | |
| 83 | 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 | 87 | return x | |
| 87 | 88 | } | |
| @@ -98,7 +99,7 @@ func (w *Workspace) cascadeBands() (n, spacing int) { | |||
| 98 | 99 | func (w *Workspace) cellWidth(i int) int { | |
| 99 | 100 | first, last := w.laneBounds(w.laneOf(i)) | |
| 100 | 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 | 105 | // 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 | 143 | // laneWidth is the width of column i of the lane first..last with the | |
| 143 | 144 | // stats of laneStats: the usable width after gaps, split by weight. The | |
| 144 | 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 | - | 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 { | |
| 147 | 149 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| 148 | 150 | if i == last { | |
| 149 | 151 | used := 0 | |
| @@ -205,8 +207,9 @@ func (w *Workspace) cascadeRectsAt(dst []Rect, ignoreFullWidth bool, shift int) | |||
| 205 | 207 | w.walkBands(func(lane, first, last int) bool { | |
| 206 | 208 | S, u, sum := w.laneStats(first, last) | |
| 207 | 209 | x := g | |
| 210 | + | valid := w.validSpans(first, last) | |
| 208 | 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 | 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 | 214 | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 212 | 215 | 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 x | 87 | 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 of | 105 | // 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 the | 143 | // 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. The | 144 | // 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 := 0 | 151 | 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 := g | 209 | 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 + g | 215 | 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 { | |||
| 559 | 559 | func (w *Workspace) scrollBy(dir int) bool { | |
| 560 | 560 | g := w.gap() | |
| 561 | 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 | 564 | view := w.View | |
| 564 | 565 | if dir < 0 { | |
| 565 | 566 | // The first column not fully on screen at the left. | |
| 566 | 567 | 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 | |
| 569 | 570 | break | |
| 570 | 571 | } | |
| 571 | 572 | } | |
| 572 | 573 | } else { | |
| 573 | 574 | 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 | |
| 576 | 577 | break | |
| 577 | 578 | } | |
| 578 | 579 | } | |
| @@ -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.View | 564 | 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 - g | 569 | + | view = xs[i] - w.Usable.X - g |
| 569 | break | 570 | 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.W | 576 | + | view = xs[i] + ws[i] + g - w.Usable.X - w.Usable.W |
| 576 | break | 577 | break | ||
| 577 | } | 578 | } | ||
| 578 | } | 579 | } | ||
M internal/core/flash.go +4 -1
| @@ -196,7 +196,10 @@ func (c *Core) flashFor(sc *screen) *ports.FocusFlash { | |||
| 196 | 196 | } | |
| 197 | 197 | sample := ports.FocusFlash{Rect: f.rect, Width: c.cfg.Focus.FlashWidth, Radius: f.radius, Color: color, Alpha: f.value} | |
| 198 | 198 | 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 | |
| 200 | 203 | } | |
| 201 | 204 | return f.published | |
| 202 | 205 | } | |
| @@ -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 = &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 | |||
| 200 | } | 203 | } | ||
| 201 | return f.published | 204 | 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 { | |||
| 1649 | 1649 | if w.policy().wraps || w.laneRow() { | |
| 1650 | 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 | 1672 | // specialWidth is the width of a maximized or fullscreen column. | |
| @@ -1673,13 +1690,13 @@ func (w *Workspace) laneRow() bool { | |||
| 1673 | 1690 | // scrollWidth is the width of column i when it is sized on its own: auto | |
| 1674 | 1691 | // columns share the usable width as the visible ones would, the others take | |
| 1675 | 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 | 1694 | k := max(w.MaxColumns, 1) | |
| 1678 | 1695 | if w.policy().equalCells { | |
| 1679 | 1696 | // Fixed overflow past MaxColumns: the spiral's slots. | |
| 1680 | 1697 | return w.equalShare(k) | |
| 1681 | 1698 | } | |
| 1682 | - | if w.validSpans(0, len(w.Columns)-1) { | |
| 1699 | + | if spansValid { | |
| 1683 | 1700 | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | |
| 1684 | 1701 | } | |
| 1685 | 1702 | c := w.Columns[i].Cells | |
| @@ -1689,10 +1706,28 @@ func (w *Workspace) scrollWidth(i int) int { | |||
| 1689 | 1706 | c = min(c, k) | |
| 1690 | 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 | 1726 | func (w *Workspace) columnX(i int) int { | |
| 1727 | + | valid := w.scrollSpansValid() | |
| 1693 | 1728 | x := w.Usable.X + w.gap() | |
| 1694 | 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 | 1732 | return x | |
| 1698 | 1733 | } | |
| @@ -1756,8 +1791,9 @@ func (w *Workspace) columnRectsAt(dst []Rect, ignoreFullWidth bool, shift int) [ | |||
| 1756 | 1791 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 1757 | 1792 | view := w.View + shift | |
| 1758 | 1793 | x := w.Usable.X + g | |
| 1794 | + | valid := w.scrollSpansValid() | |
| 1759 | 1795 | for i := range w.Columns { | |
| 1760 | - | width := w.columnWidthFor(i, ignoreFullWidth) | |
| 1796 | + | width := w.columnWidthWith(i, ignoreFullWidth, valid) | |
| 1761 | 1797 | rects[i] = Rect{X: x - view, Y: y, W: width, H: h} | |
| 1762 | 1798 | x += width + g | |
| 1763 | 1799 | } | |
| @@ -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: auto | 1690 | // 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 take | 1691 | // 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) / 10000 | 1700 | return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000 | ||
| 1684 | } | 1701 | } | ||
| 1685 | c := w.Columns[i].Cells | 1702 | 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 x | 1732 | 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 + shift | 1792 | view := w.View + shift | ||
| 1758 | x := w.Usable.X + g | 1793 | 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 + g | 1798 | 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 { | |||
| 86 | 86 | // validity before clearing any stamps, so shared identities cannot become | |
| 87 | 87 | // valid merely because an earlier invalid lane was cleared. | |
| 88 | 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 | 100 | var stale []int | |
| 90 | 101 | for first := 0; first < len(w.Columns); { | |
| 91 | 102 | _, 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 become | 86 | // 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 []int | 100 | 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) { | |||
| 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 | + | } | |
| @@ -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 { | |||
| 84 | 84 | g := w.gap() | |
| 85 | 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 86 | 86 | 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) | |
| 89 | 90 | views := make([]int, 0, 2*len(w.Columns)) | |
| 90 | 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 | 94 | slices.Sort(views) | |
| 94 | 95 | minStep := float64(w.Usable.W) * snapSpacing | |
| @@ -121,9 +122,10 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 121 | 122 | } | |
| 122 | 123 | g := w.gap() | |
| 123 | 124 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 125 | + | xs, widths := w.columnEdges() | |
| 124 | 126 | 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 | |
| 127 | 129 | } | |
| 128 | 130 | if full(w.Focus) && view == w.View { | |
| 129 | 131 | return w.Focus | |
| @@ -138,7 +140,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 138 | 140 | // Columns wider than the view: the one at the left edge. | |
| 139 | 141 | focus = 0 | |
| 140 | 142 | for i := range w.Columns { | |
| 141 | - | if w.columnX(i)-view <= minX { | |
| 143 | + | if xs[i]-view <= minX { | |
| 142 | 144 | focus = i | |
| 143 | 145 | } | |
| 144 | 146 | } | |
| @@ -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-g | 85 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | ||
| 86 | last := len(w.Columns) - 1 | 86 | last := len(w.Columns) - 1 | ||
| 87 | - | lo := w.columnX(0) - minX | 87 | + | 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) * snapSpacing | 95 | 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-g | 124 | 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) - view | 127 | + | x := xs[i] - view |
| 126 | - | return x >= minX && x+w.columnWidth(i) <= maxX | 128 | + | 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.Focus | 131 | 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 = 0 | 141 | 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 = i | 144 | focus = i | ||
| 143 | } | 145 | } | ||
| 144 | } | 146 | } | ||