detached d0a3940

fix(core): reset an uneven full lane to equal shares with cycle-column-width

commit
d0a3940c50384f6c574b50a201c113bc461513ab
author
brice · · 2026-10-12 10:01 UTC
parent
0cfaceb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +36 -6
M docs/config.md +4 -4
@@ -224,7 +224,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
224224
225225 ### Cascade
226226
227-Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
227+Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its row of cells (see [Column widths](#column-widths)); auto columns share it equally, so a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
228228
229229 Left/right focus and moves stay in the band; at its edge they go to the neighbor monitor on that side, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
230230
@@ -238,7 +238,7 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
238238
239239 ### Column widths
240240
241-A cell is `1/max-columns` of the usable width. A column is `Cells` wide, from 1 to 16; an auto column (no cells set) shares the width equally. The same model serves every overflow:
241+A cell is `1/max-columns` of the usable width. A column is N cells wide, from 1 to 16; an auto column (no cells set) shares the width equally. The same model serves every overflow:
242242
243243 - `cascade`: the columns of a band share its row of cells.
244244 - `fixed`: the columns share the row while there are at most `max-columns` of them. Past that, the spiral applies and cells are ignored.
@@ -263,12 +263,12 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
263263 | `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
264264 | `switch-column-prev` | The same, in the opposite direction |
265265 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
266-| `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. Does nothing for a lone column, a full row or a spiral |
266+| `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. A lone column or a full row cannot grow: it goes back to equal shares if its cells were set, and otherwise nothing changes. A spiral never changes |
267267 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
268268 | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends |
269269 | `move-column-left/right` | Move the focused column; at the edge, to the neighbor monitor, focus following. In cascade overflow the edge is the band's; with no monitor there, the column crosses into the adjacent band. Fixed overflow follows column order, not the screen |
270270 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it. In cascade overflow, past the column, the column moves to the adjacent band, where `focus-window-up/down` goes |
271-| `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. The cells come from, or go to, the other columns of the row |
271+| `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. In `cascade` and `fixed` overflow the cells come from, or go to, the other columns of the row; in `scroll` overflow the column grows on its own, up to `max-columns` cells |
272272 | `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |
273273 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
274274 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |
@@ -224,7 +224,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
224 224
225 ### Cascade225 ### Cascade
226 226
227-Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its width equally, as in `fixed` overflow: a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.227+Cascade is the default layout (`layout.overflow = cascade`); it can also be set per output or on a named workspace. Columns fill bands of up to `max-columns`, then continue in a fresh band below. The columns of a band share its row of cells (see [Column widths](#column-widths)); auto columns share it equally, so a lone column takes the full width until another joins its band. New columns append last; closing a column compacts the following bands. Each band fills the usable height after panel reservations.
228 228
229 Left/right focus and moves stay in the band; at its edge they go to the neighbor monitor on that side, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.229 Left/right focus and moves stay in the band; at its edge they go to the neighbor monitor on that side, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
230 230
@@ -238,7 +238,7 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
238 238
239 ### Column widths239 ### Column widths
240 240
241-A cell is `1/max-columns` of the usable width. A column is `Cells` wide, from 1 to 16; an auto column (no cells set) shares the width equally. The same model serves every overflow:241+A cell is `1/max-columns` of the usable width. A column is N cells wide, from 1 to 16; an auto column (no cells set) shares the width equally. The same model serves every overflow:
242 242
243 - `cascade`: the columns of a band share its row of cells.243 - `cascade`: the columns of a band share its row of cells.
244 - `fixed`: the columns share the row while there are at most `max-columns` of them. Past that, the spiral applies and cells are ignored.244 - `fixed`: the columns share the row while there are at most `max-columns` of them. Past that, the spiral applies and cells are ignored.
@@ -263,12 +263,12 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
263 | `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |263 | `switch-column-next` | Switch to the column used before this one on the workspace. Hold Cmd and press again to go further back; after a short hold a strip of cards shows the columns, most recent first, and releasing Cmd focuses the selected one. Escape cancels; clicking a card picks it, and a click elsewhere cancels and is not passed to windows. A click before the cards show cancels and acts as usual. A maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
264 | `switch-column-prev` | The same, in the opposite direction |264 | `switch-column-prev` | The same, in the opposite direction |
265 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |265 | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
266-| `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. Does nothing for a lone column, a full row or a spiral |266+| `cycle-column-width` | Grow the focused column by one cell, taking it from the widest other column of its row (see [Column widths](#column-widths)). When no column has a cell to give, every column of the row goes back to equal shares. In scroll overflow, the column grows to `max-columns` cells, then back to auto. A lone column or a full row cannot grow: it goes back to equal shares if its cells were set, and otherwise nothing changes. A spiral never changes |
267 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |267 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
268 | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends |268 | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns. From a fullscreen window, a window above or below comes first and fullscreen ends |
269 | `move-column-left/right` | Move the focused column; at the edge, to the neighbor monitor, focus following. In cascade overflow the edge is the band's; with no monitor there, the column crosses into the adjacent band. Fixed overflow follows column order, not the screen |269 | `move-column-left/right` | Move the focused column; at the edge, to the neighbor monitor, focus following. In cascade overflow the edge is the band's; with no monitor there, the column crosses into the adjacent band. Fixed overflow follows column order, not the screen |
270 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it. In cascade overflow, past the column, the column moves to the adjacent band, where `focus-window-up/down` goes |270 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it. In cascade overflow, past the column, the column moves to the adjacent band, where `focus-window-up/down` goes |
271-| `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. The cells come from, or go to, the other columns of the row |271+| `set-column-width <+N\|-N>` | Widen or narrow the focused column by N cells (1-16), never below one cell and without cycling back to equal shares. In `cascade` and `fixed` overflow the cells come from, or go to, the other columns of the row; in `scroll` overflow the column grows on its own, up to `max-columns` cells |
272 | `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |272 | `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |
273 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |273 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
274 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |274 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |
M internal/adapters/config/config.go +1 -1
@@ -783,7 +783,7 @@ func set(c *ports.Config, key, v string) error {
783783 }
784784 c.Layout.Gaps = n
785785 case "layout.default-width":
786- return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
786+ return fmt.Errorf("replaced by layout.max-columns (column widths are cells of 1/max-columns)")
787787 case "layout.max-columns":
788788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
789789 case "layout.overflow":
@@ -783,7 +783,7 @@ func set(c *ports.Config, key, v string) error {
783 }783 }
784 c.Layout.Gaps = n784 c.Layout.Gaps = n
785 case "layout.default-width":785 case "layout.default-width":
786- return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")786+ return fmt.Errorf("replaced by layout.max-columns (column widths are cells of 1/max-columns)")
787 case "layout.max-columns":787 case "layout.max-columns":
788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
789 case "layout.overflow":789 case "layout.overflow":
M internal/core/column_cells_test.go +26 -1
@@ -314,6 +314,31 @@ func TestCellsAboveMaxColumns(t *testing.T) {
314314 }
315315 }
316316 })
317+ t.Run("a full uneven lane cycles back to equal", func(t *testing.T) {
318+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
319+ w := cellsWorkspace(o, 4, 4)
320+ w.Columns[1].Cells = 2
321+ w.Columns[0].Cells, w.Columns[2].Cells = 1, 1
322+ w.Apply("set-column-width +1")
323+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{1, 2, 1, 0}) {
324+ t.Fatalf("%s: set-column-width cycled: %v", o, got)
325+ }
326+ w.Apply(ActionCycleColumnWidth)
327+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0}) {
328+ t.Fatalf("%s: cells %v", o, got)
329+ }
330+ }
331+ })
332+ t.Run("a lone column with cells cycles back to auto", func(t *testing.T) {
333+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
334+ w := cellsWorkspace(o, 4, 1)
335+ w.Columns[0].Cells = 2
336+ w.Apply(ActionCycleColumnWidth)
337+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0}) {
338+ t.Fatalf("%s: cells %v", o, got)
339+ }
340+ }
341+ })
317342 t.Run("scroll", func(t *testing.T) {
318343 w := cellsWorkspace(OverflowScroll, 2, 2)
319344 w.Columns[0].Cells = 3
@@ -335,7 +360,7 @@ func TestCellsAboveMaxColumns(t *testing.T) {
335360 t.Fatal("no column")
336361 }
337362 dst.receive(col, 1)
338- if got := cellsOf(dst); got[len(got)-1] != 3 && got[0] != 3 {
363+ if got := cellsOf(dst); !reflect.DeepEqual(got, []int{0, 3}) {
339364 t.Fatalf("cells %v", got)
340365 }
341366 })
@@ -314,6 +314,31 @@ func TestCellsAboveMaxColumns(t *testing.T) {
314 }314 }
315 }315 }
316 })316 })
317+ t.Run("a full uneven lane cycles back to equal", func(t *testing.T) {
318+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
319+ w := cellsWorkspace(o, 4, 4)
320+ w.Columns[1].Cells = 2
321+ w.Columns[0].Cells, w.Columns[2].Cells = 1, 1
322+ w.Apply("set-column-width +1")
323+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{1, 2, 1, 0}) {
324+ t.Fatalf("%s: set-column-width cycled: %v", o, got)
325+ }
326+ w.Apply(ActionCycleColumnWidth)
327+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0}) {
328+ t.Fatalf("%s: cells %v", o, got)
329+ }
330+ }
331+ })
332+ t.Run("a lone column with cells cycles back to auto", func(t *testing.T) {
333+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
334+ w := cellsWorkspace(o, 4, 1)
335+ w.Columns[0].Cells = 2
336+ w.Apply(ActionCycleColumnWidth)
337+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0}) {
338+ t.Fatalf("%s: cells %v", o, got)
339+ }
340+ }
341+ })
317 t.Run("scroll", func(t *testing.T) {342 t.Run("scroll", func(t *testing.T) {
318 w := cellsWorkspace(OverflowScroll, 2, 2)343 w := cellsWorkspace(OverflowScroll, 2, 2)
319 w.Columns[0].Cells = 3344 w.Columns[0].Cells = 3
@@ -335,7 +360,7 @@ func TestCellsAboveMaxColumns(t *testing.T) {
335 t.Fatal("no column")360 t.Fatal("no column")
336 }361 }
337 dst.receive(col, 1)362 dst.receive(col, 1)
338- if got := cellsOf(dst); got[len(got)-1] != 3 && got[0] != 3 {363+ if got := cellsOf(dst); !reflect.DeepEqual(got, []int{0, 3}) {
339 t.Fatalf("cells %v", got)364 t.Fatalf("cells %v", got)
340 }365 }
341 })366 })
M internal/core/layout.go +5 -0
@@ -997,6 +997,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
997997 first, last := w.laneBounds(w.laneOf(w.Focus))
998998 n := last - first + 1
999999 if n == 1 || n >= k {
1000+ // Nothing to grow, but a lane left uneven (a column joined
1001+ // it, or max-columns shrank) still cycles back to equal.
1002+ if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
1003+ w.setLaneCells(first, make([]int, n))
1004+ }
10001005 return
10011006 }
10021007 cells, ok := w.laneCells(first, last, w.Focus)
@@ -997,6 +997,11 @@ func (w *Workspace) GrowColumn(cycle bool) {
997 first, last := w.laneBounds(w.laneOf(w.Focus))997 first, last := w.laneBounds(w.laneOf(w.Focus))
998 n := last - first + 1998 n := last - first + 1
999 if n == 1 || n >= k {999 if n == 1 || n >= k {
1000+ // Nothing to grow, but a lane left uneven (a column joined
1001+ // it, or max-columns shrank) still cycles back to equal.
1002+ if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) {
1003+ w.setLaneCells(first, make([]int, n))
1004+ }
1000 return1005 return
1001 }1006 }
1002 cells, ok := w.laneCells(first, last, w.Focus)1007 cells, ok := w.laneCells(first, last, w.Focus)

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