feat(core): add border hit-testing and resize geometry
- commit
- 5932e6bc733d650fd6454e49ba66c92c4ce9eb3e
- author
- brice · · 2026-10-21 05:24 UTC
- committer
- brice · · 2026-10-21 07:11 UTC
- parent
- 4e22582
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +342 -0
A internal/core/border.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | type borderKind int | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | borderColumn borderKind = iota + 1 | |
| 7 | + | borderRow | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // border is an output-local hit zone between consecutive columns or rows. | |
| 11 | + | type border struct { | |
| 12 | + | kind borderKind | |
| 13 | + | col, row int | |
| 14 | + | rect Rect | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // borderMinWidth is the minimum logical pixel width for a border resize. | |
| 18 | + | // The effective minimum also includes 5% of the lane and four border widths. | |
| 19 | + | const borderMinWidth = 48 | |
| 20 | + | ||
| 21 | + | func borderContains(r Rect, x, y int) bool { | |
| 22 | + | return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func borderZone(a, b, pad int) (int, int) { | |
| 26 | + | mid := (a + b) / 2 | |
| 27 | + | lo, hi := min(a, mid-pad), max(b, mid+pad) | |
| 28 | + | return lo, max(hi-lo, 1) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (w *Workspace) borderAt(x, y, pad int) (border, bool) { | |
| 32 | + | if w.fullscreen != 0 { | |
| 33 | + | return border{}, false | |
| 34 | + | } | |
| 35 | + | // Overview and preview are Monitor presentation, not Workspace state. Their | |
| 36 | + | // caller must not ask for workspace borders while presenting those scenes. | |
| 37 | + | for _, p := range w.Layout() { | |
| 38 | + | if p.Floating && !p.Hidden && !p.Below && borderContains(p.Rect, x, y) { | |
| 39 | + | return border{}, false | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | rs := w.columnRects() | |
| 43 | + | fixedFull := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |
| 44 | + | for i, r := range rs { | |
| 45 | + | if fixedFull && i != w.Focus { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | if r.X+r.W <= w.Usable.X || r.X >= w.Usable.X+w.Usable.W || r.Y+r.H <= w.Usable.Y || r.Y >= w.Usable.Y+w.Usable.H { | |
| 49 | + | continue | |
| 50 | + | } | |
| 51 | + | rows := rowRects(r, w.Columns[i], w.gap()) | |
| 52 | + | for j := 0; j+1 < len(rows); j++ { | |
| 53 | + | lo, size := borderZone(rows[j].Y+rows[j].H, rows[j+1].Y, pad) | |
| 54 | + | b := border{kind: borderRow, col: i, row: j, rect: Rect{X: r.X, Y: lo, W: r.W, H: size}} | |
| 55 | + | if borderContains(b.rect, x, y) { | |
| 56 | + | return b, true | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | if fixedFull || w.Overflow == OverflowFixed && !w.laneRow() || i+1 >= len(rs) || w.laneOf(i) != w.laneOf(i+1) || w.Columns[i].FullWidth || w.Columns[i+1].FullWidth { | |
| 60 | + | continue | |
| 61 | + | } | |
| 62 | + | next := rs[i+1] | |
| 63 | + | if next.X >= w.Usable.X+w.Usable.W || next.X+next.W <= w.Usable.X { | |
| 64 | + | continue | |
| 65 | + | } | |
| 66 | + | top, bottom := max(r.Y, next.Y), min(r.Y+r.H, next.Y+next.H) | |
| 67 | + | lo, size := borderZone(r.X+r.W, next.X, pad) | |
| 68 | + | b := border{kind: borderColumn, col: i, rect: Rect{X: lo, Y: top, W: size, H: max(bottom-top, 0)}} | |
| 69 | + | if borderContains(b.rect, x, y) { | |
| 70 | + | return b, true | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | return border{}, false | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // pixelShare rounds upward so a stamped width reproduces its integer pixels. | |
| 77 | + | func pixelShare(pixels, total int) int { | |
| 78 | + | if total <= 0 { | |
| 79 | + | return 0 | |
| 80 | + | } | |
| 81 | + | return (pixels*10000 + total - 1) / total | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (w *Workspace) setBorder(b border, pos int) { | |
| 85 | + | if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | rs := w.columnRects() | |
| 89 | + | r := rs[b.col] | |
| 90 | + | if b.kind == borderRow { | |
| 91 | + | c := &w.Columns[b.col] | |
| 92 | + | if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | rows := rowRects(r, *c, w.gap()) | |
| 96 | + | avail := max(r.H-(len(rows)-1)*w.gap(), 0) | |
| 97 | + | if avail == 0 { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | if !validShares(*c) { | |
| 101 | + | c.Shares = make([]int, len(rows)) | |
| 102 | + | used := 0 | |
| 103 | + | for i := range rows { | |
| 104 | + | c.Shares[i] = (rows[i].H*10000 + 5000) / avail | |
| 105 | + | used += c.Shares[i] | |
| 106 | + | } | |
| 107 | + | c.Shares[len(rows)-1] += 10000 - used | |
| 108 | + | } | |
| 109 | + | pair := c.Shares[b.row] + c.Shares[b.row+1] | |
| 110 | + | if pair < 2*minShare { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail) | |
| 114 | + | target = min(max(target, minShare), pair-minShare) | |
| 115 | + | c.Shares[b.row], c.Shares[b.row+1] = target, pair-target | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | 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 { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | first, last := w.laneBounds(w.laneOf(b.col)) | |
| 122 | + | avail := max(w.Usable.W-2*w.gap(), 0) | |
| 123 | + | if w.policy().equalCells { | |
| 124 | + | avail = max(w.Usable.W-w.gap()*(last-first+2), 0) | |
| 125 | + | } | |
| 126 | + | if avail == 0 { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | if !w.validSpans(first, last) { | |
| 130 | + | used := 0 | |
| 131 | + | for i := first; i <= last; i++ { | |
| 132 | + | w.Columns[i].Span = max(pixelShare(rs[i].W, avail), 1) | |
| 133 | + | w.Columns[i].spanLane = last - first + 1 | |
| 134 | + | used += w.Columns[i].Span | |
| 135 | + | } | |
| 136 | + | if w.policy().equalCells { | |
| 137 | + | w.Columns[last].Span += 10000 - used | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail) | |
| 141 | + | target := pixelShare(pos-w.gap()/2-r.X, avail) | |
| 142 | + | if !w.policy().equalCells { | |
| 143 | + | w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span | |
| 147 | + | if pair < 2*minimum { | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | target = min(max(target, minimum), pair-minimum) | |
| 151 | + | w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (w *Workspace) resetBorder(b border) { | |
| 155 | + | if b.col < 0 || b.col >= len(w.Columns) { | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | switch b.kind { | |
| 159 | + | case borderColumn: | |
| 160 | + | w.clearSpans(b.col) | |
| 161 | + | case borderRow: | |
| 162 | + | w.Columns[b.col].Shares = nil | |
| 163 | + | } | |
| 164 | + | } | |
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | type borderKind int | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | borderColumn borderKind = iota + 1 | |||
| 7 | + | borderRow | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // border is an output-local hit zone between consecutive columns or rows. | |||
| 11 | + | type border struct { | |||
| 12 | + | kind borderKind | |||
| 13 | + | col, row int | |||
| 14 | + | rect Rect | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // borderMinWidth is the minimum logical pixel width for a border resize. | |||
| 18 | + | // The effective minimum also includes 5% of the lane and four border widths. | |||
| 19 | + | const borderMinWidth = 48 | |||
| 20 | + | ||||
| 21 | + | func borderContains(r Rect, x, y int) bool { | |||
| 22 | + | return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func borderZone(a, b, pad int) (int, int) { | |||
| 26 | + | mid := (a + b) / 2 | |||
| 27 | + | lo, hi := min(a, mid-pad), max(b, mid+pad) | |||
| 28 | + | return lo, max(hi-lo, 1) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (w *Workspace) borderAt(x, y, pad int) (border, bool) { | |||
| 32 | + | if w.fullscreen != 0 { | |||
| 33 | + | return border{}, false | |||
| 34 | + | } | |||
| 35 | + | // Overview and preview are Monitor presentation, not Workspace state. Their | |||
| 36 | + | // caller must not ask for workspace borders while presenting those scenes. | |||
| 37 | + | for _, p := range w.Layout() { | |||
| 38 | + | if p.Floating && !p.Hidden && !p.Below && borderContains(p.Rect, x, y) { | |||
| 39 | + | return border{}, false | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | rs := w.columnRects() | |||
| 43 | + | fixedFull := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |||
| 44 | + | for i, r := range rs { | |||
| 45 | + | if fixedFull && i != w.Focus { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | if r.X+r.W <= w.Usable.X || r.X >= w.Usable.X+w.Usable.W || r.Y+r.H <= w.Usable.Y || r.Y >= w.Usable.Y+w.Usable.H { | |||
| 49 | + | continue | |||
| 50 | + | } | |||
| 51 | + | rows := rowRects(r, w.Columns[i], w.gap()) | |||
| 52 | + | for j := 0; j+1 < len(rows); j++ { | |||
| 53 | + | lo, size := borderZone(rows[j].Y+rows[j].H, rows[j+1].Y, pad) | |||
| 54 | + | b := border{kind: borderRow, col: i, row: j, rect: Rect{X: r.X, Y: lo, W: r.W, H: size}} | |||
| 55 | + | if borderContains(b.rect, x, y) { | |||
| 56 | + | return b, true | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | if fixedFull || w.Overflow == OverflowFixed && !w.laneRow() || i+1 >= len(rs) || w.laneOf(i) != w.laneOf(i+1) || w.Columns[i].FullWidth || w.Columns[i+1].FullWidth { | |||
| 60 | + | continue | |||
| 61 | + | } | |||
| 62 | + | next := rs[i+1] | |||
| 63 | + | if next.X >= w.Usable.X+w.Usable.W || next.X+next.W <= w.Usable.X { | |||
| 64 | + | continue | |||
| 65 | + | } | |||
| 66 | + | top, bottom := max(r.Y, next.Y), min(r.Y+r.H, next.Y+next.H) | |||
| 67 | + | lo, size := borderZone(r.X+r.W, next.X, pad) | |||
| 68 | + | b := border{kind: borderColumn, col: i, rect: Rect{X: lo, Y: top, W: size, H: max(bottom-top, 0)}} | |||
| 69 | + | if borderContains(b.rect, x, y) { | |||
| 70 | + | return b, true | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | return border{}, false | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // pixelShare rounds upward so a stamped width reproduces its integer pixels. | |||
| 77 | + | func pixelShare(pixels, total int) int { | |||
| 78 | + | if total <= 0 { | |||
| 79 | + | return 0 | |||
| 80 | + | } | |||
| 81 | + | return (pixels*10000 + total - 1) / total | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (w *Workspace) setBorder(b border, pos int) { | |||
| 85 | + | if b.col < 0 || b.col >= len(w.Columns) || w.fullscreen != 0 { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | rs := w.columnRects() | |||
| 89 | + | r := rs[b.col] | |||
| 90 | + | if b.kind == borderRow { | |||
| 91 | + | c := &w.Columns[b.col] | |||
| 92 | + | if b.row < 0 || b.row+1 >= len(c.Windows) || len(c.Windows)*minShare > 10000 { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | rows := rowRects(r, *c, w.gap()) | |||
| 96 | + | avail := max(r.H-(len(rows)-1)*w.gap(), 0) | |||
| 97 | + | if avail == 0 { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | if !validShares(*c) { | |||
| 101 | + | c.Shares = make([]int, len(rows)) | |||
| 102 | + | used := 0 | |||
| 103 | + | for i := range rows { | |||
| 104 | + | c.Shares[i] = (rows[i].H*10000 + 5000) / avail | |||
| 105 | + | used += c.Shares[i] | |||
| 106 | + | } | |||
| 107 | + | c.Shares[len(rows)-1] += 10000 - used | |||
| 108 | + | } | |||
| 109 | + | pair := c.Shares[b.row] + c.Shares[b.row+1] | |||
| 110 | + | if pair < 2*minShare { | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | target := pixelShare(pos-w.gap()/2-rows[b.row].Y, avail) | |||
| 114 | + | target = min(max(target, minShare), pair-minShare) | |||
| 115 | + | c.Shares[b.row], c.Shares[b.row+1] = target, pair-target | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | 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 { | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | first, last := w.laneBounds(w.laneOf(b.col)) | |||
| 122 | + | avail := max(w.Usable.W-2*w.gap(), 0) | |||
| 123 | + | if w.policy().equalCells { | |||
| 124 | + | avail = max(w.Usable.W-w.gap()*(last-first+2), 0) | |||
| 125 | + | } | |||
| 126 | + | if avail == 0 { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | if !w.validSpans(first, last) { | |||
| 130 | + | used := 0 | |||
| 131 | + | for i := first; i <= last; i++ { | |||
| 132 | + | w.Columns[i].Span = max(pixelShare(rs[i].W, avail), 1) | |||
| 133 | + | w.Columns[i].spanLane = last - first + 1 | |||
| 134 | + | used += w.Columns[i].Span | |||
| 135 | + | } | |||
| 136 | + | if w.policy().equalCells { | |||
| 137 | + | w.Columns[last].Span += 10000 - used | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | minimum := pixelShare(max(avail/20, 4*w.border, borderMinWidth), avail) | |||
| 141 | + | target := pixelShare(pos-w.gap()/2-r.X, avail) | |||
| 142 | + | if !w.policy().equalCells { | |||
| 143 | + | w.Columns[b.col].Span = min(max(target, min(minimum, 10000)), 10000) | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | pair := w.Columns[b.col].Span + w.Columns[b.col+1].Span | |||
| 147 | + | if pair < 2*minimum { | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | target = min(max(target, minimum), pair-minimum) | |||
| 151 | + | w.Columns[b.col].Span, w.Columns[b.col+1].Span = target, pair-target | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (w *Workspace) resetBorder(b border) { | |||
| 155 | + | if b.col < 0 || b.col >= len(w.Columns) { | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | switch b.kind { | |||
| 159 | + | case borderColumn: | |||
| 160 | + | w.clearSpans(b.col) | |||
| 161 | + | case borderRow: | |||
| 162 | + | w.Columns[b.col].Shares = nil | |||
| 163 | + | } | |||
| 164 | + | } | |||
A internal/core/border_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestBorderAt(t *testing.T) { | |
| 9 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |
| 10 | + | for _, gap := range []int{0, 10} { | |
| 11 | + | w := spanWorkspace(mode, 2) | |
| 12 | + | w.SetGaps(gap) | |
| 13 | + | w.View = 0 | |
| 14 | + | w.view = slide{} | |
| 15 | + | rs := w.columnRects() | |
| 16 | + | x := rs[0].X + rs[0].W + gap/2 | |
| 17 | + | y := rs[0].Y + 100 | |
| 18 | + | b, ok := w.borderAt(x, y, 6) | |
| 19 | + | if !ok || b.kind != borderColumn || b.col != 0 { | |
| 20 | + | t.Fatal(mode, gap, b, ok) | |
| 21 | + | } | |
| 22 | + | if _, ok = w.borderAt(x-5, y, 6); !ok { | |
| 23 | + | t.Fatal("padding") | |
| 24 | + | } | |
| 25 | + | if _, ok = w.borderAt(rs[0].X+20, y, 6); ok { | |
| 26 | + | t.Fatal("tile interior") | |
| 27 | + | } | |
| 28 | + | w.Columns[0].Windows = append(w.Columns[0].Windows, 9) | |
| 29 | + | rows := rowRects(rs[0], w.Columns[0], gap) | |
| 30 | + | b, ok = w.borderAt(rs[0].X+20, rows[0].Y+rows[0].H+gap/2, 6) | |
| 31 | + | if !ok || b.kind != borderRow { | |
| 32 | + | t.Fatal("row", b, ok) | |
| 33 | + | } | |
| 34 | + | w.AddFloating(10, 400, 400) | |
| 35 | + | f := w.floatRect(w.Floats[0]) | |
| 36 | + | if _, ok = w.borderAt(f.X+f.W/2, f.Y+f.H/2, 6); ok { | |
| 37 | + | t.Fatal("float") | |
| 38 | + | } | |
| 39 | + | w.FocusID(1) | |
| 40 | + | w.SetFullscreen(1, true) | |
| 41 | + | if _, ok = w.borderAt(x, y, 6); ok { | |
| 42 | + | t.Fatal("fullscreen") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | w := spanWorkspace(OverflowFixed, 4) | |
| 47 | + | rs := w.columnRects() | |
| 48 | + | if _, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 8); ok { | |
| 49 | + | t.Fatal("spiral") | |
| 50 | + | } | |
| 51 | + | w = spanWorkspace(OverflowFixed, 2) | |
| 52 | + | w.Columns[w.Focus].FullWidth = true | |
| 53 | + | if _, ok := w.borderAt(500, 100, 8); ok { | |
| 54 | + | t.Fatal("maximized") | |
| 55 | + | } | |
| 56 | + | w = spanWorkspace(OverflowCascade, 4) | |
| 57 | + | rs = w.columnRects() | |
| 58 | + | if _, ok := w.borderAt(rs[2].X+rs[2].W+5, 100, 8); ok { | |
| 59 | + | t.Fatal("different bands") | |
| 60 | + | } | |
| 61 | + | // Presentation offsets must be applied just like Layout. | |
| 62 | + | w = spanWorkspace(OverflowScroll, 4) | |
| 63 | + | w.View = 100 | |
| 64 | + | rs = w.columnRects() | |
| 65 | + | b, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 6) | |
| 66 | + | if !ok || b.col != 0 { | |
| 67 | + | t.Fatal("view offset", b, ok) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestSetColumnBorder(t *testing.T) { | |
| 72 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade} { | |
| 73 | + | for _, n := range []int{2, 3} { | |
| 74 | + | w := spanWorkspace(mode, n) | |
| 75 | + | before := w.columnRects() | |
| 76 | + | b := border{kind: borderColumn, col: 0} | |
| 77 | + | pos := before[0].X + before[0].W + 5 + 50 | |
| 78 | + | w.setBorder(b, pos) | |
| 79 | + | after := w.columnRects() | |
| 80 | + | if after[0].X+after[0].W+5 != pos { | |
| 81 | + | t.Fatal("position", mode, n, pos, after) | |
| 82 | + | } | |
| 83 | + | if n == 3 && after[2] != before[2] { | |
| 84 | + | t.Fatal("third moved", before, after) | |
| 85 | + | } | |
| 86 | + | if after[n-1].X+after[n-1].W != 990 { | |
| 87 | + | t.Fatal("not pixel filled", after) | |
| 88 | + | } | |
| 89 | + | total := 0 | |
| 90 | + | for _, c := range w.Columns { | |
| 91 | + | total += c.Span | |
| 92 | + | } | |
| 93 | + | if total != 10000 { | |
| 94 | + | t.Fatal(total) | |
| 95 | + | } | |
| 96 | + | pair := w.Columns[0].Span + w.Columns[1].Span | |
| 97 | + | w.setBorder(b, -1000) | |
| 98 | + | after = w.columnRects() | |
| 99 | + | if after[0].W < 48 || w.Columns[0].Span+w.Columns[1].Span != pair { | |
| 100 | + | t.Fatal("minimum", after) | |
| 101 | + | } | |
| 102 | + | w.setBorder(b, 10000) | |
| 103 | + | after = w.columnRects() | |
| 104 | + | if after[1].W < 48 { | |
| 105 | + | t.Fatal("right minimum", after) | |
| 106 | + | } | |
| 107 | + | w.resetBorder(b) | |
| 108 | + | for _, c := range w.Columns { | |
| 109 | + | if c.Span != 0 || c.spanLane != 0 { | |
| 110 | + | t.Fatal("reset") | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestSetBorderCascadeBand(t *testing.T) { | |
| 118 | + | w := spanWorkspace(OverflowCascade, 5) | |
| 119 | + | before := w.columnRects() | |
| 120 | + | w.setBorder(border{kind: borderColumn, col: 0}, 400) | |
| 121 | + | after := w.columnRects() | |
| 122 | + | if !slices.Equal(before[3:], after[3:]) || w.Columns[3].Span != 0 { | |
| 123 | + | t.Fatal("other band changed", before, after) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestSetBorderScroll(t *testing.T) { | |
| 128 | + | w := spanWorkspace(OverflowScroll, 3) | |
| 129 | + | before := w.columnRects() | |
| 130 | + | b := border{kind: borderColumn, col: 0} | |
| 131 | + | pos := before[0].X + before[0].W + 5 + 60 | |
| 132 | + | w.setBorder(b, pos) | |
| 133 | + | after := w.columnRects() | |
| 134 | + | if after[0].X+after[0].W+5 != pos { | |
| 135 | + | t.Fatal("position", after) | |
| 136 | + | } | |
| 137 | + | for i := 1; i < len(after); i++ { | |
| 138 | + | if after[i].W != before[i].W || after[i].X != before[i].X+60 { | |
| 139 | + | t.Fatal("right columns", before, after) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | saved := w.Columns[1].Span | |
| 143 | + | w.setBorder(b, 10000) | |
| 144 | + | if w.columnRects()[0].W != 980 || w.Columns[1].Span != saved { | |
| 145 | + | t.Fatal("maximum") | |
| 146 | + | } | |
| 147 | + | w.setBorder(b, -10000) | |
| 148 | + | if w.columnRects()[0].W < 49 { | |
| 149 | + | t.Fatal("minimum") | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestSetRowBorder(t *testing.T) { | |
| 154 | + | w := spanWorkspace(OverflowFixed, 2) | |
| 155 | + | w.Columns[0].Windows = []WindowID{1, 3, 4} | |
| 156 | + | r := w.columnRects()[0] | |
| 157 | + | before := rowRects(r, w.Columns[0], w.gap()) | |
| 158 | + | b := border{kind: borderRow, col: 0, row: 0} | |
| 159 | + | pos := before[0].Y + before[0].H + 5 + 30 | |
| 160 | + | w.setBorder(b, pos) | |
| 161 | + | after := rowRects(r, w.Columns[0], w.gap()) | |
| 162 | + | if after[0].Y+after[0].H+5 != pos || after[2] != before[2] || sum(w.Columns[0].Shares) != 10000 { | |
| 163 | + | t.Fatal(before, after, w.Columns[0].Shares) | |
| 164 | + | } | |
| 165 | + | pair := w.Columns[0].Shares[0] + w.Columns[0].Shares[1] | |
| 166 | + | w.setBorder(b, -10000) | |
| 167 | + | if w.Columns[0].Shares[0] != minShare { | |
| 168 | + | t.Fatal("minimum") | |
| 169 | + | } | |
| 170 | + | w.setBorder(b, 10000) | |
| 171 | + | if w.Columns[0].Shares[1] != minShare || w.Columns[0].Shares[0]+w.Columns[0].Shares[1] != pair { | |
| 172 | + | t.Fatal("pair") | |
| 173 | + | } | |
| 174 | + | w.resetBorder(b) | |
| 175 | + | if w.Columns[0].Shares != nil { | |
| 176 | + | t.Fatal("reset") | |
| 177 | + | } | |
| 178 | + | } | |
| @@ -0,0 +1,178 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestBorderAt(t *testing.T) { | |||
| 9 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} { | |||
| 10 | + | for _, gap := range []int{0, 10} { | |||
| 11 | + | w := spanWorkspace(mode, 2) | |||
| 12 | + | w.SetGaps(gap) | |||
| 13 | + | w.View = 0 | |||
| 14 | + | w.view = slide{} | |||
| 15 | + | rs := w.columnRects() | |||
| 16 | + | x := rs[0].X + rs[0].W + gap/2 | |||
| 17 | + | y := rs[0].Y + 100 | |||
| 18 | + | b, ok := w.borderAt(x, y, 6) | |||
| 19 | + | if !ok || b.kind != borderColumn || b.col != 0 { | |||
| 20 | + | t.Fatal(mode, gap, b, ok) | |||
| 21 | + | } | |||
| 22 | + | if _, ok = w.borderAt(x-5, y, 6); !ok { | |||
| 23 | + | t.Fatal("padding") | |||
| 24 | + | } | |||
| 25 | + | if _, ok = w.borderAt(rs[0].X+20, y, 6); ok { | |||
| 26 | + | t.Fatal("tile interior") | |||
| 27 | + | } | |||
| 28 | + | w.Columns[0].Windows = append(w.Columns[0].Windows, 9) | |||
| 29 | + | rows := rowRects(rs[0], w.Columns[0], gap) | |||
| 30 | + | b, ok = w.borderAt(rs[0].X+20, rows[0].Y+rows[0].H+gap/2, 6) | |||
| 31 | + | if !ok || b.kind != borderRow { | |||
| 32 | + | t.Fatal("row", b, ok) | |||
| 33 | + | } | |||
| 34 | + | w.AddFloating(10, 400, 400) | |||
| 35 | + | f := w.floatRect(w.Floats[0]) | |||
| 36 | + | if _, ok = w.borderAt(f.X+f.W/2, f.Y+f.H/2, 6); ok { | |||
| 37 | + | t.Fatal("float") | |||
| 38 | + | } | |||
| 39 | + | w.FocusID(1) | |||
| 40 | + | w.SetFullscreen(1, true) | |||
| 41 | + | if _, ok = w.borderAt(x, y, 6); ok { | |||
| 42 | + | t.Fatal("fullscreen") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | w := spanWorkspace(OverflowFixed, 4) | |||
| 47 | + | rs := w.columnRects() | |||
| 48 | + | if _, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 8); ok { | |||
| 49 | + | t.Fatal("spiral") | |||
| 50 | + | } | |||
| 51 | + | w = spanWorkspace(OverflowFixed, 2) | |||
| 52 | + | w.Columns[w.Focus].FullWidth = true | |||
| 53 | + | if _, ok := w.borderAt(500, 100, 8); ok { | |||
| 54 | + | t.Fatal("maximized") | |||
| 55 | + | } | |||
| 56 | + | w = spanWorkspace(OverflowCascade, 4) | |||
| 57 | + | rs = w.columnRects() | |||
| 58 | + | if _, ok := w.borderAt(rs[2].X+rs[2].W+5, 100, 8); ok { | |||
| 59 | + | t.Fatal("different bands") | |||
| 60 | + | } | |||
| 61 | + | // Presentation offsets must be applied just like Layout. | |||
| 62 | + | w = spanWorkspace(OverflowScroll, 4) | |||
| 63 | + | w.View = 100 | |||
| 64 | + | rs = w.columnRects() | |||
| 65 | + | b, ok := w.borderAt(rs[0].X+rs[0].W+5, 100, 6) | |||
| 66 | + | if !ok || b.col != 0 { | |||
| 67 | + | t.Fatal("view offset", b, ok) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestSetColumnBorder(t *testing.T) { | |||
| 72 | + | for _, mode := range []Overflow{OverflowFixed, OverflowCascade} { | |||
| 73 | + | for _, n := range []int{2, 3} { | |||
| 74 | + | w := spanWorkspace(mode, n) | |||
| 75 | + | before := w.columnRects() | |||
| 76 | + | b := border{kind: borderColumn, col: 0} | |||
| 77 | + | pos := before[0].X + before[0].W + 5 + 50 | |||
| 78 | + | w.setBorder(b, pos) | |||
| 79 | + | after := w.columnRects() | |||
| 80 | + | if after[0].X+after[0].W+5 != pos { | |||
| 81 | + | t.Fatal("position", mode, n, pos, after) | |||
| 82 | + | } | |||
| 83 | + | if n == 3 && after[2] != before[2] { | |||
| 84 | + | t.Fatal("third moved", before, after) | |||
| 85 | + | } | |||
| 86 | + | if after[n-1].X+after[n-1].W != 990 { | |||
| 87 | + | t.Fatal("not pixel filled", after) | |||
| 88 | + | } | |||
| 89 | + | total := 0 | |||
| 90 | + | for _, c := range w.Columns { | |||
| 91 | + | total += c.Span | |||
| 92 | + | } | |||
| 93 | + | if total != 10000 { | |||
| 94 | + | t.Fatal(total) | |||
| 95 | + | } | |||
| 96 | + | pair := w.Columns[0].Span + w.Columns[1].Span | |||
| 97 | + | w.setBorder(b, -1000) | |||
| 98 | + | after = w.columnRects() | |||
| 99 | + | if after[0].W < 48 || w.Columns[0].Span+w.Columns[1].Span != pair { | |||
| 100 | + | t.Fatal("minimum", after) | |||
| 101 | + | } | |||
| 102 | + | w.setBorder(b, 10000) | |||
| 103 | + | after = w.columnRects() | |||
| 104 | + | if after[1].W < 48 { | |||
| 105 | + | t.Fatal("right minimum", after) | |||
| 106 | + | } | |||
| 107 | + | w.resetBorder(b) | |||
| 108 | + | for _, c := range w.Columns { | |||
| 109 | + | if c.Span != 0 || c.spanLane != 0 { | |||
| 110 | + | t.Fatal("reset") | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestSetBorderCascadeBand(t *testing.T) { | |||
| 118 | + | w := spanWorkspace(OverflowCascade, 5) | |||
| 119 | + | before := w.columnRects() | |||
| 120 | + | w.setBorder(border{kind: borderColumn, col: 0}, 400) | |||
| 121 | + | after := w.columnRects() | |||
| 122 | + | if !slices.Equal(before[3:], after[3:]) || w.Columns[3].Span != 0 { | |||
| 123 | + | t.Fatal("other band changed", before, after) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestSetBorderScroll(t *testing.T) { | |||
| 128 | + | w := spanWorkspace(OverflowScroll, 3) | |||
| 129 | + | before := w.columnRects() | |||
| 130 | + | b := border{kind: borderColumn, col: 0} | |||
| 131 | + | pos := before[0].X + before[0].W + 5 + 60 | |||
| 132 | + | w.setBorder(b, pos) | |||
| 133 | + | after := w.columnRects() | |||
| 134 | + | if after[0].X+after[0].W+5 != pos { | |||
| 135 | + | t.Fatal("position", after) | |||
| 136 | + | } | |||
| 137 | + | for i := 1; i < len(after); i++ { | |||
| 138 | + | if after[i].W != before[i].W || after[i].X != before[i].X+60 { | |||
| 139 | + | t.Fatal("right columns", before, after) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | saved := w.Columns[1].Span | |||
| 143 | + | w.setBorder(b, 10000) | |||
| 144 | + | if w.columnRects()[0].W != 980 || w.Columns[1].Span != saved { | |||
| 145 | + | t.Fatal("maximum") | |||
| 146 | + | } | |||
| 147 | + | w.setBorder(b, -10000) | |||
| 148 | + | if w.columnRects()[0].W < 49 { | |||
| 149 | + | t.Fatal("minimum") | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestSetRowBorder(t *testing.T) { | |||
| 154 | + | w := spanWorkspace(OverflowFixed, 2) | |||
| 155 | + | w.Columns[0].Windows = []WindowID{1, 3, 4} | |||
| 156 | + | r := w.columnRects()[0] | |||
| 157 | + | before := rowRects(r, w.Columns[0], w.gap()) | |||
| 158 | + | b := border{kind: borderRow, col: 0, row: 0} | |||
| 159 | + | pos := before[0].Y + before[0].H + 5 + 30 | |||
| 160 | + | w.setBorder(b, pos) | |||
| 161 | + | after := rowRects(r, w.Columns[0], w.gap()) | |||
| 162 | + | if after[0].Y+after[0].H+5 != pos || after[2] != before[2] || sum(w.Columns[0].Shares) != 10000 { | |||
| 163 | + | t.Fatal(before, after, w.Columns[0].Shares) | |||
| 164 | + | } | |||
| 165 | + | pair := w.Columns[0].Shares[0] + w.Columns[0].Shares[1] | |||
| 166 | + | w.setBorder(b, -10000) | |||
| 167 | + | if w.Columns[0].Shares[0] != minShare { | |||
| 168 | + | t.Fatal("minimum") | |||
| 169 | + | } | |||
| 170 | + | w.setBorder(b, 10000) | |||
| 171 | + | if w.Columns[0].Shares[1] != minShare || w.Columns[0].Shares[0]+w.Columns[0].Shares[1] != pair { | |||
| 172 | + | t.Fatal("pair") | |||
| 173 | + | } | |||
| 174 | + | w.resetBorder(b) | |||
| 175 | + | if w.Columns[0].Shares != nil { | |||
| 176 | + | t.Fatal("reset") | |||
| 177 | + | } | |||
| 178 | + | } | |||