detached 4e22582

feat(core): add lane-scoped column width spans

commit
4e22582251f08f2c232bb5d35baa1088b20dd40e
author
brice · · 2026-10-21 05:24 UTC
committer
brice · · 2026-10-21 07:11 UTC
parent
d458689
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +205 -0
M internal/core/cascade.go +11 -0
@@ -143,6 +143,17 @@ func (w *Workspace) laneWeight(i, S, u int) int {
143143 // stats of laneStats: the usable width after gaps, split by weight. The
144144 // remainder under one pixel per weight stays empty, as in equalShare.
145145 func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
146+ if w.validSpans(first, last) {
147+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148+ if i == last {
149+ used := 0
150+ for j := first; j < last; j++ {
151+ used += avail * w.Columns[j].Span / 10000
152+ }
153+ return avail - used
154+ }
155+ return avail * w.Columns[i].Span / 10000
156+ }
146157 if sum <= 0 {
147158 return 0
148159 }
@@ -143,6 +143,17 @@ func (w *Workspace) laneWeight(i, S, u int) int {
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 // remainder under one pixel per weight stays empty, as in equalShare.
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) {
147+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
148+ if i == last {
149+ used := 0
150+ for j := first; j < last; j++ {
151+ used += avail * w.Columns[j].Span / 10000
152+ }
153+ return avail - used
154+ }
155+ return avail * w.Columns[i].Span / 10000
156+ }
146 if sum <= 0 {157 if sum <= 0 {
147 return 0158 return 0
148 }159 }
M internal/core/layout.go +10 -0
@@ -19,6 +19,9 @@ const maxCells = 16
1919 // max-columns-th of the usable width. Zero (auto) shares the width equally
2020 // with the other auto columns, up to MaxColumns visible at once.
2121 type Column struct {
22+ // Span is a manually sized width in basis points; zero uses Cells.
23+ Span int
24+ spanLane int
2225 Windows []WindowID
2326 Cells int
2427 FullWidth bool
@@ -534,6 +537,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534537 at = min(w.Focus+1, len(w.Columns))
535538 }
536539 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
540+ w.dropStaleSpans()
537541 return
538542 }
539543 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
@@ -1051,6 +1055,7 @@ func (w *Workspace) GrowColumn(cycle bool) {
10511055 return
10521056 }
10531057 defer w.scroll()
1058+ w.clearSpans(w.Focus)
10541059 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
10551060 w.unmaximizeBand()
10561061 }
@@ -1120,6 +1125,7 @@ func (w *Workspace) ShrinkColumn() {
11201125 return
11211126 }
11221127 defer w.scroll()
1128+ w.clearSpans(w.Focus)
11231129 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
11241130 w.unmaximizeBand()
11251131 }
@@ -1667,6 +1673,9 @@ func (w *Workspace) scrollWidth(i int) int {
16671673 // Fixed overflow past MaxColumns: the spiral's slots.
16681674 return w.equalShare(k)
16691675 }
1676+ if w.validSpans(0, len(w.Columns)-1) {
1677+ return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1678+ }
16701679 c := w.Columns[i].Cells
16711680 if c <= 0 {
16721681 return w.equalShare(min(len(w.Columns), k))
@@ -1682,6 +1691,7 @@ func (w *Workspace) columnX(i int) int {
16821691 return x
16831692 }
16841693 func (w *Workspace) scroll() {
1694+ w.dropStaleSpans()
16851695 before := w.View
16861696 defer func() { w.retarget(before) }()
16871697 if w.policy().wraps {
@@ -19,6 +19,9 @@ const maxCells = 16
19 // max-columns-th of the usable width. Zero (auto) shares the width equally19 // max-columns-th of the usable width. Zero (auto) shares the width equally
20 // with the other auto columns, up to MaxColumns visible at once.20 // with the other auto columns, up to MaxColumns visible at once.
21 type Column struct {21 type Column struct {
22+ // Span is a manually sized width in basis points; zero uses Cells.
23+ Span int
24+ spanLane int
22 Windows []WindowID25 Windows []WindowID
23 Cells int26 Cells int
24 FullWidth bool27 FullWidth bool
@@ -534,6 +537,7 @@ func (w *Workspace) addWindow(id WindowID, cells int) {
534 at = min(w.Focus+1, len(w.Columns))537 at = min(w.Focus+1, len(w.Columns))
535 }538 }
536 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})539 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
540+ w.dropStaleSpans()
537 return541 return
538 }542 }
539 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})543 w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
@@ -1051,6 +1055,7 @@ func (w *Workspace) GrowColumn(cycle bool) {
1051 return1055 return
1052 }1056 }
1053 defer w.scroll()1057 defer w.scroll()
1058+ w.clearSpans(w.Focus)
1054 if w.policy().wraps && w.Columns[w.Focus].FullWidth {1059 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1055 w.unmaximizeBand()1060 w.unmaximizeBand()
1056 }1061 }
@@ -1120,6 +1125,7 @@ func (w *Workspace) ShrinkColumn() {
1120 return1125 return
1121 }1126 }
1122 defer w.scroll()1127 defer w.scroll()
1128+ w.clearSpans(w.Focus)
1123 if w.policy().wraps && w.Columns[w.Focus].FullWidth {1129 if w.policy().wraps && w.Columns[w.Focus].FullWidth {
1124 w.unmaximizeBand()1130 w.unmaximizeBand()
1125 }1131 }
@@ -1667,6 +1673,9 @@ func (w *Workspace) scrollWidth(i int) int {
1667 // Fixed overflow past MaxColumns: the spiral's slots.1673 // Fixed overflow past MaxColumns: the spiral's slots.
1668 return w.equalShare(k)1674 return w.equalShare(k)
1669 }1675 }
1676+ if w.validSpans(0, len(w.Columns)-1) {
1677+ return w.Columns[i].Span * max(w.Usable.W-2*w.gap(), 0) / 10000
1678+ }
1670 c := w.Columns[i].Cells1679 c := w.Columns[i].Cells
1671 if c <= 0 {1680 if c <= 0 {
1672 return w.equalShare(min(len(w.Columns), k))1681 return w.equalShare(min(len(w.Columns), k))
@@ -1682,6 +1691,7 @@ func (w *Workspace) columnX(i int) int {
1682 return x1691 return x
1683 }1692 }
1684 func (w *Workspace) scroll() {1693 func (w *Workspace) scroll() {
1694+ w.dropStaleSpans()
1685 before := w.View1695 before := w.View
1686 defer func() { w.retarget(before) }()1696 defer func() { w.retarget(before) }()
1687 if w.policy().wraps {1697 if w.policy().wraps {
M internal/core/layout_policy.go +42 -0
@@ -53,3 +53,45 @@ func (w *Workspace) policy() layoutPolicy {
5353 }
5454 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
5555 }
56+
57+// validSpans rejects partial stamps and stamps from a differently sized lane.
58+func (w *Workspace) validSpans(first, last int) bool {
59+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
60+ return false
61+ }
62+ total := 0
63+ for i := first; i <= last; i++ {
64+ c := w.Columns[i]
65+ if c.Span <= 0 || c.spanLane != last-first+1 {
66+ return false
67+ }
68+ total += c.Span
69+ }
70+ return !w.policy().equalCells || total == 10000
71+}
72+
73+// dropStaleSpans runs before scroll measures mutated layout geometry. A band
74+// is independent: invalidating one never discards another band's ratios.
75+func (w *Workspace) dropStaleSpans() {
76+ for first := 0; first < len(w.Columns); {
77+ _, last := w.laneBounds(w.laneOf(first))
78+ if !w.validSpans(first, last) {
79+ for i := first; i <= last; i++ {
80+ w.Columns[i].Span = 0
81+ w.Columns[i].spanLane = 0
82+ }
83+ }
84+ first = last + 1
85+ }
86+}
87+
88+func (w *Workspace) clearSpans(col int) {
89+ if col < 0 || col >= len(w.Columns) {
90+ return
91+ }
92+ first, last := w.laneBounds(w.laneOf(col))
93+ for i := first; i <= last; i++ {
94+ w.Columns[i].Span = 0
95+ w.Columns[i].spanLane = 0
96+ }
97+}
@@ -53,3 +53,45 @@ func (w *Workspace) policy() layoutPolicy {
53 }53 }
54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
55 }55 }
56+
57+// validSpans rejects partial stamps and stamps from a differently sized lane.
58+func (w *Workspace) validSpans(first, last int) bool {
59+ if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
60+ return false
61+ }
62+ total := 0
63+ for i := first; i <= last; i++ {
64+ c := w.Columns[i]
65+ if c.Span <= 0 || c.spanLane != last-first+1 {
66+ return false
67+ }
68+ total += c.Span
69+ }
70+ return !w.policy().equalCells || total == 10000
71+}
72+
73+// dropStaleSpans runs before scroll measures mutated layout geometry. A band
74+// is independent: invalidating one never discards another band's ratios.
75+func (w *Workspace) dropStaleSpans() {
76+ for first := 0; first < len(w.Columns); {
77+ _, last := w.laneBounds(w.laneOf(first))
78+ if !w.validSpans(first, last) {
79+ for i := first; i <= last; i++ {
80+ w.Columns[i].Span = 0
81+ w.Columns[i].spanLane = 0
82+ }
83+ }
84+ first = last + 1
85+ }
86+}
87+
88+func (w *Workspace) clearSpans(col int) {
89+ if col < 0 || col >= len(w.Columns) {
90+ return
91+ }
92+ first, last := w.laneBounds(w.laneOf(col))
93+ for i := first; i <= last; i++ {
94+ w.Columns[i].Span = 0
95+ w.Columns[i].spanLane = 0
96+ }
97+}
A internal/core/spans_test.go +142 -0
@@ -0,0 +1,142 @@
1+package core
2+
3+import "testing"
4+
5+func spanWorkspace(mode Overflow, n int) *Workspace {
6+ w := workspace()
7+ w.Overflow = mode
8+ w.MaxColumns = 3
9+ w.SetOutput(1000, 800)
10+ w.SetGaps(10)
11+ for i := 0; i < n; i++ {
12+ w.AddWindow(WindowID(i + 1))
13+ }
14+ w.View = 0
15+ w.view = slide{}
16+ return w
17+}
18+
19+func stampSpans(w *Workspace, first, last int) {
20+ for i := first; i <= last; i++ {
21+ w.Columns[i].Span = 10000 / (last - first + 1)
22+ w.Columns[i].spanLane = last - first + 1
23+ }
24+ w.Columns[last].Span += 10000 % (last - first + 1)
25+}
26+
27+func TestSpansMutations(t *testing.T) {
28+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
29+ for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} {
30+ t.Run(string(mode)+"/"+mutation, func(t *testing.T) {
31+ w := spanWorkspace(mode, 2)
32+ stampSpans(w, 0, 1)
33+ switch mutation {
34+ case "add":
35+ w.AddWindow(3)
36+ case "remove":
37+ w.RemoveWindow(1)
38+ case "take":
39+ col, ok := w.takeColumn()
40+ if !ok {
41+ t.Fatal("take")
42+ }
43+ other := spanWorkspace(mode, 1)
44+ other.receive(col, -1)
45+ for _, c := range other.Columns {
46+ if c.Span != 0 {
47+ t.Fatal("destination kept stale span")
48+ }
49+ }
50+ case "receive":
51+ w.receive(Column{Windows: []WindowID{3}}, 1)
52+ case "swap":
53+ w.Focus = 0
54+ w.MoveColumn(1)
55+ case "grow":
56+ w.GrowColumn(false)
57+ case "shrink":
58+ w.ShrinkColumn()
59+ case "action":
60+ w.Apply("set-column-width +1")
61+ }
62+ for _, c := range w.Columns {
63+ if mutation == "swap" {
64+ if c.Span == 0 {
65+ t.Fatal("swap discarded valid spans")
66+ }
67+ } else if c.Span != 0 || c.spanLane != 0 {
68+ t.Fatal("stale span", c)
69+ }
70+ }
71+ })
72+ }
73+ }
74+}
75+
76+func TestSpansCascadeIsolation(t *testing.T) {
77+ w := spanWorkspace(OverflowCascade, 5)
78+ stampSpans(w, 0, 2)
79+ stampSpans(w, 3, 4)
80+ w.RemoveWindow(w.Columns[0].Windows[0])
81+ if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 {
82+ t.Fatal("changed band retained spans")
83+ }
84+ if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 {
85+ t.Fatal("unrelated band reset")
86+ }
87+}
88+
89+func TestSpanWidths(t *testing.T) {
90+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
91+ w := spanWorkspace(mode, 2)
92+ stampSpans(w, 0, 1)
93+ w.Columns[0].Span = 3000
94+ w.Columns[1].Span = 7000
95+ rs := w.columnRects()
96+ avail := 970
97+ if mode == OverflowScroll {
98+ avail = 980
99+ }
100+ if rs[0].W != avail*3/10 {
101+ t.Fatal(mode, rs)
102+ }
103+ w.SetOutput(1400, 800)
104+ if w.columnRects()[0].W != (avail+400)*3/10 {
105+ t.Fatal("ratio lost", mode, w.columnRects())
106+ }
107+ w.Columns[0].FullWidth = true
108+ if w.columnWidth(0) != 1380 {
109+ t.Fatal("special width lost")
110+ }
111+ }
112+ w := spanWorkspace(OverflowFixed, 4)
113+ stampSpans(w, 0, 3)
114+ w.scroll()
115+ for _, c := range w.Columns {
116+ if c.Span != 0 {
117+ t.Fatal("spiral spans")
118+ }
119+ }
120+}
121+
122+func TestSpansDropIntoLane(t *testing.T) {
123+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
124+ w := spanWorkspace(mode, 2)
125+ stampSpans(w, 0, 1)
126+ src := spanWorkspace(mode, 1)
127+ src.Columns[0].Windows[0] = 9
128+ m := monitor()
129+ m.Workspaces = []*Workspace{w, src}
130+ sc := &screen{mon: m}
131+ c := &Core{screens: []*screen{sc}}
132+ c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]})
133+ if len(w.Columns) != 3 {
134+ t.Fatal("not dropped", mode, w.Columns)
135+ }
136+ for _, col := range w.Columns {
137+ if col.Span != 0 || col.spanLane != 0 {
138+ t.Fatal("drop retained stamp", mode, col)
139+ }
140+ }
141+ }
142+}
@@ -0,0 +1,142 @@
1+package core
2+
3+import "testing"
4+
5+func spanWorkspace(mode Overflow, n int) *Workspace {
6+ w := workspace()
7+ w.Overflow = mode
8+ w.MaxColumns = 3
9+ w.SetOutput(1000, 800)
10+ w.SetGaps(10)
11+ for i := 0; i < n; i++ {
12+ w.AddWindow(WindowID(i + 1))
13+ }
14+ w.View = 0
15+ w.view = slide{}
16+ return w
17+}
18+
19+func stampSpans(w *Workspace, first, last int) {
20+ for i := first; i <= last; i++ {
21+ w.Columns[i].Span = 10000 / (last - first + 1)
22+ w.Columns[i].spanLane = last - first + 1
23+ }
24+ w.Columns[last].Span += 10000 % (last - first + 1)
25+}
26+
27+func TestSpansMutations(t *testing.T) {
28+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
29+ for _, mutation := range []string{"add", "remove", "take", "receive", "swap", "grow", "shrink", "action"} {
30+ t.Run(string(mode)+"/"+mutation, func(t *testing.T) {
31+ w := spanWorkspace(mode, 2)
32+ stampSpans(w, 0, 1)
33+ switch mutation {
34+ case "add":
35+ w.AddWindow(3)
36+ case "remove":
37+ w.RemoveWindow(1)
38+ case "take":
39+ col, ok := w.takeColumn()
40+ if !ok {
41+ t.Fatal("take")
42+ }
43+ other := spanWorkspace(mode, 1)
44+ other.receive(col, -1)
45+ for _, c := range other.Columns {
46+ if c.Span != 0 {
47+ t.Fatal("destination kept stale span")
48+ }
49+ }
50+ case "receive":
51+ w.receive(Column{Windows: []WindowID{3}}, 1)
52+ case "swap":
53+ w.Focus = 0
54+ w.MoveColumn(1)
55+ case "grow":
56+ w.GrowColumn(false)
57+ case "shrink":
58+ w.ShrinkColumn()
59+ case "action":
60+ w.Apply("set-column-width +1")
61+ }
62+ for _, c := range w.Columns {
63+ if mutation == "swap" {
64+ if c.Span == 0 {
65+ t.Fatal("swap discarded valid spans")
66+ }
67+ } else if c.Span != 0 || c.spanLane != 0 {
68+ t.Fatal("stale span", c)
69+ }
70+ }
71+ })
72+ }
73+ }
74+}
75+
76+func TestSpansCascadeIsolation(t *testing.T) {
77+ w := spanWorkspace(OverflowCascade, 5)
78+ stampSpans(w, 0, 2)
79+ stampSpans(w, 3, 4)
80+ w.RemoveWindow(w.Columns[0].Windows[0])
81+ if w.Columns[0].Span != 0 || w.Columns[1].Span != 0 {
82+ t.Fatal("changed band retained spans")
83+ }
84+ if w.Columns[2].Span != 5000 || w.Columns[3].Span != 5000 {
85+ t.Fatal("unrelated band reset")
86+ }
87+}
88+
89+func TestSpanWidths(t *testing.T) {
90+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
91+ w := spanWorkspace(mode, 2)
92+ stampSpans(w, 0, 1)
93+ w.Columns[0].Span = 3000
94+ w.Columns[1].Span = 7000
95+ rs := w.columnRects()
96+ avail := 970
97+ if mode == OverflowScroll {
98+ avail = 980
99+ }
100+ if rs[0].W != avail*3/10 {
101+ t.Fatal(mode, rs)
102+ }
103+ w.SetOutput(1400, 800)
104+ if w.columnRects()[0].W != (avail+400)*3/10 {
105+ t.Fatal("ratio lost", mode, w.columnRects())
106+ }
107+ w.Columns[0].FullWidth = true
108+ if w.columnWidth(0) != 1380 {
109+ t.Fatal("special width lost")
110+ }
111+ }
112+ w := spanWorkspace(OverflowFixed, 4)
113+ stampSpans(w, 0, 3)
114+ w.scroll()
115+ for _, c := range w.Columns {
116+ if c.Span != 0 {
117+ t.Fatal("spiral spans")
118+ }
119+ }
120+}
121+
122+func TestSpansDropIntoLane(t *testing.T) {
123+ for _, mode := range []Overflow{OverflowFixed, OverflowCascade, OverflowScroll} {
124+ w := spanWorkspace(mode, 2)
125+ stampSpans(w, 0, 1)
126+ src := spanWorkspace(mode, 1)
127+ src.Columns[0].Windows[0] = 9
128+ m := monitor()
129+ m.Workspaces = []*Workspace{w, src}
130+ sc := &screen{mon: m}
131+ c := &Core{screens: []*screen{sc}}
132+ c.drop(9, dropTarget{kind: dropInsert, screen: sc, ws: w, anchor: w.Columns[1].Windows[0]})
133+ if len(w.Columns) != 3 {
134+ t.Fatal("not dropped", mode, w.Columns)
135+ }
136+ for _, col := range w.Columns {
137+ if col.Span != 0 || col.spanLane != 0 {
138+ t.Fatal("drop retained stamp", mode, col)
139+ }
140+ }
141+ }
142+}

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