detached 5932e6b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+}

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