detached 7609c0e

fix(layout): normalize cascade covers on restore and reload

commit
7609c0ef7955e4ddaa50a6e56f98ebce6de387de
author
brice · · 2026-10-06 08:12 UTC
parent
8c17b30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +72 -19
M internal/core/cascade.go +14 -7
@@ -4,6 +4,15 @@ import "slices"
44
55 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
66 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+func (w *Workspace) cellX(i int) int {
8+ return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap())
9+}
10+func (w *Workspace) cascadeBands() (n, spacing int) {
11+ if len(w.Columns) == 0 || w.Usable.H <= 0 {
12+ return 1, 0
13+ }
14+ return w.band(len(w.Columns)-1) + 1, w.Usable.H
15+}
716 func (w *Workspace) cellWidth() int {
817 k := max(w.MaxColumns, 1)
918 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
@@ -37,12 +46,12 @@ func (w *Workspace) bandNeighbor(dir int) int {
3746 }
3847
3948 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
40- k, g := max(w.MaxColumns, 1), w.gap()
49+ g := w.gap()
4150 width := w.cellWidth()
4251 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
4352 view := w.View + w.shiftPixels()
4453 for i := range w.Columns {
45- r := Rect{X: w.Usable.X + g + (i%k)*(width+g), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
54+ r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
4655 if w.Columns[i].FullWidth && !ignoreFullWidth {
4756 r.X = w.Usable.X + g
4857 r.W = max(w.Usable.W-2*g, 0)
@@ -53,12 +62,10 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
5362 }
5463
5564 func (w *Workspace) cascadePoints() []float64 {
56- if len(w.Columns) == 0 || w.Usable.H <= 0 {
57- return []float64{0}
58- }
59- points := make([]float64, w.band(len(w.Columns)-1)+1)
65+ n, spacing := w.cascadeBands()
66+ points := make([]float64, n)
6067 for i := range points {
61- points[i] = float64(i * w.Usable.H)
68+ points[i] = float64(i * spacing)
6269 }
6370 return points
6471 }
@@ -4,6 +4,15 @@ import "slices"
4 4
5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+func (w *Workspace) cellX(i int) int {
8+ return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth()+w.gap())
9+}
10+func (w *Workspace) cascadeBands() (n, spacing int) {
11+ if len(w.Columns) == 0 || w.Usable.H <= 0 {
12+ return 1, 0
13+ }
14+ return w.band(len(w.Columns)-1) + 1, w.Usable.H
15+}
7 func (w *Workspace) cellWidth() int {16 func (w *Workspace) cellWidth() int {
8 k := max(w.MaxColumns, 1)17 k := max(w.MaxColumns, 1)
9 return max((w.Usable.W-w.gap()*(k+1))/k, 0)18 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
@@ -37,12 +46,12 @@ func (w *Workspace) bandNeighbor(dir int) int {
37 }46 }
38 47
39 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {48 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
40- k, g := max(w.MaxColumns, 1), w.gap()49+ g := w.gap()
41 width := w.cellWidth()50 width := w.cellWidth()
42 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]51 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
43 view := w.View + w.shiftPixels()52 view := w.View + w.shiftPixels()
44 for i := range w.Columns {53 for i := range w.Columns {
45- r := Rect{X: w.Usable.X + g + (i%k)*(width+g), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}54+ r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: width, H: max(w.Usable.H-2*g, 0)}
46 if w.Columns[i].FullWidth && !ignoreFullWidth {55 if w.Columns[i].FullWidth && !ignoreFullWidth {
47 r.X = w.Usable.X + g56 r.X = w.Usable.X + g
48 r.W = max(w.Usable.W-2*g, 0)57 r.W = max(w.Usable.W-2*g, 0)
@@ -53,12 +62,10 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
53 }62 }
54 63
55 func (w *Workspace) cascadePoints() []float64 {64 func (w *Workspace) cascadePoints() []float64 {
56- if len(w.Columns) == 0 || w.Usable.H <= 0 {65+ n, spacing := w.cascadeBands()
57- return []float64{0}66+ points := make([]float64, n)
58- }
59- points := make([]float64, w.band(len(w.Columns)-1)+1)
60 for i := range points {67 for i := range points {
61- points[i] = float64(i * w.Usable.H)68+ points[i] = float64(i * spacing)
62 }69 }
63 return points70 return points
64 }71 }
M internal/core/cascade_test.go +43 -0
@@ -117,6 +117,49 @@ func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
117117 }
118118 }
119119
120+func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
121+ w := cascadeWorkspace(3)
122+ w.FocusID(1)
123+ w.ToggleFullWidth()
124+ w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
125+ for _, c := range w.Columns {
126+ if c.FullWidth {
127+ t.Fatal("restore retained maximization")
128+ }
129+ }
130+ m := NewMonitor()
131+ m.SetOutput(900, 600)
132+ for i := WindowID(1); i <= 3; i++ {
133+ m.AddWindow(i)
134+ }
135+ m.Current().FocusID(1)
136+ m.Current().ToggleFullWidth()
137+ m.Current().FocusID(3)
138+ m.SetOverflow(OverflowCascade)
139+ for _, c := range m.Current().Columns {
140+ if c.FullWidth {
141+ t.Fatal("reload retained maximization")
142+ }
143+ }
144+}
145+
146+func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
147+ m := NewMonitor()
148+ m.SetOutput(900, 600)
149+ m.SetOverflow(OverflowCascade)
150+ for i := WindowID(1); i <= 7; i++ {
151+ m.AddWindow(i)
152+ }
153+ g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
154+ if n := testing.AllocsPerRun(100, func() {
155+ if g.columnsChanged(m) {
156+ t.Fatal("unchanged gesture invalidated")
157+ }
158+ }); n != 0 {
159+ t.Fatalf("allocations=%v", n)
160+ }
161+}
162+
120163 func TestCascadeGeometryReusesBuffer(t *testing.T) {
121164 w := cascadeWorkspace(7)
122165 buf := w.columnRectsInto(nil, false)
@@ -117,6 +117,49 @@ func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
117 }117 }
118 }118 }
119 119
120+func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
121+ w := cascadeWorkspace(3)
122+ w.FocusID(1)
123+ w.ToggleFullWidth()
124+ w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
125+ for _, c := range w.Columns {
126+ if c.FullWidth {
127+ t.Fatal("restore retained maximization")
128+ }
129+ }
130+ m := NewMonitor()
131+ m.SetOutput(900, 600)
132+ for i := WindowID(1); i <= 3; i++ {
133+ m.AddWindow(i)
134+ }
135+ m.Current().FocusID(1)
136+ m.Current().ToggleFullWidth()
137+ m.Current().FocusID(3)
138+ m.SetOverflow(OverflowCascade)
139+ for _, c := range m.Current().Columns {
140+ if c.FullWidth {
141+ t.Fatal("reload retained maximization")
142+ }
143+ }
144+}
145+
146+func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
147+ m := NewMonitor()
148+ m.SetOutput(900, 600)
149+ m.SetOverflow(OverflowCascade)
150+ for i := WindowID(1); i <= 7; i++ {
151+ m.AddWindow(i)
152+ }
153+ g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
154+ if n := testing.AllocsPerRun(100, func() {
155+ if g.columnsChanged(m) {
156+ t.Fatal("unchanged gesture invalidated")
157+ }
158+ }); n != 0 {
159+ t.Fatalf("allocations=%v", n)
160+ }
161+}
162+
120 func TestCascadeGeometryReusesBuffer(t *testing.T) {163 func TestCascadeGeometryReusesBuffer(t *testing.T) {
121 w := cascadeWorkspace(7)164 w := cascadeWorkspace(7)
122 buf := w.columnRectsInto(nil, false)165 buf := w.columnRectsInto(nil, false)
M internal/core/gesture.go +7 -10
@@ -102,15 +102,12 @@ func (g *swipeGesture) columnsChanged(m *Monitor) bool {
102102 return true
103103 }
104104 if g.ws.policy().wraps {
105- n := 1
106- if len(g.ws.Columns) > 0 && g.ws.Usable.H > 0 {
107- n = g.ws.band(len(g.ws.Columns)-1) + 1
108- }
105+ n, spacing := g.ws.cascadeBands()
109106 if len(g.points) != n {
110107 return true
111108 }
112109 for i, p := range g.points {
113- if p != float64(i*g.ws.Usable.H) {
110+ if p != float64(i*spacing) {
114111 return true
115112 }
116113 }
@@ -424,10 +421,6 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
424421 if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
425422 return false
426423 }
427- a = ActionFocusWindowDown
428- if step < 0 {
429- a = ActionFocusWindowUp
430- }
431424 }
432425 // Focus actions act on the focused screen: the swipe's, unless the
433426 // pointer took the focus to another output meanwhile.
@@ -440,7 +433,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
440433 before := c.cur().mon.Current()
441434 shots := c.snapshot(now)
442435 c.keyboard.takeBack()
443- c.applyAction(a)
436+ if p.wraps && !workspace {
437+ w.focusBand(step)
438+ } else {
439+ c.applyAction(a)
440+ }
444441 c.transition(shots, now)
445442 return c.cur().mon.Current() != before
446443 }
@@ -102,15 +102,12 @@ func (g *swipeGesture) columnsChanged(m *Monitor) bool {
102 return true102 return true
103 }103 }
104 if g.ws.policy().wraps {104 if g.ws.policy().wraps {
105- n := 1105+ n, spacing := g.ws.cascadeBands()
106- if len(g.ws.Columns) > 0 && g.ws.Usable.H > 0 {
107- n = g.ws.band(len(g.ws.Columns)-1) + 1
108- }
109 if len(g.points) != n {106 if len(g.points) != n {
110 return true107 return true
111 }108 }
112 for i, p := range g.points {109 for i, p := range g.points {
113- if p != float64(i*g.ws.Usable.H) {110+ if p != float64(i*spacing) {
114 return true111 return true
115 }112 }
116 }113 }
@@ -424,10 +421,6 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
424 if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {421 if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
425 return false422 return false
426 }423 }
427- a = ActionFocusWindowDown
428- if step < 0 {
429- a = ActionFocusWindowUp
430- }
431 }424 }
432 // Focus actions act on the focused screen: the swipe's, unless the425 // Focus actions act on the focused screen: the swipe's, unless the
433 // pointer took the focus to another output meanwhile.426 // pointer took the focus to another output meanwhile.
@@ -440,7 +433,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
440 before := c.cur().mon.Current()433 before := c.cur().mon.Current()
441 shots := c.snapshot(now)434 shots := c.snapshot(now)
442 c.keyboard.takeBack()435 c.keyboard.takeBack()
443- c.applyAction(a)436+ if p.wraps && !workspace {
437+ w.focusBand(step)
438+ } else {
439+ c.applyAction(a)
440+ }
444 c.transition(shots, now)441 c.transition(shots, now)
445 return c.cur().mon.Current() != before442 return c.cur().mon.Current() != before
446 }443 }
M internal/core/monitor.go +4 -0
@@ -510,10 +510,14 @@ func (m *Monitor) applyNamed() {
510510 }
511511 m.each(func(w *Workspace) {
512512 s := specs[w.Name]
513+ before := w.Overflow
513514 w.Overflow = m.template.Overflow
514515 if s.Overflow != "" {
515516 w.Overflow = s.Overflow
516517 }
518+ if before != w.Overflow && w.policy().equalCells {
519+ w.unmaximize()
520+ }
517521 w.SetMaxColumns(m.template.MaxColumns)
518522 if s.MaxColumns > 0 {
519523 w.SetMaxColumns(s.MaxColumns)
@@ -510,10 +510,14 @@ func (m *Monitor) applyNamed() {
510 }510 }
511 m.each(func(w *Workspace) {511 m.each(func(w *Workspace) {
512 s := specs[w.Name]512 s := specs[w.Name]
513+ before := w.Overflow
513 w.Overflow = m.template.Overflow514 w.Overflow = m.template.Overflow
514 if s.Overflow != "" {515 if s.Overflow != "" {
515 w.Overflow = s.Overflow516 w.Overflow = s.Overflow
516 }517 }
518+ if before != w.Overflow && w.policy().equalCells {
519+ w.unmaximize()
520+ }
517 w.SetMaxColumns(m.template.MaxColumns)521 w.SetMaxColumns(m.template.MaxColumns)
518 if s.MaxColumns > 0 {522 if s.MaxColumns > 0 {
519 w.SetMaxColumns(s.MaxColumns)523 w.SetMaxColumns(s.MaxColumns)
M internal/core/overview.go +1 -2
@@ -749,9 +749,8 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
749749 for i, c := range w.Columns {
750750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751751 if w.policy().wraps {
752- k := max(w.MaxColumns, 1)
753752 width := w.cellWidth()
754- r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)
753+ r.X = w.band(i)*w.Usable.W + w.cellX(i)
755754 r.W = width
756755 } else if w.Overflow == OverflowFixed {
757756 r = rects[i]
@@ -749,9 +749,8 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
749 for i, c := range w.Columns {749 for i, c := range w.Columns {
750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}750 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
751 if w.policy().wraps {751 if w.policy().wraps {
752- k := max(w.MaxColumns, 1)
753 width := w.cellWidth()752 width := w.cellWidth()
754- r.X = w.band(i)*w.Usable.W + g + (i%k)*(width+g)753+ r.X = w.band(i)*w.Usable.W + w.cellX(i)
755 r.W = width754 r.W = width
756 } else if w.Overflow == OverflowFixed {755 } else if w.Overflow == OverflowFixed {
757 r = rects[i]756 r = rects[i]
M internal/core/stash.go +3 -0
@@ -132,6 +132,9 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
132132 }
133133 c.Windows = slices.Insert(c.Windows, row, id)
134134 c.Focus = row
135+ if w.policy().equalCells && w.Focus != i {
136+ w.unmaximize()
137+ }
135138 w.Focus = i
136139 w.scroll()
137140 return
@@ -132,6 +132,9 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
132 }132 }
133 c.Windows = slices.Insert(c.Windows, row, id)133 c.Windows = slices.Insert(c.Windows, row, id)
134 c.Focus = row134 c.Focus = row
135+ if w.policy().equalCells && w.Focus != i {
136+ w.unmaximize()
137+ }
135 w.Focus = i138 w.Focus = i
136 w.scroll()139 w.scroll()
137 return140 return

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 23

Tags 10

↑↓ moveenter openesc close