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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 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 | 33 | return border{}, false | |
| 34 | 34 | } | |
| 35 | 35 | // Overview and preview are Monitor presentation, not Workspace state. Their | |
| @@ -101,6 +101,8 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 101 | 101 | c.Shares = make([]int, len(rows)) | |
| 102 | 102 | used := 0 | |
| 103 | 103 | for i := range rows { | |
| 104 | + | // Stamp the middle of the pixel bucket, leaving room for | |
| 105 | + | // pair-preserving rounding on subsequent resizes. | |
| 104 | 106 | c.Shares[i] = (rows[i].H*10000 + 5000) / avail | |
| 105 | 107 | used += c.Shares[i] | |
| 106 | 108 | } | |
| @@ -112,6 +114,9 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 112 | 114 | } | |
| 113 | 115 | target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail) | |
| 114 | 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 | 120 | c.Shares[b.row], c.Shares[b.row+1] = target, pair-target | |
| 116 | 121 | return | |
| 117 | 122 | } | |
| @@ -129,7 +134,7 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 129 | 134 | if !w.validSpans(first, last) { | |
| 130 | 135 | used := 0 | |
| 131 | 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 | 138 | w.Columns[i].spanLane = last - first + 1 | |
| 134 | 139 | used += w.Columns[i].Span | |
| 135 | 140 | } | |
| @@ -148,6 +153,9 @@ func (w *Workspace) setBorder(b border, pos int) { | |||
| 148 | 153 | return | |
| 149 | 154 | } | |
| 150 | 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 | 159 | 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 | 170 | 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 | + | } | |
| @@ -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{}, false | 33 | return border{}, false | ||
| 34 | } | 34 | } | ||
| 35 | // Overview and preview are Monitor presentation, not Workspace state. Their | 35 | // 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 := 0 | 102 | 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) / avail | 106 | 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-target | 120 | c.Shares[b.row], c.Shares[b.row+1] = target, pair-target | ||
| 116 | return | 121 | 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 := 0 | 135 | 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 + 1 | 138 | w.Columns[i].spanLane = last - first + 1 | ||
| 134 | used += w.Columns[i].Span | 139 | used += w.Columns[i].Span | ||
| 135 | } | 140 | } | ||
| @@ -148,6 +153,9 @@ func (w *Workspace) setBorder(b border, pos int) { | |||||
| 148 | return | 153 | 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-target | 159 | 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 = nil | 170 | 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) { | |||
| 176 | 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 | + | } | |
| @@ -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 | + | } | |||