detached d72b69d

fix(core): identify span lanes by stamp

commit
d72b69d08518a4267d0d4eee76be71206b492321
author
brice · · 2026-10-21 05:45 UTC
committer
brice · · 2026-10-21 07:11 UTC
parent
9f44b24
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +84 -15
M internal/core/border.go +2 -1
@@ -132,10 +132,11 @@ func (w *Workspace) setBorder(b border, pos int) {
132132 return
133133 }
134134 if !w.validSpans(first, last) {
135+ w.spanSeq++
135136 used := 0
136137 for i := first; i <= last; i++ {
137138 w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)
138- w.Columns[i].spanLane = last - first + 1
139+ w.Columns[i].spanStamp = w.spanSeq
139140 used += w.Columns[i].Span
140141 }
141142 if w.policy().equalCells {
@@ -132,10 +132,11 @@ func (w *Workspace) setBorder(b border, pos int) {
132 return132 return
133 }133 }
134 if !w.validSpans(first, last) {134 if !w.validSpans(first, last) {
135+ w.spanSeq++
135 used := 0136 used := 0
136 for i := first; i <= last; i++ {137 for i := first; i <= last; i++ {
137 w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)138 w.Columns[i].Span = max((w.columnWidthFor(i, true)*10000+5000)/avail, 1)
138- w.Columns[i].spanLane = last - first + 1139+ w.Columns[i].spanStamp = w.spanSeq
139 used += w.Columns[i].Span140 used += w.Columns[i].Span
140 }141 }
141 if w.policy().equalCells {142 if w.policy().equalCells {
M internal/core/border_resize.go +1 -1
@@ -85,6 +85,6 @@ func (g *borderResize) cancel() {
8585 }
8686 for i, saved := range g.saved {
8787 col := &g.ws.Columns[i]
88- col.Span, col.spanLane, col.Shares = saved.Span, saved.spanLane, slices.Clone(saved.Shares)
88+ col.Span, col.spanStamp, col.Shares = saved.Span, saved.spanStamp, slices.Clone(saved.Shares)
8989 }
9090 }
@@ -85,6 +85,6 @@ func (g *borderResize) cancel() {
85 }85 }
86 for i, saved := range g.saved {86 for i, saved := range g.saved {
87 col := &g.ws.Columns[i]87 col := &g.ws.Columns[i]
88- col.Span, col.spanLane, col.Shares = saved.Span, saved.spanLane, slices.Clone(saved.Shares)88+ col.Span, col.spanStamp, col.Shares = saved.Span, saved.spanStamp, slices.Clone(saved.Shares)
89 }89 }
90 }90 }
M internal/core/border_test.go +1 -1
@@ -106,7 +106,7 @@ func TestSetColumnBorder(t *testing.T) {
106106 }
107107 w.resetBorder(b)
108108 for _, c := range w.Columns {
109- if c.Span != 0 || c.spanLane != 0 {
109+ if c.Span != 0 || c.spanStamp != 0 {
110110 t.Fatal("reset")
111111 }
112112 }
@@ -106,7 +106,7 @@ func TestSetColumnBorder(t *testing.T) {
106 }106 }
107 w.resetBorder(b)107 w.resetBorder(b)
108 for _, c := range w.Columns {108 for _, c := range w.Columns {
109- if c.Span != 0 || c.spanLane != 0 {109+ if c.Span != 0 || c.spanStamp != 0 {
110 t.Fatal("reset")110 t.Fatal("reset")
111 }111 }
112 }112 }
M internal/core/cascade.go +2 -0
@@ -511,6 +511,8 @@ func (w *Workspace) spawnIndex() (at int, newBand bool) {
511511
512512 // deleteColumn removes column i; a band it started keeps its start.
513513 func (w *Workspace) deleteColumn(i int) {
514+ // A removed member cannot be detected by counting surviving stamp members.
515+ w.clearSpans(i)
514516 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
515517 w.Columns[i+1].BandStart = true
516518 }
@@ -511,6 +511,8 @@ func (w *Workspace) spawnIndex() (at int, newBand bool) {
511 511
512 // deleteColumn removes column i; a band it started keeps its start.512 // deleteColumn removes column i; a band it started keeps its start.
513 func (w *Workspace) deleteColumn(i int) {513 func (w *Workspace) deleteColumn(i int) {
514+ // A removed member cannot be detected by counting surviving stamp members.
515+ w.clearSpans(i)
514 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {516 if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) {
515 w.Columns[i+1].BandStart = true517 w.Columns[i+1].BandStart = true
516 }518 }
M internal/core/layout.go +5 -1
@@ -21,7 +21,7 @@ const maxCells = 16
2121 type Column struct {
2222 // Span is a manually sized width in basis points; zero uses Cells.
2323 Span int
24- spanLane int
24+ spanStamp uint64
2525 Windows []WindowID
2626 Cells int
2727 FullWidth bool
@@ -108,6 +108,8 @@ type Workspace struct {
108108 overviewAfter *Workspace
109109 Overflow Overflow
110110 Columns []Column
111+ // spanSeq issues workspace-local identities for complete lane stamps.
112+ spanSeq uint64
111113 // maximized remembers recently maximized columns by window ID, newest
112114 // first; stack() filters removed windows at read time.
113115 maximized []WindowID
@@ -1037,6 +1039,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
10371039 // insertAt inserts col at index at; in a cascade it joins the band of the
10381040 // column it goes before.
10391041 func (w *Workspace) insertAt(at int, col Column) {
1042+ // Stamp identities belong to the issuing workspace and complete lane.
1043+ col.Span, col.spanStamp = 0, 0
10401044 col.BandStart = false
10411045 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
10421046 col.BandStart, w.Columns[at].BandStart = true, false
@@ -21,7 +21,7 @@ const maxCells = 16
21 type Column struct {21 type Column struct {
22 // Span is a manually sized width in basis points; zero uses Cells.22 // Span is a manually sized width in basis points; zero uses Cells.
23 Span int23 Span int
24- spanLane int24+ spanStamp uint64
25 Windows []WindowID25 Windows []WindowID
26 Cells int26 Cells int
27 FullWidth bool27 FullWidth bool
@@ -108,6 +108,8 @@ type Workspace struct {
108 overviewAfter *Workspace108 overviewAfter *Workspace
109 Overflow Overflow109 Overflow Overflow
110 Columns []Column110 Columns []Column
111+ // spanSeq issues workspace-local identities for complete lane stamps.
112+ spanSeq uint64
111 // maximized remembers recently maximized columns by window ID, newest113 // maximized remembers recently maximized columns by window ID, newest
112 // first; stack() filters removed windows at read time.114 // first; stack() filters removed windows at read time.
113 maximized []WindowID115 maximized []WindowID
@@ -1037,6 +1039,8 @@ func (w *Workspace) insertColumn(at int, col Column) {
1037 // insertAt inserts col at index at; in a cascade it joins the band of the1039 // insertAt inserts col at index at; in a cascade it joins the band of the
1038 // column it goes before.1040 // column it goes before.
1039 func (w *Workspace) insertAt(at int, col Column) {1041 func (w *Workspace) insertAt(at int, col Column) {
1042+ // Stamp identities belong to the issuing workspace and complete lane.
1043+ col.Span, col.spanStamp = 0, 0
1040 col.BandStart = false1044 col.BandStart = false
1041 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {1045 if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart {
1042 col.BandStart, w.Columns[at].BandStart = true, false1046 col.BandStart, w.Columns[at].BandStart = true, false
M internal/core/layout_policy.go +24 -8
@@ -54,35 +54,51 @@ func (w *Workspace) policy() layoutPolicy {
5454 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
5555 }
5656
57-// validSpans rejects partial stamps and stamps from a differently sized lane.
57+// validSpans requires a complete, exclusive lane stamp: every column has
58+// a positive span and the same nonzero identity, with no members elsewhere
59+// in the workspace. Equal-cell lanes must also sum to 10000 basis points.
5860 func (w *Workspace) validSpans(first, last int) bool {
5961 if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
6062 return false
6163 }
64+ stamp := w.Columns[first].spanStamp
65+ if stamp == 0 {
66+ return false
67+ }
6268 total := 0
6369 for i := first; i <= last; i++ {
6470 c := w.Columns[i]
65- if c.Span <= 0 || c.spanLane != last-first+1 {
71+ if c.Span <= 0 || c.spanStamp != stamp {
6672 return false
6773 }
6874 total += c.Span
6975 }
70- return !w.policy().equalCells || total == 10000
76+ count := 0
77+ for _, c := range w.Columns {
78+ if c.spanStamp == stamp {
79+ count++
80+ }
81+ }
82+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
7183 }
7284
73-// dropStaleSpans runs before scroll measures mutated layout geometry. A band
74-// is independent: invalidating one never discards another band's ratios.
85+// dropStaleSpans runs before scroll measures mutated layout geometry. Decide
86+// validity before clearing any stamps, so shared identities cannot become
87+// valid merely because an earlier invalid lane was cleared.
7588 func (w *Workspace) dropStaleSpans() {
89+ var stale []int
7690 for first := 0; first < len(w.Columns); {
7791 _, last := w.laneBounds(w.laneOf(first))
7892 if !w.validSpans(first, last) {
7993 for i := first; i <= last; i++ {
80- w.Columns[i].Span = 0
81- w.Columns[i].spanLane = 0
94+ stale = append(stale, i)
8295 }
8396 }
8497 first = last + 1
8598 }
99+ for _, i := range stale {
100+ w.Columns[i].Span, w.Columns[i].spanStamp = 0, 0
101+ }
86102 }
87103
88104 func (w *Workspace) clearSpans(col int) {
@@ -92,6 +108,6 @@ func (w *Workspace) clearSpans(col int) {
92108 first, last := w.laneBounds(w.laneOf(col))
93109 for i := first; i <= last; i++ {
94110 w.Columns[i].Span = 0
95- w.Columns[i].spanLane = 0
111+ w.Columns[i].spanStamp = 0
96112 }
97113 }
@@ -54,35 +54,51 @@ func (w *Workspace) policy() layoutPolicy {
54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}54 return layoutPolicy{content: horizontalAxis, workspace: verticalAxis}
55 }55 }
56 56
57-// validSpans rejects partial stamps and stamps from a differently sized lane.57+// validSpans requires a complete, exclusive lane stamp: every column has
58+// a positive span and the same nonzero identity, with no members elsewhere
59+// in the workspace. Equal-cell lanes must also sum to 10000 basis points.
58 func (w *Workspace) validSpans(first, last int) bool {60 func (w *Workspace) validSpans(first, last int) bool {
59 if first > last || w.Overflow == OverflowFixed && !w.laneRow() {61 if first > last || w.Overflow == OverflowFixed && !w.laneRow() {
60 return false62 return false
61 }63 }
64+ stamp := w.Columns[first].spanStamp
65+ if stamp == 0 {
66+ return false
67+ }
62 total := 068 total := 0
63 for i := first; i <= last; i++ {69 for i := first; i <= last; i++ {
64 c := w.Columns[i]70 c := w.Columns[i]
65- if c.Span <= 0 || c.spanLane != last-first+1 {71+ if c.Span <= 0 || c.spanStamp != stamp {
66 return false72 return false
67 }73 }
68 total += c.Span74 total += c.Span
69 }75 }
70- return !w.policy().equalCells || total == 1000076+ count := 0
77+ for _, c := range w.Columns {
78+ if c.spanStamp == stamp {
79+ count++
80+ }
81+ }
82+ return count == last-first+1 && (!w.policy().equalCells || total == 10000)
71 }83 }
72 84
73-// dropStaleSpans runs before scroll measures mutated layout geometry. A band85+// dropStaleSpans runs before scroll measures mutated layout geometry. Decide
74-// is independent: invalidating one never discards another band's ratios.86+// validity before clearing any stamps, so shared identities cannot become
87+// valid merely because an earlier invalid lane was cleared.
75 func (w *Workspace) dropStaleSpans() {88 func (w *Workspace) dropStaleSpans() {
89+ var stale []int
76 for first := 0; first < len(w.Columns); {90 for first := 0; first < len(w.Columns); {
77 _, last := w.laneBounds(w.laneOf(first))91 _, last := w.laneBounds(w.laneOf(first))
78 if !w.validSpans(first, last) {92 if !w.validSpans(first, last) {
79 for i := first; i <= last; i++ {93 for i := first; i <= last; i++ {
80- w.Columns[i].Span = 094+ stale = append(stale, i)
81- w.Columns[i].spanLane = 0
82 }95 }
83 }96 }
84 first = last + 197 first = last + 1
85 }98 }
99+ for _, i := range stale {
100+ w.Columns[i].Span, w.Columns[i].spanStamp = 0, 0
101+ }
86 }102 }
87 103
88 func (w *Workspace) clearSpans(col int) {104 func (w *Workspace) clearSpans(col int) {
@@ -92,6 +108,6 @@ func (w *Workspace) clearSpans(col int) {
92 first, last := w.laneBounds(w.laneOf(col))108 first, last := w.laneBounds(w.laneOf(col))
93 for i := first; i <= last; i++ {109 for i := first; i <= last; i++ {
94 w.Columns[i].Span = 0110 w.Columns[i].Span = 0
95- w.Columns[i].spanLane = 0111+ w.Columns[i].spanStamp = 0
96 }112 }
97 }113 }
M internal/core/spans_test.go +49 -3
@@ -17,9 +17,10 @@ func spanWorkspace(mode Overflow, n int) *Workspace {
1717 }
1818
1919 func stampSpans(w *Workspace, first, last int) {
20+ w.spanSeq++
2021 for i := first; i <= last; i++ {
2122 w.Columns[i].Span = 10000 / (last - first + 1)
22- w.Columns[i].spanLane = last - first + 1
23+ w.Columns[i].spanStamp = w.spanSeq
2324 }
2425 w.Columns[last].Span += 10000 % (last - first + 1)
2526 }
@@ -64,7 +65,7 @@ func TestSpansMutations(t *testing.T) {
6465 if c.Span == 0 {
6566 t.Fatal("swap discarded valid spans")
6667 }
67- } else if c.Span != 0 || c.spanLane != 0 {
68+ } else if c.Span != 0 || c.spanStamp != 0 {
6869 t.Fatal("stale span", c)
6970 }
7071 }
@@ -134,9 +135,54 @@ func TestSpansDropIntoLane(t *testing.T) {
134135 t.Fatal("not dropped", mode, w.Columns)
135136 }
136137 for _, col := range w.Columns {
137- if col.Span != 0 || col.spanLane != 0 {
138+ if col.Span != 0 || col.spanStamp != 0 {
138139 t.Fatal("drop retained stamp", mode, col)
139140 }
140141 }
141142 }
142143 }
144+
145+func TestSpanLaneIdentity(t *testing.T) {
146+ for _, action := range []string{"cross-drop", "within-drop", "within-keyboard"} {
147+ t.Run(action, func(t *testing.T) {
148+ w := spanWorkspace(OverflowCascade, 4)
149+ w.SetMaxColumns(2)
150+ for i := range w.Columns {
151+ w.Columns[i].BandStart = i%2 == 0
152+ }
153+ for _, i := range []int{0, 2} {
154+ rs := w.columnRects()
155+ w.setBorder(border{kind: borderColumn, col: i}, rs[i].X+rs[i].W+5)
156+ }
157+ for _, col := range w.Columns {
158+ if col.Span != 5000 {
159+ t.Fatal("not stamped equally", w.Columns)
160+ }
161+ }
162+ cross := action == "cross-drop"
163+ if action == "cross-drop" || action == "within-drop" {
164+ m := monitor()
165+ m.Workspaces = []*Workspace{w}
166+ sc := &screen{mon: m}
167+ c := &Core{screens: []*screen{sc}}
168+ to := 1
169+ if cross {
170+ to = 2
171+ }
172+ c.drop(w.Columns[0].Windows[0], dropTarget{kind: dropSwap, screen: sc, ws: w, anchor: w.Columns[to].Windows[0]})
173+ } else {
174+ w.Focus = 0
175+ w.MoveColumn(1)
176+ }
177+ w.dropStaleSpans()
178+ for _, col := range w.Columns {
179+ if cross && col.Span != 0 {
180+ t.Fatal("cross-band swap retained stamp", w.Columns)
181+ }
182+ if !cross && col.Span != 5000 {
183+ t.Fatal("within-band swap discarded stamp", w.Columns)
184+ }
185+ }
186+ })
187+ }
188+}
@@ -17,9 +17,10 @@ func spanWorkspace(mode Overflow, n int) *Workspace {
17 }17 }
18 18
19 func stampSpans(w *Workspace, first, last int) {19 func stampSpans(w *Workspace, first, last int) {
20+ w.spanSeq++
20 for i := first; i <= last; i++ {21 for i := first; i <= last; i++ {
21 w.Columns[i].Span = 10000 / (last - first + 1)22 w.Columns[i].Span = 10000 / (last - first + 1)
22- w.Columns[i].spanLane = last - first + 123+ w.Columns[i].spanStamp = w.spanSeq
23 }24 }
24 w.Columns[last].Span += 10000 % (last - first + 1)25 w.Columns[last].Span += 10000 % (last - first + 1)
25 }26 }
@@ -64,7 +65,7 @@ func TestSpansMutations(t *testing.T) {
64 if c.Span == 0 {65 if c.Span == 0 {
65 t.Fatal("swap discarded valid spans")66 t.Fatal("swap discarded valid spans")
66 }67 }
67- } else if c.Span != 0 || c.spanLane != 0 {68+ } else if c.Span != 0 || c.spanStamp != 0 {
68 t.Fatal("stale span", c)69 t.Fatal("stale span", c)
69 }70 }
70 }71 }
@@ -134,9 +135,54 @@ func TestSpansDropIntoLane(t *testing.T) {
134 t.Fatal("not dropped", mode, w.Columns)135 t.Fatal("not dropped", mode, w.Columns)
135 }136 }
136 for _, col := range w.Columns {137 for _, col := range w.Columns {
137- if col.Span != 0 || col.spanLane != 0 {138+ if col.Span != 0 || col.spanStamp != 0 {
138 t.Fatal("drop retained stamp", mode, col)139 t.Fatal("drop retained stamp", mode, col)
139 }140 }
140 }141 }
141 }142 }
142 }143 }
144+
145+func TestSpanLaneIdentity(t *testing.T) {
146+ for _, action := range []string{"cross-drop", "within-drop", "within-keyboard"} {
147+ t.Run(action, func(t *testing.T) {
148+ w := spanWorkspace(OverflowCascade, 4)
149+ w.SetMaxColumns(2)
150+ for i := range w.Columns {
151+ w.Columns[i].BandStart = i%2 == 0
152+ }
153+ for _, i := range []int{0, 2} {
154+ rs := w.columnRects()
155+ w.setBorder(border{kind: borderColumn, col: i}, rs[i].X+rs[i].W+5)
156+ }
157+ for _, col := range w.Columns {
158+ if col.Span != 5000 {
159+ t.Fatal("not stamped equally", w.Columns)
160+ }
161+ }
162+ cross := action == "cross-drop"
163+ if action == "cross-drop" || action == "within-drop" {
164+ m := monitor()
165+ m.Workspaces = []*Workspace{w}
166+ sc := &screen{mon: m}
167+ c := &Core{screens: []*screen{sc}}
168+ to := 1
169+ if cross {
170+ to = 2
171+ }
172+ c.drop(w.Columns[0].Windows[0], dropTarget{kind: dropSwap, screen: sc, ws: w, anchor: w.Columns[to].Windows[0]})
173+ } else {
174+ w.Focus = 0
175+ w.MoveColumn(1)
176+ }
177+ w.dropStaleSpans()
178+ for _, col := range w.Columns {
179+ if cross && col.Span != 0 {
180+ t.Fatal("cross-band swap retained stamp", w.Columns)
181+ }
182+ if !cross && col.Span != 5000 {
183+ t.Fatal("within-band swap discarded stamp", w.Columns)
184+ }
185+ }
186+ })
187+ }
188+}

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