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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 224 | 224 | ||
| 225 | 225 | ### 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 | 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 | 239 | ### 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 | 243 | - `cascade`: the columns of a band share its row of cells. | |
| 244 | 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 | 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 | 264 | | `switch-column-prev` | The same, in the opposite direction | | |
| 265 | 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 | 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 | 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 | 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 | 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 | 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 | 273 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | |
| 274 | 274 | | `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 | ### Cascade | 225 | ### 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 widths | 239 | ### 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 { | |||
| 783 | 783 | } | |
| 784 | 784 | c.Layout.Gaps = n | |
| 785 | 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 | 787 | case "layout.max-columns": | |
| 788 | 788 | return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v) | |
| 789 | 789 | case "layout.overflow": | |
| @@ -783,7 +783,7 @@ func set(c *ports.Config, key, v string) error { | |||||
| 783 | } | 783 | } | ||
| 784 | c.Layout.Gaps = n | 784 | 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) { | |||
| 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 | 342 | t.Run("scroll", func(t *testing.T) { | |
| 318 | 343 | w := cellsWorkspace(OverflowScroll, 2, 2) | |
| 319 | 344 | w.Columns[0].Cells = 3 | |
| @@ -335,7 +360,7 @@ func TestCellsAboveMaxColumns(t *testing.T) { | |||
| 335 | 360 | t.Fatal("no column") | |
| 336 | 361 | } | |
| 337 | 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 | 364 | t.Fatalf("cells %v", got) | |
| 340 | 365 | } | |
| 341 | 366 | }) | |
| @@ -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 = 3 | 344 | 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) { | |||
| 997 | 997 | first, last := w.laneBounds(w.laneOf(w.Focus)) | |
| 998 | 998 | n := last - first + 1 | |
| 999 | 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 | 1005 | return | |
| 1001 | 1006 | } | |
| 1002 | 1007 | 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 + 1 | 998 | 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 | return | 1005 | return | ||
| 1001 | } | 1006 | } | ||
| 1002 | cells, ok := w.laneCells(first, last, w.Focus) | 1007 | cells, ok := w.laneCells(first, last, w.Focus) | ||