fix(core): keep untouched columns fixed when stamping spans
- commit
- ff6990836c19f52b70fe74aa090ae25e50e9a10d
- author
- brice · · 2026-10-21 05:43 UTC
- committer
- brice · · 2026-10-21 07:11 UTC
- parent
- 4c917cf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +55 -22
M internal/core/border_test.go +30 -11
| @@ -178,17 +178,36 @@ func TestSetRowBorder(t *testing.T) { | |||
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | func TestBorderPixelRemainders(t *testing.T) { | |
| 181 | - | for width := 997; width <= 1003; width++ { | |
| 182 | - | w := spanWorkspace(OverflowFixed, 3) | |
| 183 | - | w.SetOutput(width, 800) | |
| 184 | - | before := w.columnRects() | |
| 185 | - | b := border{kind: borderColumn, col: 0} | |
| 186 | - | for delta := -20; delta <= 20; delta++ { | |
| 187 | - | pos := before[0].X + before[0].W + 5 + delta | |
| 188 | - | w.setBorder(b, pos) | |
| 189 | - | after := w.columnRects() | |
| 190 | - | if after[2].X != before[2].X || after[0].X+after[0].W+5 != pos { | |
| 191 | - | t.Fatal(width, delta, before, after) | |
| 181 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade} { | |
| 182 | + | for n := 2; n <= 4; n++ { | |
| 183 | + | for width := 997; width <= 1003; width++ { | |
| 184 | + | for pair := 0; pair < n-1; pair++ { | |
| 185 | + | w := spanWorkspace(mode, n) | |
| 186 | + | w.SetMaxColumns(n) | |
| 187 | + | for i := range w.Columns { | |
| 188 | + | w.Columns[i].BandStart = false | |
| 189 | + | } | |
| 190 | + | w.SetOutput(width, 800) | |
| 191 | + | before := w.columnRects() | |
| 192 | + | b := border{kind: borderColumn, col: pair} | |
| 193 | + | pos := before[pair].X + before[pair].W + 5 | |
| 194 | + | w.setBorder(b, pos) | |
| 195 | + | if after := w.columnRects(); !slices.Equal(before, after) { | |
| 196 | + | t.Fatalf("stamp changed geometry: mode=%v n=%d width=%d pair=%d before=%v after=%v", mode, n, width, pair, before, after) | |
| 197 | + | } | |
| 198 | + | for delta := -20; delta <= 20; delta++ { | |
| 199 | + | w.setBorder(b, pos+delta) | |
| 200 | + | after := w.columnRects() | |
| 201 | + | for i := range before { | |
| 202 | + | if i != pair && i != pair+1 && after[i] != before[i] { | |
| 203 | + | t.Fatalf("untouched rect changed: mode=%v n=%d width=%d pair=%d delta=%d before=%v after=%v", mode, n, width, pair, delta, before, after) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | if after[pair].X+after[pair].W+5 != pos+delta { | |
| 207 | + | t.Fatal("border missed pointer", before, after) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 192 | 211 | } | |
| 193 | 212 | } | |
| 194 | 213 | } | |
| @@ -178,17 +178,36 @@ func TestSetRowBorder(t *testing.T) { | |||||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | func TestBorderPixelRemainders(t *testing.T) { | 180 | func TestBorderPixelRemainders(t *testing.T) { | ||
| 181 | - | for width := 997; width <= 1003; width++ { | 181 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade} { |
| 182 | - | w := spanWorkspace(OverflowFixed, 3) | 182 | + | for n := 2; n <= 4; n++ { |
| 183 | - | w.SetOutput(width, 800) | 183 | + | for width := 997; width <= 1003; width++ { |
| 184 | - | before := w.columnRects() | 184 | + | for pair := 0; pair < n-1; pair++ { |
| 185 | - | b := border{kind: borderColumn, col: 0} | 185 | + | w := spanWorkspace(mode, n) |
| 186 | - | for delta := -20; delta <= 20; delta++ { | 186 | + | w.SetMaxColumns(n) |
| 187 | - | pos := before[0].X + before[0].W + 5 + delta | 187 | + | for i := range w.Columns { |
| 188 | - | w.setBorder(b, pos) | 188 | + | w.Columns[i].BandStart = false |
| 189 | - | after := w.columnRects() | 189 | + | } |
| 190 | - | if after[2].X != before[2].X || after[0].X+after[0].W+5 != pos { | 190 | + | w.SetOutput(width, 800) |
| 191 | - | t.Fatal(width, delta, before, after) | 191 | + | before := w.columnRects() |
| 192 | + | b := border{kind: borderColumn, col: pair} | |||
| 193 | + | pos := before[pair].X + before[pair].W + 5 | |||
| 194 | + | w.setBorder(b, pos) | |||
| 195 | + | if after := w.columnRects(); !slices.Equal(before, after) { | |||
| 196 | + | t.Fatalf("stamp changed geometry: mode=%v n=%d width=%d pair=%d before=%v after=%v", mode, n, width, pair, before, after) | |||
| 197 | + | } | |||
| 198 | + | for delta := -20; delta <= 20; delta++ { | |||
| 199 | + | w.setBorder(b, pos+delta) | |||
| 200 | + | after := w.columnRects() | |||
| 201 | + | for i := range before { | |||
| 202 | + | if i != pair && i != pair+1 && after[i] != before[i] { | |||
| 203 | + | t.Fatalf("untouched rect changed: mode=%v n=%d width=%d pair=%d delta=%d before=%v after=%v", mode, n, width, pair, delta, before, after) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | if after[pair].X+after[pair].W+5 != pos+delta { | |||
| 207 | + | t.Fatal("border missed pointer", before, after) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 192 | } | 211 | } | ||
| 193 | } | 212 | } | ||
| 194 | } | 213 | } | ||
M internal/core/cascade.go +13 -3
| @@ -141,7 +141,7 @@ func (w *Workspace) laneWeight(i, S, u int) int { | |||
| 141 | 141 | ||
| 142 | 142 | // laneWidth is the width of column i of the lane first..last with the | |
| 143 | 143 | // stats of laneStats: the usable width after gaps, split by weight. The | |
| 144 | - | // remainder under one pixel per weight stays empty, as in equalShare. | |
| 144 | + | // last column receives the pixel remainder, just as for stamped spans. | |
| 145 | 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | |
| 146 | 146 | if w.validSpans(first, last) { | |
| 147 | 147 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| @@ -159,8 +159,18 @@ func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | |||
| 159 | 159 | } | |
| 160 | 160 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| 161 | 161 | // floor(avail*weight/sum) without overflowing on a huge area. | |
| 162 | - | weight := w.laneWeight(i, S, u) | |
| 163 | - | return avail/sum*weight + avail%sum*weight/sum | |
| 162 | + | width := func(j int) int { | |
| 163 | + | weight := w.laneWeight(j, S, u) | |
| 164 | + | return avail/sum*weight + avail%sum*weight/sum | |
| 165 | + | } | |
| 166 | + | if i == last { | |
| 167 | + | used := 0 | |
| 168 | + | for j := first; j < last; j++ { | |
| 169 | + | used += width(j) | |
| 170 | + | } | |
| 171 | + | return avail - used | |
| 172 | + | } | |
| 173 | + | return width(i) | |
| 164 | 174 | } | |
| 165 | 175 | ||
| 166 | 176 | // equalShare is the width of each of k columns sharing the usable width | |
| @@ -141,7 +141,7 @@ func (w *Workspace) laneWeight(i, S, u int) int { | |||||
| 141 | 141 | ||||
| 142 | // laneWidth is the width of column i of the lane first..last with the | 142 | // 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 | 143 | // stats of laneStats: the usable width after gaps, split by weight. The | ||
| 144 | - | // remainder under one pixel per weight stays empty, as in equalShare. | 144 | + | // last column receives the pixel remainder, just as for stamped spans. |
| 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | 145 | func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | ||
| 146 | if w.validSpans(first, last) { | 146 | if w.validSpans(first, last) { | ||
| 147 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | 147 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | ||
| @@ -159,8 +159,18 @@ func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int { | |||||
| 159 | } | 159 | } | ||
| 160 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | 160 | avail := max(w.Usable.W-w.gap()*(last-first+2), 0) | ||
| 161 | // floor(avail*weight/sum) without overflowing on a huge area. | 161 | // floor(avail*weight/sum) without overflowing on a huge area. | ||
| 162 | - | weight := w.laneWeight(i, S, u) | 162 | + | width := func(j int) int { |
| 163 | - | return avail/sum*weight + avail%sum*weight/sum | 163 | + | weight := w.laneWeight(j, S, u) |
| 164 | + | return avail/sum*weight + avail%sum*weight/sum | |||
| 165 | + | } | |||
| 166 | + | if i == last { | |||
| 167 | + | used := 0 | |||
| 168 | + | for j := first; j < last; j++ { | |||
| 169 | + | used += width(j) | |||
| 170 | + | } | |||
| 171 | + | return avail - used | |||
| 172 | + | } | |||
| 173 | + | return width(i) | |||
| 164 | } | 174 | } | ||
| 165 | 175 | ||||
| 166 | // equalShare is the width of each of k columns sharing the usable width | 176 | // equalShare is the width of each of k columns sharing the usable width | ||
M internal/core/cascade_test.go +1 -1
| @@ -49,7 +49,7 @@ func TestCascadePartialLastBandWithGaps(t *testing.T) { | |||
| 49 | 49 | third, half := (1000-4*8)/3, (1000-3*8)/2 | |
| 50 | 50 | rects := w.columnRectsFor(false) | |
| 51 | 51 | for i, want := range []struct{ x, w int }{ | |
| 52 | - | {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third}, | |
| 52 | + | {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), 1000 - 4*8 - 2*third}, | |
| 53 | 53 | {8, half}, {8 + half + 8, half}, | |
| 54 | 54 | } { | |
| 55 | 55 | if r := rects[i]; r.X != want.x || r.W != want.w { | |
| @@ -49,7 +49,7 @@ func TestCascadePartialLastBandWithGaps(t *testing.T) { | |||||
| 49 | third, half := (1000-4*8)/3, (1000-3*8)/2 | 49 | third, half := (1000-4*8)/3, (1000-3*8)/2 | ||
| 50 | rects := w.columnRectsFor(false) | 50 | rects := w.columnRectsFor(false) | ||
| 51 | for i, want := range []struct{ x, w int }{ | 51 | for i, want := range []struct{ x, w int }{ | ||
| 52 | - | {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third}, | 52 | + | {8, third}, {8 + third + 8, third}, {8 + 2*(third+8), 1000 - 4*8 - 2*third}, |
| 53 | {8, half}, {8 + half + 8, half}, | 53 | {8, half}, {8 + half + 8, half}, | ||
| 54 | } { | 54 | } { | ||
| 55 | if r := rects[i]; r.X != want.x || r.W != want.w { | 55 | if r := rects[i]; r.X != want.x || r.W != want.w { | ||
M internal/core/column_cells_test.go +2 -2
| @@ -56,11 +56,11 @@ func TestGrowColumnCycle(t *testing.T) { | |||
| 56 | 56 | }}, | |
| 57 | 57 | {"cascade three columns", OverflowCascade, 4, 3, 2, []step{ | |
| 58 | 58 | {[]int{1, 2, 1}, []int{200, 400, 200}}, | |
| 59 | - | {[]int{0, 0, 0}, []int{266, 266, 266}}, | |
| 59 | + | {[]int{0, 0, 0}, []int{266, 266, 268}}, | |
| 60 | 60 | }}, | |
| 61 | 61 | {"fixed three columns", OverflowFixed, 4, 3, 2, []step{ | |
| 62 | 62 | {[]int{1, 2, 1}, []int{200, 400, 200}}, | |
| 63 | - | {[]int{0, 0, 0}, []int{266, 266, 266}}, | |
| 63 | + | {[]int{0, 0, 0}, []int{266, 266, 268}}, | |
| 64 | 64 | }}, | |
| 65 | 65 | {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{ | |
| 66 | 66 | // 5 cells over 3 columns: 1 each and the 2 left to the focused one. | |
| @@ -56,11 +56,11 @@ func TestGrowColumnCycle(t *testing.T) { | |||||
| 56 | }}, | 56 | }}, | ||
| 57 | {"cascade three columns", OverflowCascade, 4, 3, 2, []step{ | 57 | {"cascade three columns", OverflowCascade, 4, 3, 2, []step{ | ||
| 58 | {[]int{1, 2, 1}, []int{200, 400, 200}}, | 58 | {[]int{1, 2, 1}, []int{200, 400, 200}}, | ||
| 59 | - | {[]int{0, 0, 0}, []int{266, 266, 266}}, | 59 | + | {[]int{0, 0, 0}, []int{266, 266, 268}}, |
| 60 | }}, | 60 | }}, | ||
| 61 | {"fixed three columns", OverflowFixed, 4, 3, 2, []step{ | 61 | {"fixed three columns", OverflowFixed, 4, 3, 2, []step{ | ||
| 62 | {[]int{1, 2, 1}, []int{200, 400, 200}}, | 62 | {[]int{1, 2, 1}, []int{200, 400, 200}}, | ||
| 63 | - | {[]int{0, 0, 0}, []int{266, 266, 266}}, | 63 | + | {[]int{0, 0, 0}, []int{266, 266, 268}}, |
| 64 | }}, | 64 | }}, | ||
| 65 | {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{ | 65 | {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{ | ||
| 66 | // 5 cells over 3 columns: 1 each and the 2 left to the focused one. | 66 | // 5 cells over 3 columns: 1 each and the 2 left to the focused one. | ||
M internal/core/layout_test.go +9 -5
| @@ -50,15 +50,19 @@ func TestLaneWidths(t *testing.T) { | |||
| 50 | 50 | t.Errorf("2,auto,auto: got %v want %v", got, want) | |
| 51 | 51 | } | |
| 52 | 52 | // No room for them: an auto column weighs one cell. | |
| 53 | - | if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) { | |
| 53 | + | if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail - avail*3/5 - avail/5}; !reflect.DeepEqual(got, want) { | |
| 54 | 54 | t.Errorf("3,auto,auto: got %v want %v", got, want) | |
| 55 | 55 | } | |
| 56 | - | // All auto: the equal share of n columns, pixel for pixel. | |
| 56 | + | // All auto: equal shares, with remaining pixels in the last column. | |
| 57 | 57 | for n := 1; n <= 4; n++ { | |
| 58 | 58 | w := mk(n) | |
| 59 | 59 | for i := range w.Columns { | |
| 60 | - | if got := w.cellWidth(i); got != w.equalShare(n) { | |
| 61 | - | t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n)) | |
| 60 | + | want := w.equalShare(n) | |
| 61 | + | if i == n-1 { | |
| 62 | + | want = 1000 - 8*(n+1) - (n-1)*want | |
| 63 | + | } | |
| 64 | + | if got := w.cellWidth(i); got != want { | |
| 65 | + | t.Errorf("%d auto columns: width %d want %d", n, got, want) | |
| 62 | 66 | } | |
| 63 | 67 | } | |
| 64 | 68 | } | |
| @@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) { | |||
| 510 | 514 | check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}}) | |
| 511 | 515 | // No donor is left: back to equal shares. | |
| 512 | 516 | w.GrowColumn(true) | |
| 513 | - | check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}}) | |
| 517 | + | check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 34, H: 80}}) | |
| 514 | 518 | }) | |
| 515 | 519 | t.Run("with gaps", func(t *testing.T) { | |
| 516 | 520 | w := fixedWorkspace(4, 4, 2) | |
| @@ -50,15 +50,19 @@ func TestLaneWidths(t *testing.T) { | |||||
| 50 | t.Errorf("2,auto,auto: got %v want %v", got, want) | 50 | t.Errorf("2,auto,auto: got %v want %v", got, want) | ||
| 51 | } | 51 | } | ||
| 52 | // No room for them: an auto column weighs one cell. | 52 | // No room for them: an auto column weighs one cell. | ||
| 53 | - | if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) { | 53 | + | if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail - avail*3/5 - avail/5}; !reflect.DeepEqual(got, want) { |
| 54 | t.Errorf("3,auto,auto: got %v want %v", got, want) | 54 | t.Errorf("3,auto,auto: got %v want %v", got, want) | ||
| 55 | } | 55 | } | ||
| 56 | - | // All auto: the equal share of n columns, pixel for pixel. | 56 | + | // All auto: equal shares, with remaining pixels in the last column. |
| 57 | for n := 1; n <= 4; n++ { | 57 | for n := 1; n <= 4; n++ { | ||
| 58 | w := mk(n) | 58 | w := mk(n) | ||
| 59 | for i := range w.Columns { | 59 | for i := range w.Columns { | ||
| 60 | - | if got := w.cellWidth(i); got != w.equalShare(n) { | 60 | + | want := w.equalShare(n) |
| 61 | - | t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n)) | 61 | + | if i == n-1 { |
| 62 | + | want = 1000 - 8*(n+1) - (n-1)*want | |||
| 63 | + | } | |||
| 64 | + | if got := w.cellWidth(i); got != want { | |||
| 65 | + | t.Errorf("%d auto columns: width %d want %d", n, got, want) | |||
| 62 | } | 66 | } | ||
| 63 | } | 67 | } | ||
| 64 | } | 68 | } | ||
| @@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) { | |||||
| 510 | check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}}) | 514 | check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}}) | ||
| 511 | // No donor is left: back to equal shares. | 515 | // No donor is left: back to equal shares. | ||
| 512 | w.GrowColumn(true) | 516 | w.GrowColumn(true) | ||
| 513 | - | check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}}) | 517 | + | check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 34, H: 80}}) |
| 514 | }) | 518 | }) | ||
| 515 | t.Run("with gaps", func(t *testing.T) { | 519 | t.Run("with gaps", func(t *testing.T) { | ||
| 516 | w := fixedWorkspace(4, 4, 2) | 520 | w := fixedWorkspace(4, 4, 2) | ||