detached 02f2bf5

fix(core): preserve untouched tiles across border rounding

commit
02f2bf5cf610c9c53374607e445f82305463d72e
author
brice · · 2026-10-21 05:26 UTC
committer
brice · · 2026-10-21 07:11 UTC
parent
5932e6b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +41 -2
M internal/core/border.go +24 -2
@@ -29,7 +29,7 @@ func borderZone(a, b, pad int) (int, int) {
2929 }
3030
3131 func (w *Workspace) borderAt(x, y, pad int) (border, bool) {
32- if w.fullscreen != 0 {
32+ if w.fullscreen != 0 || !borderContains(w.Usable, x, y) {
3333 return border{}, false
3434 }
3535 // Overview and preview are Monitor presentation, not Workspace state. Their
@@ -101,6 +101,8 @@ func (w *Workspace) setBorder(b border, pos int) {
101101 c.Shares = make([]int, len(rows))
102102 used := 0
103103 for i := range rows {
104+ // Stamp the middle of the pixel bucket, leaving room for
105+ // pair-preserving rounding on subsequent resizes.
104106 c.Shares[i] = (rows[i].H*10000 + 5000) / avail
105107 used += c.Shares[i]
106108 }
@@ -112,6 +114,9 @@ func (w *Workspace) setBorder(b border, pos int) {
112114 }
113115 target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
114116 target = min(max(target, minShare), pair-minShare)
117+ if b.row+1 < len(rows)-1 {
118+ target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
119+ }
115120 c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
116121 return
117122 }
@@ -129,7 +134,7 @@ func (w *Workspace) setBorder(b border, pos int) {
129134 if !w.validSpans(first, last) {
130135 used := 0
131136 for i := first; i <= last; i++ {
132- w.Columns[i].Span = max(pixelShare(rs[i].W, avail), 1)
137+ w.Columns[i].Span = max((rs[i].W*10000+5000)/avail, 1)
133138 w.Columns[i].spanLane = last - first + 1
134139 used += w.Columns[i].Span
135140 }
@@ -148,6 +153,9 @@ func (w *Workspace) setBorder(b border, pos int) {
148153 return
149154 }
150155 target = min(max(target, minimum), pair-minimum)
156+ if b.col+1 < last {
157+ target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
158+ }
151159 w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
152160 }
153161
@@ -162,3 +170,17 @@ func (w *Workspace) resetBorder(b border) {
162170 w.Columns[b.col].Shares = nil
163171 }
164172 }
173+
174+// pairPixelShare keeps the pair's combined integer width unchanged as well as
175+// its basis-point sum. Independent floors can otherwise shift later tiles by
176+// one pixel even when the sum of the two shares is constant.
177+func pairPixelShare(target, pair, avail, pixels, minimum int) int {
178+ left := avail * target / 10000
179+ right := pixels - left
180+ lo := max(pixelShare(left, avail), pair-pixelShare(right+1, avail)+1, minimum)
181+ hi := min(pixelShare(left+1, avail)-1, pair-pixelShare(right, avail), pair-minimum)
182+ if lo <= hi {
183+ return min(max(target, lo), hi)
184+ }
185+ return target
186+}
@@ -29,7 +29,7 @@ func borderZone(a, b, pad int) (int, int) {
29 }29 }
30 30
31 func (w *Workspace) borderAt(x, y, pad int) (border, bool) {31 func (w *Workspace) borderAt(x, y, pad int) (border, bool) {
32- if w.fullscreen != 0 {32+ if w.fullscreen != 0 || !borderContains(w.Usable, x, y) {
33 return border{}, false33 return border{}, false
34 }34 }
35 // Overview and preview are Monitor presentation, not Workspace state. Their35 // Overview and preview are Monitor presentation, not Workspace state. Their
@@ -101,6 +101,8 @@ func (w *Workspace) setBorder(b border, pos int) {
101 c.Shares = make([]int, len(rows))101 c.Shares = make([]int, len(rows))
102 used := 0102 used := 0
103 for i := range rows {103 for i := range rows {
104+ // Stamp the middle of the pixel bucket, leaving room for
105+ // pair-preserving rounding on subsequent resizes.
104 c.Shares[i] = (rows[i].H*10000 + 5000) / avail106 c.Shares[i] = (rows[i].H*10000 + 5000) / avail
105 used += c.Shares[i]107 used += c.Shares[i]
106 }108 }
@@ -112,6 +114,9 @@ func (w *Workspace) setBorder(b border, pos int) {
112 }114 }
113 target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)115 target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail)
114 target = min(max(target, minShare), pair-minShare)116 target = min(max(target, minShare), pair-minShare)
117+ if b.row+1 < len(rows)-1 {
118+ target = pairPixelShare(target, pair, avail, rows[b.row].H+rows[b.row+1].H, minShare)
119+ }
115 c.Shares[b.row], c.Shares[b.row+1] = target, pair-target120 c.Shares[b.row], c.Shares[b.row+1] = target, pair-target
116 return121 return
117 }122 }
@@ -129,7 +134,7 @@ func (w *Workspace) setBorder(b border, pos int) {
129 if !w.validSpans(first, last) {134 if !w.validSpans(first, last) {
130 used := 0135 used := 0
131 for i := first; i <= last; i++ {136 for i := first; i <= last; i++ {
132- w.Columns[i].Span = max(pixelShare(rs[i].W, avail), 1)137+ w.Columns[i].Span = max((rs[i].W*10000+5000)/avail, 1)
133 w.Columns[i].spanLane = last - first + 1138 w.Columns[i].spanLane = last - first + 1
134 used += w.Columns[i].Span139 used += w.Columns[i].Span
135 }140 }
@@ -148,6 +153,9 @@ func (w *Workspace) setBorder(b border, pos int) {
148 return153 return
149 }154 }
150 target = min(max(target, minimum), pair-minimum)155 target = min(max(target, minimum), pair-minimum)
156+ if b.col+1 < last {
157+ target = pairPixelShare(target, pair, avail, rs[b.col].W+rs[b.col+1].W, minimum)
158+ }
151 w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target159 w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target
152 }160 }
153 161
@@ -162,3 +170,17 @@ func (w *Workspace) resetBorder(b border) {
162 w.Columns[b.col].Shares = nil170 w.Columns[b.col].Shares = nil
163 }171 }
164 }172 }
173+
174+// pairPixelShare keeps the pair's combined integer width unchanged as well as
175+// its basis-point sum. Independent floors can otherwise shift later tiles by
176+// one pixel even when the sum of the two shares is constant.
177+func pairPixelShare(target, pair, avail, pixels, minimum int) int {
178+ left := avail * target / 10000
179+ right := pixels - left
180+ lo := max(pixelShare(left, avail), pair-pixelShare(right+1, avail)+1, minimum)
181+ hi := min(pixelShare(left+1, avail)-1, pair-pixelShare(right, avail), pair-minimum)
182+ if lo <= hi {
183+ return min(max(target, lo), hi)
184+ }
185+ return target
186+}
M internal/core/border_test.go +17 -0
@@ -176,3 +176,20 @@ func TestSetRowBorder(t *testing.T) {
176176 t.Fatal("reset")
177177 }
178178 }
179+
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)
192+ }
193+ }
194+ }
195+}
@@ -176,3 +176,20 @@ func TestSetRowBorder(t *testing.T) {
176 t.Fatal("reset")176 t.Fatal("reset")
177 }177 }
178 }178 }
179+
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)
192+ }
193+ }
194+ }
195+}

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