detached ff69908

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
178178 }
179179
180180 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+ }
192211 }
193212 }
194213 }
@@ -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 + delta187+ 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 {
141141
142142 // laneWidth is the width of column i of the lane first..last with the
143143 // 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.
145145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146146 if w.validSpans(first, last) {
147147 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 {
159159 }
160160 avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
161161 // 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)
164174 }
165175
166176 // 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 the142 // 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. The143 // 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/sum163+ 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 width176 // 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) {
4949 third, half := (1000-4*8)/3, (1000-3*8)/2
5050 rects := w.columnRectsFor(false)
5151 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},
5353 {8, half}, {8 + half + 8, half},
5454 } {
5555 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)/249 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) {
5656 }},
5757 {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
5858 {[]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}},
6060 }},
6161 {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
6262 {[]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}},
6464 }},
6565 {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
6666 // 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) {
5050 t.Errorf("2,auto,auto: got %v want %v", got, want)
5151 }
5252 // 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) {
5454 t.Errorf("3,auto,auto: got %v want %v", got, want)
5555 }
56- // All auto: the equal share of n columns, pixel for pixel.
56+ // All auto: equal shares, with remaining pixels in the last column.
5757 for n := 1; n <= 4; n++ {
5858 w := mk(n)
5959 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)
6266 }
6367 }
6468 }
@@ -510,7 +514,7 @@ func TestFixedGrowColumn(t *testing.T) {
510514 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}})
511515 // No donor is left: back to equal shares.
512516 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}})
514518 })
515519 t.Run("with gaps", func(t *testing.T) {
516520 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)

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close