detached 0cfaceb

feat(core): grow the focused column by one cell with cycle-column-width

commit
0cfaceb577338b754dfdb03d4abc202732ff2860
author
brice · · 2026-10-12 09:50 UTC
parent
25f53bf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +403 -21
M README.md +4 -5
@@ -204,7 +204,6 @@ floating.dim = 0.3
204204 layout.gaps = 0
205205 layout.max-columns = 2
206206 layout.overflow = cascade
207-layout.presets = 1/3, 1/2, 2/3, 1
208207 stash.width = 80
209208 stash.gap = 2
210209 stash.dim = 0.5
@@ -267,12 +266,12 @@ bind.cmd+bracketleft = consume-or-expel-window-left
267266 bind.cmd+bracketright = consume-or-expel-window-right
268267
269268 # Resize and window modes
270-bind.cmd+alt+left = set-column-width -10%
271-bind.cmd+alt+right = set-column-width +10%
269+bind.cmd+alt+left = set-column-width -1
270+bind.cmd+alt+right = set-column-width +1
272271 bind.cmd+alt+up = set-window-height -10%
273272 bind.cmd+alt+down = set-window-height +10%
274-bind.cmd+alt+h = set-column-width -10%
275-bind.cmd+alt+l = set-column-width +10%
273+bind.cmd+alt+h = set-column-width -1
274+bind.cmd+alt+l = set-column-width +1
276275 bind.cmd+alt+k = set-window-height -10%
277276 bind.cmd+alt+j = set-window-height +10%
278277 bind.cmd+shift+space = toggle-floating
@@ -204,7 +204,6 @@ floating.dim = 0.3
204 layout.gaps = 0204 layout.gaps = 0
205 layout.max-columns = 2205 layout.max-columns = 2
206 layout.overflow = cascade206 layout.overflow = cascade
207-layout.presets = 1/3, 1/2, 2/3, 1
208 stash.width = 80207 stash.width = 80
209 stash.gap = 2208 stash.gap = 2
210 stash.dim = 0.5209 stash.dim = 0.5
@@ -267,12 +266,12 @@ bind.cmd+bracketleft = consume-or-expel-window-left
267 bind.cmd+bracketright = consume-or-expel-window-right266 bind.cmd+bracketright = consume-or-expel-window-right
268 267
269 # Resize and window modes268 # Resize and window modes
270-bind.cmd+alt+left = set-column-width -10%269+bind.cmd+alt+left = set-column-width -1
271-bind.cmd+alt+right = set-column-width +10%270+bind.cmd+alt+right = set-column-width +1
272 bind.cmd+alt+up = set-window-height -10%271 bind.cmd+alt+up = set-window-height -10%
273 bind.cmd+alt+down = set-window-height +10%272 bind.cmd+alt+down = set-window-height +10%
274-bind.cmd+alt+h = set-column-width -10%273+bind.cmd+alt+h = set-column-width -1
275-bind.cmd+alt+l = set-column-width +10%274+bind.cmd+alt+l = set-column-width +1
276 bind.cmd+alt+k = set-window-height -10%275 bind.cmd+alt+k = set-window-height -10%
277 bind.cmd+alt+j = set-window-height +10%276 bind.cmd+alt+j = set-window-height +10%
278 bind.cmd+shift+space = toggle-floating277 bind.cmd+shift+space = toggle-floating
M docs/config.md +23 -13
@@ -29,10 +29,9 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
2929 | `stash.capture` | `on` | `on`: a window opened while the stash is shown joins its end, selected; `toggle-window-stash` tiles it as a new column. `off`: new windows always tile |
3030 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |
3131 | `layout.gaps` | `0` | Space between windows |
32-| `layout.max-columns` | `2` | Columns that share the screen before scrolling |
32+| `layout.max-columns` | `2` | Columns that share the screen before the overflow applies. It also sets the cell: a column is `N` cells wide, a cell being `1/max-columns` of the usable width (see [Column widths](#column-widths)) |
3333 | `layout.overflow` | `cascade` | Past the max: `cascade` into vertically scrolling bands, `scroll` right, or `fixed` spiral split |
3434 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
35-| `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) |
3635 | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) |
3736 | `touchpad.tap` | `on` | Tap to click: one finger left, two right, three middle |
3837 | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) |
@@ -54,7 +53,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
5453 | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects |
5554 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
5655 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
57-| `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
56+| `workspace.<name>.column.<N>` | none | Slot: `<cells>, <command>`; cells is a whole number from 1 to 16 |
5857 | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) |
5958 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
6059 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
@@ -81,7 +80,7 @@ A window rule places the windows of an app when they first open. Rules are flat
8180 | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float |
8281 | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace |
8382 | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected |
84-| `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only |
83+| `rule.<name>.width` | Width of the window's new column in cells, a whole number from 1 to 16 (see [Column widths](#column-widths)); tiled windows only |
8584
8685 ```ini
8786 rule.games.app-id = steam_app_.*
@@ -89,7 +88,7 @@ rule.games.workspace = 3
8988 rule.games.monitor = DP-2
9089 rule.chat.app-id = discord|org\.telegram\..*
9190 rule.chat.workspace = chat
92-rule.chat.width = 1/3
91+rule.chat.width = 1
9392 rule.calc.app-id = org\.gnome\.Calculator
9493 rule.calc.floating = on
9594 ```
@@ -99,7 +98,6 @@ rule.calc.floating = on
9998 - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does.
10099 - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.
101100 - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.
102-- `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.
103101 - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key.
104102
105103 To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.
@@ -228,7 +226,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
228226
229227 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.
230228
231-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 width presets and resizing do nothing; window height shares still apply. 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.
232230
233231 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
234232
@@ -238,6 +236,18 @@ In the overview, up/down move to the previous or next band and stop at the first
238236
239237 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
240238
239+### Column widths
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:
242+
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.
245+- `scroll`: each column is sized on its own, up to `max-columns` cells; an auto column is the equal share of the columns in view.
246+
247+In a row, the auto columns share the cells the set ones leave; when no cell is left, an auto column counts for one. Columns with more cells than the row holds (after a smaller `max-columns`, or moved to another workspace) share the width in proportion. All-auto rows are laid out as equal shares.
248+
249+`cycle-column-width` gives the focused column one more cell, taking it from the widest other column of its row (the nearest, right side first, on a tie). A row that does not add up to `max-columns` cells is first spread evenly, the focused column taking the remainder, and that counts as the step. With no cell left to take, the row returns to equal shares. `set-column-width +N` repeats the grow without cycling; `-N` gives a cell to the right neighbor (the left one at the row's end), down to one cell.
250+
241251 ### Actions
242252
243253 | Action | Effect |
@@ -246,19 +256,19 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
246256 | `spawn-terminal` | Run `terminal` |
247257 | `close-window` | Close the focused window |
248258 | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
249-| `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
259+| `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
250260 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
251261 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |
252262 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
253263 | `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 |
254264 | `switch-column-prev` | The same, in the opposite direction |
255265 | `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 |
256-| `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |
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 |
257267 | `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 |
258268 | `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 |
259269 | `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 |
260270 | `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 |
261-| `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |
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 |
262272 | `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 |
263273 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
264274 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |
@@ -283,7 +293,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou
283293
284294 `toggle-floating` lifts the focused tile out of its column into a free floating window. NeferWL sets its size, and it stays where you put it, kept inside the usable area. Its place is relative to the screen, so it keeps it on another workspace or monitor of any size.
285295
286-On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.
296+On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it: a width step moves it by N cells (`Usable.W / max-columns` each), a height step by N% of the usable height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.
287297
288298 ### Moving windows with the pointer
289299
@@ -339,8 +349,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
339349 | `cmd+bracketright` | `consume-or-expel-window-right` |
340350 | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` |
341351 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |
342-| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |
343-| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |
352+| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -1` |
353+| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +1` |
344354 | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |
345355 | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |
346356 | `cmd+r` | `cycle-column-width` |
@@ -29,10 +29,9 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
29 | `stash.capture` | `on` | `on`: a window opened while the stash is shown joins its end, selected; `toggle-window-stash` tiles it as a new column. `off`: new windows always tile |29 | `stash.capture` | `on` | `on`: a window opened while the stash is shown joins its end, selected; `toggle-window-stash` tiles it as a new column. `off`: new windows always tile |
30 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |30 | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up |
31 | `layout.gaps` | `0` | Space between windows |31 | `layout.gaps` | `0` | Space between windows |
32-| `layout.max-columns` | `2` | Columns that share the screen before scrolling |32+| `layout.max-columns` | `2` | Columns that share the screen before the overflow applies. It also sets the cell: a column is `N` cells wide, a cell being `1/max-columns` of the usable width (see [Column widths](#column-widths)) |
33 | `layout.overflow` | `cascade` | Past the max: `cascade` into vertically scrolling bands, `scroll` right, or `fixed` spiral split |33 | `layout.overflow` | `cascade` | Past the max: `cascade` into vertically scrolling bands, `scroll` right, or `fixed` spiral split |
34 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |34 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
35-| `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) |
36 | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) |35 | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) |
37 | `touchpad.tap` | `on` | Tap to click: one finger left, two right, three middle |36 | `touchpad.tap` | `on` | Tap to click: one finger left, two right, three middle |
38 | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) |37 | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) |
@@ -54,7 +53,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
54 | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects |53 | `workspace.<name>.monitor` | focused output | Home monitor: connector (`DP-2`) or monitor key. A `workspace <name>` bind focuses it there. Unset, or while home is absent, the workspace comes to the focused output; it returns home when it reconnects |
55 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |54 | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale |
56 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |55 | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout |
57-| `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |56+| `workspace.<name>.column.<N>` | none | Slot: `<cells>, <command>`; cells is a whole number from 1 to 16 |
58 | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) |57 | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) |
59 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |58 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
60 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |59 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
@@ -81,7 +80,7 @@ A window rule places the windows of an app when they first open. Rules are flat
81 | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float |80 | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float |
82 | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace |81 | `rule.<name>.workspace` | A workspace number (1-based, past the last one means the last) or the name of a declared `workspace.<name>.*` workspace |
83 | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected |82 | `rule.<name>.monitor` | A connector (`DP-2`) or a monitor key (`make model serial`); the focused output when it is not connected |
84-| `rule.<name>.width` | Width of the window's new column, like a column width in `workspace.<name>.column.N`: `50%`, `1/3`, `800px` or `1`; tiled windows only |83+| `rule.<name>.width` | Width of the window's new column in cells, a whole number from 1 to 16 (see [Column widths](#column-widths)); tiled windows only |
85 84
86 ```ini85 ```ini
87 rule.games.app-id = steam_app_.*86 rule.games.app-id = steam_app_.*
@@ -89,7 +88,7 @@ rule.games.workspace = 3
89 rule.games.monitor = DP-288 rule.games.monitor = DP-2
90 rule.chat.app-id = discord|org\.telegram\..*89 rule.chat.app-id = discord|org\.telegram\..*
91 rule.chat.workspace = chat90 rule.chat.workspace = chat
92-rule.chat.width = 1/391+rule.chat.width = 1
93 rule.calc.app-id = org\.gnome\.Calculator92 rule.calc.app-id = org\.gnome\.Calculator
94 rule.calc.floating = on93 rule.calc.floating = on
95 ```94 ```
@@ -99,7 +98,6 @@ rule.calc.floating = on
99 - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does.98 - A rule never moves you. The window opens on its target workspace without taking the focus or switching the workspace or monitor you are on. When the target is the workspace on screen of the focused output, the window opens as any new window does.
100 - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.99 - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.
101 - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.100 - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.
102-- `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.
103 - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key.101 - An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key.
104 102
105 To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.103 To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.
@@ -228,7 +226,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
228 226
229 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 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.
230 228
231-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 width presets and resizing do nothing; window height shares still apply. 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.
232 230
233 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.231 Three-finger vertical swipes scroll bands; horizontal swipes switch workspaces. Scroll and fixed workspaces retain their existing gesture axes. A switch keeps the source workspace's axis throughout its transition; the destination's axes apply to the next gesture. The `focus-workspace-prev/next` commands switch to the previous or next workspace, whether the transition is vertical or horizontal. Four-finger and stash gestures keep their existing behaviour.
234 232
@@ -238,6 +236,18 @@ In the overview, up/down move to the previous or next band and stop at the first
238 236
239 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.237 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
240 238
239+### Column widths
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:
242+
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.
245+- `scroll`: each column is sized on its own, up to `max-columns` cells; an auto column is the equal share of the columns in view.
246+
247+In a row, the auto columns share the cells the set ones leave; when no cell is left, an auto column counts for one. Columns with more cells than the row holds (after a smaller `max-columns`, or moved to another workspace) share the width in proportion. All-auto rows are laid out as equal shares.
248+
249+`cycle-column-width` gives the focused column one more cell, taking it from the widest other column of its row (the nearest, right side first, on a tie). A row that does not add up to `max-columns` cells is first spread evenly, the focused column taking the remainder, and that counts as the step. With no cell left to take, the row returns to equal shares. `set-column-width +N` repeats the grow without cycling; `-N` gives a cell to the right neighbor (the left one at the row's end), down to one cell.
250+
241 ### Actions251 ### Actions
242 252
243 | Action | Effect |253 | Action | Effect |
@@ -246,19 +256,19 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
246 | `spawn-terminal` | Run `terminal` |256 | `spawn-terminal` | Run `terminal` |
247 | `close-window` | Close the focused window |257 | `close-window` | Close the focused window |
248 | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |258 | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
249-| `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |259+| `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
250 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |260 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
251 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |261 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Over a fullscreen window, shows the stash on top with the focus; the window stays fullscreen behind (composed, so no direct scanout while the stash shows) and is back when the stash hides or the focus leaves it. `toggle-window-stash` or `toggle-fullscreen` on a stashed window there leaves that fullscreen for it. New windows never join or show over it; the fullscreen window's own dialogs show above it |
252 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |262 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
253 | `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 |
254 | `switch-column-prev` | The same, in the opposite direction |264 | `switch-column-prev` | The same, in the opposite direction |
255 | `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 |
256-| `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time |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 |
257 | `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 |
258 | `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 |
259 | `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 |
260 | `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 |
261-| `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |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 |
262 | `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 |
263 | `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 |
264 | `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 |
@@ -283,7 +293,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou
283 293
284 `toggle-floating` lifts the focused tile out of its column into a free floating window. NeferWL sets its size, and it stays where you put it, kept inside the usable area. Its place is relative to the screen, so it keeps it on another workspace or monitor of any size.294 `toggle-floating` lifts the focused tile out of its column into a free floating window. NeferWL sets its size, and it stays where you put it, kept inside the usable area. Its place is relative to the screen, so it keeps it on another workspace or monitor of any size.
285 295
286-On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it by a share of the usable width or height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.296+On a free floating window, `move-column-left/right` and `move-window-up/down` move it by 50 logical pixels, and `set-column-width`/`set-window-height` resize it: a width step moves it by N cells (`Usable.W / max-columns` each), a height step by N% of the usable height. `toggle-window-stash` stashes it and brings it back floating at the same place. `toggle-floating` returns it to its column.
287 297
288 ### Moving windows with the pointer298 ### Moving windows with the pointer
289 299
@@ -339,8 +349,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
339 | `cmd+bracketright` | `consume-or-expel-window-right` |349 | `cmd+bracketright` | `consume-or-expel-window-right` |
340 | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` |350 | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` |
341 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |351 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |
342-| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |352+| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -1` |
343-| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |353+| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +1` |
344 | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |354 | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |
345 | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |355 | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |
346 | `cmd+r` | `cycle-column-width` |356 | `cmd+r` | `cycle-column-width` |
M docs/desktop.md +1 -1
@@ -141,7 +141,7 @@ While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland so
141141 - the focused output and window;
142142 - every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);
143143 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.
144-- for tiled windows, their 1-based `column` (layout order, left to right with `overflow = "scroll"`; with `overflow = "fixed"`, spiral and expanded columns break that, so a later column can sit below or beside an earlier one) and `row` (top to bottom in the column); both are `0` when `floating` is true. Sort by `column`, `row` for layout order.
144+- for tiled windows, their 1-based `column` (layout order, left to right with `overflow = "scroll"`; with `overflow = "fixed"`, the spiral breaks that, so a later column can sit below or beside an earlier one) and `row` (top to bottom in the column); both are `0` when `floating` is true. Sort by `column`, `row` for layout order.
145145
146146 ```sh
147147 neferwl state # the whole state as JSON
@@ -141,7 +141,7 @@ While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland so
141 - the focused output and window;141 - the focused output and window;
142 - every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);142 - every window, with its app ID, PID, output, workspace, `workspace_id` (the ID string of its workspace, numbered or hidden) and whether it is on screen (`visible`);
143 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.143 - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden.
144-- for tiled windows, their 1-based `column` (layout order, left to right with `overflow = "scroll"`; with `overflow = "fixed"`, spiral and expanded columns break that, so a later column can sit below or beside an earlier one) and `row` (top to bottom in the column); both are `0` when `floating` is true. Sort by `column`, `row` for layout order.144+- for tiled windows, their 1-based `column` (layout order, left to right with `overflow = "scroll"`; with `overflow = "fixed"`, the spiral breaks that, so a later column can sit below or beside an earlier one) and `row` (top to bottom in the column); both are `0` when `floating` is true. Sort by `column`, `row` for layout order.
145 145
146 ```sh146 ```sh
147 neferwl state # the whole state as JSON147 neferwl state # the whole state as JSON
A internal/core/column_cells_test.go +373 -0
@@ -0,0 +1,373 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+// cellsWorkspace is n columns on an 800 px wide output, no gaps, so a cell
9+// of max-columns k is 800/k px.
10+func cellsWorkspace(o Overflow, k, n int) *Workspace {
11+ w := &Workspace{Overflow: o, MaxColumns: k}
12+ w.SetOutput(800, 600)
13+ for id := 1; id <= n; id++ {
14+ w.AddWindow(WindowID(id))
15+ }
16+ return w
17+}
18+
19+func cellsOf(w *Workspace) []int {
20+ got := make([]int, len(w.Columns))
21+ for i, c := range w.Columns {
22+ got[i] = c.Cells
23+ }
24+ return got
25+}
26+
27+func widthsOf(w *Workspace) []int {
28+ got := make([]int, len(w.Columns))
29+ for i, r := range w.columnRects() {
30+ got[i] = r.W
31+ }
32+ return got
33+}
34+
35+// The grow cycle of cycle-column-width, in the three overflows (AC1 to AC4).
36+func TestGrowColumnCycle(t *testing.T) {
37+ type step struct {
38+ cells []int
39+ widths []int // nil: not checked
40+ }
41+ for _, tc := range []struct {
42+ name string
43+ o Overflow
44+ k, n int
45+ focus int // window ID
46+ steps []step
47+ }{
48+ {"cascade two columns", OverflowCascade, 4, 2, 2, []step{
49+ {[]int{1, 3}, []int{200, 600}},
50+ {[]int{0, 0}, []int{400, 400}},
51+ {[]int{1, 3}, []int{200, 600}},
52+ }},
53+ {"fixed two columns", OverflowFixed, 4, 2, 2, []step{
54+ {[]int{1, 3}, []int{200, 600}},
55+ {[]int{0, 0}, []int{400, 400}},
56+ }},
57+ {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
58+ {[]int{1, 2, 1}, []int{200, 400, 200}},
59+ {[]int{0, 0, 0}, []int{266, 266, 266}},
60+ }},
61+ {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
62+ {[]int{1, 2, 1}, []int{200, 400, 200}},
63+ {[]int{0, 0, 0}, []int{266, 266, 266}},
64+ }},
65+ {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
66+ // 5 cells over 3 columns: 1 each and the 2 left to the focused one.
67+ {[]int{3, 1, 1}, nil},
68+ // No column has a cell to give: equal shares again.
69+ {[]int{0, 0, 0}, nil},
70+ {[]int{3, 1, 1}, nil},
71+ }},
72+ {"an even lane steps at once, the nearest tied donor first", OverflowCascade, 6, 3, 3, []step{
73+ {[]int{2, 1, 3}, nil},
74+ {[]int{1, 1, 4}, nil},
75+ {[]int{0, 0, 0}, nil},
76+ }},
77+ {"scroll unset column", OverflowScroll, 4, 2, 2, []step{
78+ {[]int{0, 3}, []int{400, 600}},
79+ {[]int{0, 4}, []int{400, 800}},
80+ {[]int{0, 0}, []int{400, 400}},
81+ {[]int{0, 3}, []int{400, 600}},
82+ }},
83+ {"scroll lone column already fills the width", OverflowScroll, 4, 1, 1, []step{
84+ {[]int{0}, []int{800}},
85+ }},
86+ {"scroll three columns", OverflowScroll, 4, 3, 1, []step{
87+ {[]int{2, 0, 0}, nil},
88+ {[]int{3, 0, 0}, nil},
89+ {[]int{4, 0, 0}, nil},
90+ {[]int{0, 0, 0}, nil},
91+ }},
92+ } {
93+ t.Run(tc.name, func(t *testing.T) {
94+ w := cellsWorkspace(tc.o, tc.k, tc.n)
95+ w.FocusID(WindowID(tc.focus))
96+ for i, s := range tc.steps {
97+ w.Apply(ActionCycleColumnWidth)
98+ if got := cellsOf(w); !reflect.DeepEqual(got, s.cells) {
99+ t.Fatalf("step %d: cells %v, want %v", i, got, s.cells)
100+ }
101+ if s.widths != nil {
102+ if got := widthsOf(w); !reflect.DeepEqual(got, s.widths) {
103+ t.Fatalf("step %d: widths %v, want %v", i, got, s.widths)
104+ }
105+ }
106+ }
107+ })
108+ }
109+}
110+
111+// A lone column and a full lane do not change (AC3).
112+func TestGrowColumnNoop(t *testing.T) {
113+ for _, tc := range []struct {
114+ name string
115+ o Overflow
116+ k, n int
117+ }{
118+ {"cascade lone", OverflowCascade, 4, 1},
119+ {"fixed lone", OverflowFixed, 4, 1},
120+ {"cascade full band", OverflowCascade, 4, 4},
121+ {"cascade full bands", OverflowCascade, 3, 6},
122+ {"fixed full row", OverflowFixed, 4, 4},
123+ {"fixed spiral", OverflowFixed, 2, 4},
124+ {"max-columns 1", OverflowCascade, 1, 3},
125+ } {
126+ t.Run(tc.name, func(t *testing.T) {
127+ w := cellsWorkspace(tc.o, tc.k, tc.n)
128+ before := w.columnRects()
129+ for _, a := range []Action{ActionCycleColumnWidth, "set-column-width +1", "set-column-width -1"} {
130+ w.Apply(a)
131+ if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, tc.n)) {
132+ t.Fatalf("%s: cells %v", a, got)
133+ }
134+ if got := w.columnRects(); !reflect.DeepEqual(got, before) {
135+ t.Fatalf("%s: rects %v, want %v", a, got, before)
136+ }
137+ }
138+ })
139+ }
140+}
141+
142+// set-column-width +N/-N grows or shrinks by cells without cycling, and a
143+// column never goes below one cell (AC5).
144+func TestSetColumnWidthCells(t *testing.T) {
145+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
146+ t.Run(string(o), func(t *testing.T) {
147+ w := cellsWorkspace(o, 4, 2)
148+ w.FocusID(2)
149+ for _, s := range []struct {
150+ a Action
151+ cells []int
152+ }{
153+ {"set-column-width +1", []int{1, 3}},
154+ {"set-column-width +1", []int{1, 3}}, // no donor: no cycle
155+ {"set-column-width -1", []int{2, 2}},
156+ {"set-column-width -1", []int{3, 1}},
157+ {"set-column-width -1", []int{3, 1}}, // never below one cell
158+ {"set-column-width +2", []int{1, 3}},
159+ {"set-column-width -3", []int{3, 1}},
160+ } {
161+ w.Apply(s.a)
162+ if got := cellsOf(w); !reflect.DeepEqual(got, s.cells) {
163+ t.Fatalf("%s: cells %v, want %v", s.a, got, s.cells)
164+ }
165+ }
166+ // The leftmost column at the lane's end gives to its left neighbor.
167+ w.FocusID(1)
168+ w.Apply("set-column-width -1")
169+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2}) {
170+ t.Fatalf("cells %v", got)
171+ }
172+ })
173+ }
174+ t.Run("shrink gives to the right, or to the left at the end", func(t *testing.T) {
175+ w := cellsWorkspace(OverflowCascade, 6, 3)
176+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells = 2, 2, 2
177+ w.FocusID(2)
178+ w.Apply("set-column-width -1")
179+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 1, 3}) {
180+ t.Fatalf("cells %v", got)
181+ }
182+ w.FocusID(3)
183+ w.Apply("set-column-width -1")
184+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2, 2}) {
185+ t.Fatalf("cells %v", got)
186+ }
187+ })
188+ t.Run("unnormalized lane: the shrink gives the remainder to the leftmost", func(t *testing.T) {
189+ w := cellsWorkspace(OverflowCascade, 5, 3)
190+ w.FocusID(3)
191+ w.Apply("set-column-width -1") // base 2,2,1 and column 3 has one cell
192+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0}) {
193+ t.Fatalf("cells %v: nothing is written when nothing changes", got)
194+ }
195+ w.FocusID(2)
196+ w.Apply("set-column-width -1") // 2,2,1 -> 2,1,2
197+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 1, 2}) {
198+ t.Fatalf("cells %v", got)
199+ }
200+ })
201+}
202+
203+func TestGrowColumnScrollBounds(t *testing.T) {
204+ w := cellsWorkspace(OverflowScroll, 4, 2)
205+ w.FocusID(2)
206+ w.Apply("set-column-width -1") // auto is 2 cells
207+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 1}) {
208+ t.Fatalf("cells %v", got)
209+ }
210+ for range 3 {
211+ w.Apply("set-column-width -1")
212+ }
213+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 1}) {
214+ t.Fatalf("below one cell: %v", got)
215+ }
216+ for range 6 {
217+ w.Apply("set-column-width +1")
218+ }
219+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 4}) {
220+ t.Fatalf("above max-columns: %v", got)
221+ }
222+}
223+
224+func TestGrowColumnEdgeCases(t *testing.T) {
225+ t.Run("a float focused changes nothing", func(t *testing.T) {
226+ for _, o := range []Overflow{OverflowScroll, OverflowCascade, OverflowFixed} {
227+ w := cellsWorkspace(o, 4, 2)
228+ w.AddFloating(9, 100, 100)
229+ w.Apply(ActionCycleColumnWidth)
230+ w.Apply("set-column-width +1")
231+ w.Apply("set-column-width -1")
232+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
233+ t.Fatalf("%s: cells %v", o, got)
234+ }
235+ }
236+ })
237+ t.Run("a maximized column unmaximizes, then grows", func(t *testing.T) {
238+ for _, o := range []Overflow{OverflowScroll, OverflowCascade, OverflowFixed} {
239+ w := cellsWorkspace(o, 4, 2)
240+ w.ToggleFullWidth()
241+ w.Apply(ActionCycleColumnWidth)
242+ if w.Columns[w.Focus].FullWidth || w.Columns[w.Focus].Cells != 3 {
243+ t.Fatalf("%s: %+v", o, w.Columns)
244+ }
245+ }
246+ })
247+ t.Run("a fullscreen window hides the layout: no edit", func(t *testing.T) {
248+ w := cellsWorkspace(OverflowFixed, 4, 2)
249+ w.ToggleFullscreen()
250+ w.Apply(ActionCycleColumnWidth)
251+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
252+ t.Fatalf("cells %v", got)
253+ }
254+ })
255+ t.Run("the donor is the largest, then the nearest, right first", func(t *testing.T) {
256+ w := cellsWorkspace(OverflowCascade, 5, 3)
257+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells = 2, 1, 2
258+ w.FocusID(2)
259+ w.Apply(ActionCycleColumnWidth)
260+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2, 1}) {
261+ t.Fatalf("tie: cells %v", got)
262+ }
263+ // C is focused with {2,2,1}: A and B tie, the nearer one gives.
264+ w.FocusID(3)
265+ w.Apply(ActionCycleColumnWidth)
266+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 1, 2}) {
267+ t.Fatalf("largest: cells %v", got)
268+ }
269+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells = 3, 1, 1
270+ w.FocusID(2)
271+ w.Apply(ActionCycleColumnWidth)
272+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2, 1}) {
273+ t.Fatalf("largest: cells %v", got)
274+ }
275+ })
276+ t.Run("only the focused band changes", func(t *testing.T) {
277+ w := cellsWorkspace(OverflowCascade, 4, 6)
278+ // Band 1 holds columns 5 and 6.
279+ w.Apply(ActionCycleColumnWidth)
280+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 1, 3}) {
281+ t.Fatalf("cells %v", got)
282+ }
283+ if got := widthsOf(w); !reflect.DeepEqual(got, []int{200, 200, 200, 200, 200, 600}) {
284+ t.Fatalf("widths %v", got)
285+ }
286+ w.Apply(ActionCycleColumnWidth)
287+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) {
288+ t.Fatalf("cells %v", got)
289+ }
290+ w.FocusID(1) // a full band
291+ w.Apply(ActionCycleColumnWidth)
292+ if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, 6)) {
293+ t.Fatalf("cells %v", got)
294+ }
295+ })
296+}
297+
298+// A column set above max-columns (moved to a narrower workspace, or after a
299+// reload) renders proportionally and cycles back to auto.
300+func TestCellsAboveMaxColumns(t *testing.T) {
301+ t.Run("lane", func(t *testing.T) {
302+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
303+ w := cellsWorkspace(o, 2, 2)
304+ w.Columns[0].Cells = 3
305+ if got := widthsOf(w); !reflect.DeepEqual(got, []int{600, 200}) {
306+ t.Fatalf("%s: widths %v", o, got)
307+ }
308+ if o == OverflowCascade {
309+ continue
310+ }
311+ // A narrower K does not rewrite the stored cells.
312+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{3, 0}) {
313+ t.Fatalf("cells %v", got)
314+ }
315+ }
316+ })
317+ t.Run("scroll", func(t *testing.T) {
318+ w := cellsWorkspace(OverflowScroll, 2, 2)
319+ w.Columns[0].Cells = 3
320+ w.FocusID(1)
321+ if got := widthsOf(w)[0]; got != 800 {
322+ t.Fatalf("width %d, at most max-columns cells", got)
323+ }
324+ w.Apply(ActionCycleColumnWidth)
325+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
326+ t.Fatalf("cells %v", got)
327+ }
328+ })
329+ t.Run("a moved column keeps its cells", func(t *testing.T) {
330+ src := cellsWorkspace(OverflowScroll, 4, 2)
331+ src.Columns[1].Cells = 3
332+ dst := cellsWorkspace(OverflowScroll, 2, 1)
333+ col, ok := src.takeColumn()
334+ if !ok {
335+ t.Fatal("no column")
336+ }
337+ dst.receive(col, 1)
338+ if got := cellsOf(dst); got[len(got)-1] != 3 && got[0] != 3 {
339+ t.Fatalf("cells %v", got)
340+ }
341+ })
342+}
343+
344+// The overview blocks the grow and resize binds, like the other window
345+// mutations, and its cascade preview keeps the columns' cells.
346+func TestOverviewKeepsCells(t *testing.T) {
347+ m := newMonitor("", "")
348+ m.SetOutput(900, 600)
349+ m.SetOverflow(OverflowCascade)
350+ m.SetMaxColumns(4)
351+ for id := WindowID(1); id <= 2; id++ {
352+ m.AddWindow(id)
353+ }
354+ w := m.Current()
355+ w.Columns[0].Cells, w.Columns[1].Cells = 1, 3
356+ screen := w.columnRects()
357+ if screen[0].W != 225 || screen[1].W != 675 {
358+ t.Fatalf("screen widths %v, want 225 and 675", screen)
359+ }
360+ m.ToggleOverview()
361+ tiles, _, _ := w.previewTiles()
362+ for i, p := range tiles {
363+ if want := screen[i]; p.Rect.W != want.W || p.Rect.X != want.X-w.Usable.X || p.Rect.H != want.H {
364+ t.Fatalf("column %d: preview %+v, screen %+v", i, p.Rect, want)
365+ }
366+ }
367+ for _, a := range []Action{ActionCycleColumnWidth, "set-column-width +1", "set-column-width -1"} {
368+ m.Apply(a)
369+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{1, 3}) {
370+ t.Fatalf("%s in the overview: cells %v", a, got)
371+ }
372+ }
373+}
@@ -0,0 +1,373 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+// cellsWorkspace is n columns on an 800 px wide output, no gaps, so a cell
9+// of max-columns k is 800/k px.
10+func cellsWorkspace(o Overflow, k, n int) *Workspace {
11+ w := &Workspace{Overflow: o, MaxColumns: k}
12+ w.SetOutput(800, 600)
13+ for id := 1; id <= n; id++ {
14+ w.AddWindow(WindowID(id))
15+ }
16+ return w
17+}
18+
19+func cellsOf(w *Workspace) []int {
20+ got := make([]int, len(w.Columns))
21+ for i, c := range w.Columns {
22+ got[i] = c.Cells
23+ }
24+ return got
25+}
26+
27+func widthsOf(w *Workspace) []int {
28+ got := make([]int, len(w.Columns))
29+ for i, r := range w.columnRects() {
30+ got[i] = r.W
31+ }
32+ return got
33+}
34+
35+// The grow cycle of cycle-column-width, in the three overflows (AC1 to AC4).
36+func TestGrowColumnCycle(t *testing.T) {
37+ type step struct {
38+ cells []int
39+ widths []int // nil: not checked
40+ }
41+ for _, tc := range []struct {
42+ name string
43+ o Overflow
44+ k, n int
45+ focus int // window ID
46+ steps []step
47+ }{
48+ {"cascade two columns", OverflowCascade, 4, 2, 2, []step{
49+ {[]int{1, 3}, []int{200, 600}},
50+ {[]int{0, 0}, []int{400, 400}},
51+ {[]int{1, 3}, []int{200, 600}},
52+ }},
53+ {"fixed two columns", OverflowFixed, 4, 2, 2, []step{
54+ {[]int{1, 3}, []int{200, 600}},
55+ {[]int{0, 0}, []int{400, 400}},
56+ }},
57+ {"cascade three columns", OverflowCascade, 4, 3, 2, []step{
58+ {[]int{1, 2, 1}, []int{200, 400, 200}},
59+ {[]int{0, 0, 0}, []int{266, 266, 266}},
60+ }},
61+ {"fixed three columns", OverflowFixed, 4, 3, 2, []step{
62+ {[]int{1, 2, 1}, []int{200, 400, 200}},
63+ {[]int{0, 0, 0}, []int{266, 266, 266}},
64+ }},
65+ {"remainder goes to the focused column and is the step", OverflowCascade, 5, 3, 1, []step{
66+ // 5 cells over 3 columns: 1 each and the 2 left to the focused one.
67+ {[]int{3, 1, 1}, nil},
68+ // No column has a cell to give: equal shares again.
69+ {[]int{0, 0, 0}, nil},
70+ {[]int{3, 1, 1}, nil},
71+ }},
72+ {"an even lane steps at once, the nearest tied donor first", OverflowCascade, 6, 3, 3, []step{
73+ {[]int{2, 1, 3}, nil},
74+ {[]int{1, 1, 4}, nil},
75+ {[]int{0, 0, 0}, nil},
76+ }},
77+ {"scroll unset column", OverflowScroll, 4, 2, 2, []step{
78+ {[]int{0, 3}, []int{400, 600}},
79+ {[]int{0, 4}, []int{400, 800}},
80+ {[]int{0, 0}, []int{400, 400}},
81+ {[]int{0, 3}, []int{400, 600}},
82+ }},
83+ {"scroll lone column already fills the width", OverflowScroll, 4, 1, 1, []step{
84+ {[]int{0}, []int{800}},
85+ }},
86+ {"scroll three columns", OverflowScroll, 4, 3, 1, []step{
87+ {[]int{2, 0, 0}, nil},
88+ {[]int{3, 0, 0}, nil},
89+ {[]int{4, 0, 0}, nil},
90+ {[]int{0, 0, 0}, nil},
91+ }},
92+ } {
93+ t.Run(tc.name, func(t *testing.T) {
94+ w := cellsWorkspace(tc.o, tc.k, tc.n)
95+ w.FocusID(WindowID(tc.focus))
96+ for i, s := range tc.steps {
97+ w.Apply(ActionCycleColumnWidth)
98+ if got := cellsOf(w); !reflect.DeepEqual(got, s.cells) {
99+ t.Fatalf("step %d: cells %v, want %v", i, got, s.cells)
100+ }
101+ if s.widths != nil {
102+ if got := widthsOf(w); !reflect.DeepEqual(got, s.widths) {
103+ t.Fatalf("step %d: widths %v, want %v", i, got, s.widths)
104+ }
105+ }
106+ }
107+ })
108+ }
109+}
110+
111+// A lone column and a full lane do not change (AC3).
112+func TestGrowColumnNoop(t *testing.T) {
113+ for _, tc := range []struct {
114+ name string
115+ o Overflow
116+ k, n int
117+ }{
118+ {"cascade lone", OverflowCascade, 4, 1},
119+ {"fixed lone", OverflowFixed, 4, 1},
120+ {"cascade full band", OverflowCascade, 4, 4},
121+ {"cascade full bands", OverflowCascade, 3, 6},
122+ {"fixed full row", OverflowFixed, 4, 4},
123+ {"fixed spiral", OverflowFixed, 2, 4},
124+ {"max-columns 1", OverflowCascade, 1, 3},
125+ } {
126+ t.Run(tc.name, func(t *testing.T) {
127+ w := cellsWorkspace(tc.o, tc.k, tc.n)
128+ before := w.columnRects()
129+ for _, a := range []Action{ActionCycleColumnWidth, "set-column-width +1", "set-column-width -1"} {
130+ w.Apply(a)
131+ if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, tc.n)) {
132+ t.Fatalf("%s: cells %v", a, got)
133+ }
134+ if got := w.columnRects(); !reflect.DeepEqual(got, before) {
135+ t.Fatalf("%s: rects %v, want %v", a, got, before)
136+ }
137+ }
138+ })
139+ }
140+}
141+
142+// set-column-width +N/-N grows or shrinks by cells without cycling, and a
143+// column never goes below one cell (AC5).
144+func TestSetColumnWidthCells(t *testing.T) {
145+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
146+ t.Run(string(o), func(t *testing.T) {
147+ w := cellsWorkspace(o, 4, 2)
148+ w.FocusID(2)
149+ for _, s := range []struct {
150+ a Action
151+ cells []int
152+ }{
153+ {"set-column-width +1", []int{1, 3}},
154+ {"set-column-width +1", []int{1, 3}}, // no donor: no cycle
155+ {"set-column-width -1", []int{2, 2}},
156+ {"set-column-width -1", []int{3, 1}},
157+ {"set-column-width -1", []int{3, 1}}, // never below one cell
158+ {"set-column-width +2", []int{1, 3}},
159+ {"set-column-width -3", []int{3, 1}},
160+ } {
161+ w.Apply(s.a)
162+ if got := cellsOf(w); !reflect.DeepEqual(got, s.cells) {
163+ t.Fatalf("%s: cells %v, want %v", s.a, got, s.cells)
164+ }
165+ }
166+ // The leftmost column at the lane's end gives to its left neighbor.
167+ w.FocusID(1)
168+ w.Apply("set-column-width -1")
169+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2}) {
170+ t.Fatalf("cells %v", got)
171+ }
172+ })
173+ }
174+ t.Run("shrink gives to the right, or to the left at the end", func(t *testing.T) {
175+ w := cellsWorkspace(OverflowCascade, 6, 3)
176+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells = 2, 2, 2
177+ w.FocusID(2)
178+ w.Apply("set-column-width -1")
179+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 1, 3}) {
180+ t.Fatalf("cells %v", got)
181+ }
182+ w.FocusID(3)
183+ w.Apply("set-column-width -1")
184+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2, 2}) {
185+ t.Fatalf("cells %v", got)
186+ }
187+ })
188+ t.Run("unnormalized lane: the shrink gives the remainder to the leftmost", func(t *testing.T) {
189+ w := cellsWorkspace(OverflowCascade, 5, 3)
190+ w.FocusID(3)
191+ w.Apply("set-column-width -1") // base 2,2,1 and column 3 has one cell
192+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0}) {
193+ t.Fatalf("cells %v: nothing is written when nothing changes", got)
194+ }
195+ w.FocusID(2)
196+ w.Apply("set-column-width -1") // 2,2,1 -> 2,1,2
197+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 1, 2}) {
198+ t.Fatalf("cells %v", got)
199+ }
200+ })
201+}
202+
203+func TestGrowColumnScrollBounds(t *testing.T) {
204+ w := cellsWorkspace(OverflowScroll, 4, 2)
205+ w.FocusID(2)
206+ w.Apply("set-column-width -1") // auto is 2 cells
207+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 1}) {
208+ t.Fatalf("cells %v", got)
209+ }
210+ for range 3 {
211+ w.Apply("set-column-width -1")
212+ }
213+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 1}) {
214+ t.Fatalf("below one cell: %v", got)
215+ }
216+ for range 6 {
217+ w.Apply("set-column-width +1")
218+ }
219+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 4}) {
220+ t.Fatalf("above max-columns: %v", got)
221+ }
222+}
223+
224+func TestGrowColumnEdgeCases(t *testing.T) {
225+ t.Run("a float focused changes nothing", func(t *testing.T) {
226+ for _, o := range []Overflow{OverflowScroll, OverflowCascade, OverflowFixed} {
227+ w := cellsWorkspace(o, 4, 2)
228+ w.AddFloating(9, 100, 100)
229+ w.Apply(ActionCycleColumnWidth)
230+ w.Apply("set-column-width +1")
231+ w.Apply("set-column-width -1")
232+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
233+ t.Fatalf("%s: cells %v", o, got)
234+ }
235+ }
236+ })
237+ t.Run("a maximized column unmaximizes, then grows", func(t *testing.T) {
238+ for _, o := range []Overflow{OverflowScroll, OverflowCascade, OverflowFixed} {
239+ w := cellsWorkspace(o, 4, 2)
240+ w.ToggleFullWidth()
241+ w.Apply(ActionCycleColumnWidth)
242+ if w.Columns[w.Focus].FullWidth || w.Columns[w.Focus].Cells != 3 {
243+ t.Fatalf("%s: %+v", o, w.Columns)
244+ }
245+ }
246+ })
247+ t.Run("a fullscreen window hides the layout: no edit", func(t *testing.T) {
248+ w := cellsWorkspace(OverflowFixed, 4, 2)
249+ w.ToggleFullscreen()
250+ w.Apply(ActionCycleColumnWidth)
251+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
252+ t.Fatalf("cells %v", got)
253+ }
254+ })
255+ t.Run("the donor is the largest, then the nearest, right first", func(t *testing.T) {
256+ w := cellsWorkspace(OverflowCascade, 5, 3)
257+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells = 2, 1, 2
258+ w.FocusID(2)
259+ w.Apply(ActionCycleColumnWidth)
260+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2, 1}) {
261+ t.Fatalf("tie: cells %v", got)
262+ }
263+ // C is focused with {2,2,1}: A and B tie, the nearer one gives.
264+ w.FocusID(3)
265+ w.Apply(ActionCycleColumnWidth)
266+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 1, 2}) {
267+ t.Fatalf("largest: cells %v", got)
268+ }
269+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells = 3, 1, 1
270+ w.FocusID(2)
271+ w.Apply(ActionCycleColumnWidth)
272+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{2, 2, 1}) {
273+ t.Fatalf("largest: cells %v", got)
274+ }
275+ })
276+ t.Run("only the focused band changes", func(t *testing.T) {
277+ w := cellsWorkspace(OverflowCascade, 4, 6)
278+ // Band 1 holds columns 5 and 6.
279+ w.Apply(ActionCycleColumnWidth)
280+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 1, 3}) {
281+ t.Fatalf("cells %v", got)
282+ }
283+ if got := widthsOf(w); !reflect.DeepEqual(got, []int{200, 200, 200, 200, 200, 600}) {
284+ t.Fatalf("widths %v", got)
285+ }
286+ w.Apply(ActionCycleColumnWidth)
287+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) {
288+ t.Fatalf("cells %v", got)
289+ }
290+ w.FocusID(1) // a full band
291+ w.Apply(ActionCycleColumnWidth)
292+ if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, 6)) {
293+ t.Fatalf("cells %v", got)
294+ }
295+ })
296+}
297+
298+// A column set above max-columns (moved to a narrower workspace, or after a
299+// reload) renders proportionally and cycles back to auto.
300+func TestCellsAboveMaxColumns(t *testing.T) {
301+ t.Run("lane", func(t *testing.T) {
302+ for _, o := range []Overflow{OverflowCascade, OverflowFixed} {
303+ w := cellsWorkspace(o, 2, 2)
304+ w.Columns[0].Cells = 3
305+ if got := widthsOf(w); !reflect.DeepEqual(got, []int{600, 200}) {
306+ t.Fatalf("%s: widths %v", o, got)
307+ }
308+ if o == OverflowCascade {
309+ continue
310+ }
311+ // A narrower K does not rewrite the stored cells.
312+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{3, 0}) {
313+ t.Fatalf("cells %v", got)
314+ }
315+ }
316+ })
317+ t.Run("scroll", func(t *testing.T) {
318+ w := cellsWorkspace(OverflowScroll, 2, 2)
319+ w.Columns[0].Cells = 3
320+ w.FocusID(1)
321+ if got := widthsOf(w)[0]; got != 800 {
322+ t.Fatalf("width %d, at most max-columns cells", got)
323+ }
324+ w.Apply(ActionCycleColumnWidth)
325+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
326+ t.Fatalf("cells %v", got)
327+ }
328+ })
329+ t.Run("a moved column keeps its cells", func(t *testing.T) {
330+ src := cellsWorkspace(OverflowScroll, 4, 2)
331+ src.Columns[1].Cells = 3
332+ dst := cellsWorkspace(OverflowScroll, 2, 1)
333+ col, ok := src.takeColumn()
334+ if !ok {
335+ t.Fatal("no column")
336+ }
337+ dst.receive(col, 1)
338+ if got := cellsOf(dst); got[len(got)-1] != 3 && got[0] != 3 {
339+ t.Fatalf("cells %v", got)
340+ }
341+ })
342+}
343+
344+// The overview blocks the grow and resize binds, like the other window
345+// mutations, and its cascade preview keeps the columns' cells.
346+func TestOverviewKeepsCells(t *testing.T) {
347+ m := newMonitor("", "")
348+ m.SetOutput(900, 600)
349+ m.SetOverflow(OverflowCascade)
350+ m.SetMaxColumns(4)
351+ for id := WindowID(1); id <= 2; id++ {
352+ m.AddWindow(id)
353+ }
354+ w := m.Current()
355+ w.Columns[0].Cells, w.Columns[1].Cells = 1, 3
356+ screen := w.columnRects()
357+ if screen[0].W != 225 || screen[1].W != 675 {
358+ t.Fatalf("screen widths %v, want 225 and 675", screen)
359+ }
360+ m.ToggleOverview()
361+ tiles, _, _ := w.previewTiles()
362+ for i, p := range tiles {
363+ if want := screen[i]; p.Rect.W != want.W || p.Rect.X != want.X-w.Usable.X || p.Rect.H != want.H {
364+ t.Fatalf("column %d: preview %+v, screen %+v", i, p.Rect, want)
365+ }
366+ }
367+ for _, a := range []Action{ActionCycleColumnWidth, "set-column-width +1", "set-column-width -1"} {
368+ m.Apply(a)
369+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{1, 3}) {
370+ t.Fatalf("%s in the overview: cells %v", a, got)
371+ }
372+ }
373+}
M internal/core/layout.go +2 -2
@@ -716,8 +716,8 @@ func (w *Workspace) focusColumn(dir int) {
716716
717717 // columnToward returns the column focus-column-left/right (dir -1/1) goes
718718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed
719-// overflow stacks columns (spiral, expanded strips), so it follows the
720-// screen: the column on that side.
719+// overflow stacks columns (spiral), so it follows the screen: the column
720+// on that side.
721721 func (w *Workspace) columnToward(dir int) int {
722722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
723723 return -1
@@ -716,8 +716,8 @@ func (w *Workspace) focusColumn(dir int) {
716 716
717 // columnToward returns the column focus-column-left/right (dir -1/1) goes717 // columnToward returns the column focus-column-left/right (dir -1/1) goes
718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed
719-// overflow stacks columns (spiral, expanded strips), so it follows the719+// overflow stacks columns (spiral), so it follows the screen: the column
720-// screen: the column on that side.720+// on that side.
721 func (w *Workspace) columnToward(dir int) int {721 func (w *Workspace) columnToward(dir int) int {
722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
723 return -1723 return -1

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