detached 8ceaf67

Merge pull request 'feat(core): column widths in max-columns cells, cmd+r grows by one cell' (#39) from feat/column-cells into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/39
commit
8ceaf676ea83a35a8319cac5c5bbecc17fbe9cd8
author
bnema · · 2026-10-12 13:16 UTC
merge of
a31e85f + d0a3940 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
38 files · +1226 -666
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 +24 -14
@@ -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.
@@ -227,9 +225,9 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
227225
228226 ### Cascade
229227
230-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.
228+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.
231229
232-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.
230+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.
233231
234232 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.
235233
@@ -239,6 +237,18 @@ In the overview, up/down move to the previous or next band and stop at the first
239237
240238 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
241239
240+### Column widths
241+
242+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:
243+
244+- `cascade`: the columns of a band share its row of cells.
245+- `fixed`: the columns share the row while there are at most `max-columns` of them. Past that, the spiral applies and cells are ignored.
246+- `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.
247+
248+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.
249+
250+`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.
251+
242252 ### Actions
243253
244254 | Action | Effect |
@@ -247,19 +257,19 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
247257 | `spawn-terminal` | Run `terminal` |
248258 | `close-window` | Close the focused window |
249259 | `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 |
250-| `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 |
260+| `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 |
251261 | `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 |
252262 | `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 |
253263 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
254264 | `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 |
255265 | `switch-column-prev` | The same, in the opposite direction |
256266 | `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 |
257-| `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 |
267+| `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 |
258268 | `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 |
259269 | `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 |
260270 | `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 |
261271 | `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 |
262-| `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 |
272+| `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 |
263273 | `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 |
264274 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
265275 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |
@@ -284,7 +294,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou
284294
285295 `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.
286296
287-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.
297+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.
288298
289299 ### Moving windows with the pointer
290300
@@ -340,8 +350,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
340350 | `cmd+bracketright` | `consume-or-expel-window-right` |
341351 | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` |
342352 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |
343-| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |
344-| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |
353+| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -1` |
354+| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +1` |
345355 | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |
346356 | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |
347357 | `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.
@@ -227,9 +225,9 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
227 225
228 ### Cascade226 ### Cascade
229 227
230-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.228+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.
231 229
232-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.230+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.
233 231
234 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.232 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.
235 233
@@ -239,6 +237,18 @@ In the overview, up/down move to the previous or next band and stop at the first
239 237
240 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.238 Cascade supports dropping onto tiles, but not gap insertion or edge scrolling during a drag.
241 239
240+### Column widths
241+
242+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:
243+
244+- `cascade`: the columns of a band share its row of cells.
245+- `fixed`: the columns share the row while there are at most `max-columns` of them. Past that, the spiral applies and cells are ignored.
246+- `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.
247+
248+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.
249+
250+`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.
251+
242 ### Actions252 ### Actions
243 253
244 | Action | Effect |254 | Action | Effect |
@@ -247,19 +257,19 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du
247 | `spawn-terminal` | Run `terminal` |257 | `spawn-terminal` | Run `terminal` |
248 | `close-window` | Close the focused window |258 | `close-window` | Close the focused window |
249 | `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 |259 | `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 |
250-| `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 |260+| `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 |
251 | `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 |261 | `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 |
252 | `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 |262 | `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 |
253 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |263 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
254 | `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-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 |
255 | `switch-column-prev` | The same, in the opposite direction |265 | `switch-column-prev` | The same, in the opposite direction |
256 | `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 | `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 |
257-| `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 |267+| `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 |
258 | `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-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 |
259 | `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 | `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 |
260 | `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-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 |
261 | `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 | `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 |
262-| `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 |272+| `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 |
263 | `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 | `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 |
264 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |274 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
265 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |275 | `focus-workspace <N>` / `focus-workspace-prev/next` | Show a numbered or neighbor workspace |
@@ -284,7 +294,7 @@ Native floating windows are not in the stash: they stay centred in the usable ou
284 294
285 `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.295 `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.
286 296
287-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.297+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.
288 298
289 ### Moving windows with the pointer299 ### Moving windows with the pointer
290 300
@@ -340,8 +350,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
340 | `cmd+bracketright` | `consume-or-expel-window-right` |350 | `cmd+bracketright` | `consume-or-expel-window-right` |
341 | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` |351 | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` |
342 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |352 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |
343-| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |353+| `cmd+alt+left` / `cmd+alt+h` | `set-column-width -1` |
344-| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |354+| `cmd+alt+right` / `cmd+alt+l` | `set-column-width +1` |
345 | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |355 | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |
346 | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |356 | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |
347 | `cmd+r` | `cycle-column-width` |357 | `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
M examples/config +15 -15
@@ -37,15 +37,14 @@ border.inactive = #111111
3737 floating.dim = 0.3
3838
3939 layout.gaps = 0
40-# Columns sharing the screen equally before the overflow applies.
41-# A lone column fills the screen without separators.
40+# Columns sharing the screen equally before the overflow applies: one cell is
41+# a max-columns-th of the width, and columns, rules and slots are sized in
42+# cells. A lone column fills the screen without separators.
4243 layout.max-columns = 2
4344 # Past max-columns: cascade starts a new band of columns below, scrolling
4445 # vertically; scroll opens columns to the right; fixed keeps everything on
4546 # screen, each new window splitting the newest one (spiral).
4647 layout.overflow = cascade
47-# cycle-column-width steps through these, then back to the equal share.
48-layout.presets = 1/3, 1/2, 2/3, 1
4948
5049 # Touchpad. A three-finger swipe up or down slides between workspaces, left
5150 # or right scrolls the columns (cascade swaps the two axes). natural-scroll
@@ -96,21 +95,20 @@ focus.strength = 0.04
9695 # workspace.web.max-columns = 3
9796 # workspace.web.overflow = fixed
9897 #
99-# Slots: column.N = <width>, <command>. neferwl starts each command in the
98+# Slots: column.N = <cells>, <command> (1-16). neferwl starts each command in the
10099 # background and puts its first window in column N, without taking focus.
101100 # A closed slot stays empty until the workspace is shown again. Apps that
102-# reuse a running instance need a new-instance flag. Widths need
103-# overflow = scroll.
101+# reuse a running instance need a new-instance flag.
104102 # workspace.dev.monitor = DP-2
105-# workspace.dev.column.1 = 67%, code --new-window
106-# workspace.dev.column.2 = 33%, foot
103+# workspace.dev.column.1 = 2, code --new-window
104+# workspace.dev.column.2 = 1, foot
107105 # bind.cmd+d = workspace dev
108106
109107 # Window rules place an app's windows when they first open, without moving
110108 # the focus or the view. app-id is a regular expression matching the whole
111109 # app ID (see `neferwl state`); matching rules merge, a later one winning.
112110 # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a
113-# monitor key, width = like a column width.
111+# monitor key, width = cells (1-16).
114112 # rule.games.app-id = steam_app_.*
115113 # rule.games.workspace = 3
116114 # rule.games.monitor = DP-2
@@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up
172170 bind.cmd+shift+down = move-window-down
173171 bind.cmd+shift+k = move-window-up
174172 bind.cmd+shift+j = move-window-down
175-# Resize by a step of the usable width or height: +N% or -N% (1-100).
176-bind.cmd+alt+left = set-column-width -10%
177-bind.cmd+alt+right = set-column-width +10%
173+# Resize a column by N cells (+N or -N, 1-16), a window by a step of the
174+# usable height (+N% or -N%, 1-100).
175+bind.cmd+alt+left = set-column-width -1
176+bind.cmd+alt+right = set-column-width +1
178177 bind.cmd+alt+up = set-window-height -10%
179178 bind.cmd+alt+down = set-window-height +10%
180-bind.cmd+alt+h = set-column-width -10%
181-bind.cmd+alt+l = set-column-width +10%
179+bind.cmd+alt+h = set-column-width -1
180+bind.cmd+alt+l = set-column-width +1
182181 bind.cmd+alt+k = set-window-height -10%
183182 bind.cmd+alt+j = set-window-height +10%
184183 # A tile becomes a free floating window, or returns to its column.
185184 bind.cmd+shift+space = toggle-floating
185+# Grow the focused column by one cell, then back to equal shares.
186186 bind.cmd+r = cycle-column-width
187187 bind.cmd+f = maximize-column
188188 bind.cmd+shift+f = toggle-fullscreen
@@ -37,15 +37,14 @@ border.inactive = #111111
37 floating.dim = 0.337 floating.dim = 0.3
38 38
39 layout.gaps = 039 layout.gaps = 0
40-# Columns sharing the screen equally before the overflow applies.40+# Columns sharing the screen equally before the overflow applies: one cell is
41-# A lone column fills the screen without separators.41+# a max-columns-th of the width, and columns, rules and slots are sized in
42+# cells. A lone column fills the screen without separators.
42 layout.max-columns = 243 layout.max-columns = 2
43 # Past max-columns: cascade starts a new band of columns below, scrolling44 # Past max-columns: cascade starts a new band of columns below, scrolling
44 # vertically; scroll opens columns to the right; fixed keeps everything on45 # vertically; scroll opens columns to the right; fixed keeps everything on
45 # screen, each new window splitting the newest one (spiral).46 # screen, each new window splitting the newest one (spiral).
46 layout.overflow = cascade47 layout.overflow = cascade
47-# cycle-column-width steps through these, then back to the equal share.
48-layout.presets = 1/3, 1/2, 2/3, 1
49 48
50 # Touchpad. A three-finger swipe up or down slides between workspaces, left49 # Touchpad. A three-finger swipe up or down slides between workspaces, left
51 # or right scrolls the columns (cascade swaps the two axes). natural-scroll50 # or right scrolls the columns (cascade swaps the two axes). natural-scroll
@@ -96,21 +95,20 @@ focus.strength = 0.04
96 # workspace.web.max-columns = 395 # workspace.web.max-columns = 3
97 # workspace.web.overflow = fixed96 # workspace.web.overflow = fixed
98 #97 #
99-# Slots: column.N = <width>, <command>. neferwl starts each command in the98+# Slots: column.N = <cells>, <command> (1-16). neferwl starts each command in the
100 # background and puts its first window in column N, without taking focus.99 # background and puts its first window in column N, without taking focus.
101 # A closed slot stays empty until the workspace is shown again. Apps that100 # A closed slot stays empty until the workspace is shown again. Apps that
102-# reuse a running instance need a new-instance flag. Widths need101+# reuse a running instance need a new-instance flag.
103-# overflow = scroll.
104 # workspace.dev.monitor = DP-2102 # workspace.dev.monitor = DP-2
105-# workspace.dev.column.1 = 67%, code --new-window103+# workspace.dev.column.1 = 2, code --new-window
106-# workspace.dev.column.2 = 33%, foot104+# workspace.dev.column.2 = 1, foot
107 # bind.cmd+d = workspace dev105 # bind.cmd+d = workspace dev
108 106
109 # Window rules place an app's windows when they first open, without moving107 # Window rules place an app's windows when they first open, without moving
110 # the focus or the view. app-id is a regular expression matching the whole108 # the focus or the view. app-id is a regular expression matching the whole
111 # app ID (see `neferwl state`); matching rules merge, a later one winning.109 # app ID (see `neferwl state`); matching rules merge, a later one winning.
112 # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a110 # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a
113-# monitor key, width = like a column width.111+# monitor key, width = cells (1-16).
114 # rule.games.app-id = steam_app_.*112 # rule.games.app-id = steam_app_.*
115 # rule.games.workspace = 3113 # rule.games.workspace = 3
116 # rule.games.monitor = DP-2114 # rule.games.monitor = DP-2
@@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up
172 bind.cmd+shift+down = move-window-down170 bind.cmd+shift+down = move-window-down
173 bind.cmd+shift+k = move-window-up171 bind.cmd+shift+k = move-window-up
174 bind.cmd+shift+j = move-window-down172 bind.cmd+shift+j = move-window-down
175-# Resize by a step of the usable width or height: +N% or -N% (1-100).173+# Resize a column by N cells (+N or -N, 1-16), a window by a step of the
176-bind.cmd+alt+left = set-column-width -10%174+# usable height (+N% or -N%, 1-100).
177-bind.cmd+alt+right = set-column-width +10%175+bind.cmd+alt+left = set-column-width -1
176+bind.cmd+alt+right = set-column-width +1
178 bind.cmd+alt+up = set-window-height -10%177 bind.cmd+alt+up = set-window-height -10%
179 bind.cmd+alt+down = set-window-height +10%178 bind.cmd+alt+down = set-window-height +10%
180-bind.cmd+alt+h = set-column-width -10%179+bind.cmd+alt+h = set-column-width -1
181-bind.cmd+alt+l = set-column-width +10%180+bind.cmd+alt+l = set-column-width +1
182 bind.cmd+alt+k = set-window-height -10%181 bind.cmd+alt+k = set-window-height -10%
183 bind.cmd+alt+j = set-window-height +10%182 bind.cmd+alt+j = set-window-height +10%
184 # A tile becomes a free floating window, or returns to its column.183 # A tile becomes a free floating window, or returns to its column.
185 bind.cmd+shift+space = toggle-floating184 bind.cmd+shift+space = toggle-floating
185+# Grow the focused column by one cell, then back to equal shares.
186 bind.cmd+r = cycle-column-width186 bind.cmd+r = cycle-column-width
187 bind.cmd+f = maximize-column187 bind.cmd+f = maximize-column
188 bind.cmd+shift+f = toggle-fullscreen188 bind.cmd+shift+f = toggle-fullscreen
M internal/adapters/config/cascade_test.go +8 -3
@@ -21,9 +21,14 @@ func TestCascadeOverflowInherited(t *testing.T) {
2121 }
2222 }
2323
24-func TestCascadeIgnoresColumnWidths(t *testing.T) {
25- _, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
26- if len(w) != 1 || !strings.Contains(w[0].Msg, "ignored with overflow = cascade") {
24+// Column widths are cells in every overflow, cascade included.
25+func TestCascadeColumnWidthsInCells(t *testing.T) {
26+ c, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 2, foot\nbind.cmd+w = workspace work\n")
27+ if len(w) != 0 || c.Workspaces[0].Slots[0].Cells != 2 {
28+ t.Fatalf("warnings=%v %+v", w, c.Workspaces)
29+ }
30+ _, w = parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
31+ if len(w) != 1 || !strings.Contains(w[0].Msg, "cells") {
2732 t.Fatalf("warnings=%v", w)
2833 }
2934 }
@@ -21,9 +21,14 @@ func TestCascadeOverflowInherited(t *testing.T) {
21 }21 }
22 }22 }
23 23
24-func TestCascadeIgnoresColumnWidths(t *testing.T) {24+// Column widths are cells in every overflow, cascade included.
25- _, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")25+func TestCascadeColumnWidthsInCells(t *testing.T) {
26- if len(w) != 1 || !strings.Contains(w[0].Msg, "ignored with overflow = cascade") {26+ c, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 2, foot\nbind.cmd+w = workspace work\n")
27+ if len(w) != 0 || c.Workspaces[0].Slots[0].Cells != 2 {
28+ t.Fatalf("warnings=%v %+v", w, c.Workspaces)
29+ }
30+ _, w = parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
31+ if len(w) != 1 || !strings.Contains(w[0].Msg, "cells") {
27 t.Fatalf("warnings=%v", w)32 t.Fatalf("warnings=%v", w)
28 }33 }
29 }34 }
M internal/adapters/config/config.go +37 -52
@@ -64,8 +64,8 @@ var defaultBinds = []struct{ combo, action string }{
6464 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
6565 {"cmd+shift+up", "move-window-up"},
6666 {"cmd+shift+down", "move-window-down"},
67- {"cmd+alt+left", "set-column-width -10%"},
68- {"cmd+alt+right", "set-column-width +10%"},
67+ {"cmd+alt+left", "set-column-width -1"},
68+ {"cmd+alt+right", "set-column-width +1"},
6969 {"cmd+alt+up", "set-window-height -10%"},
7070 {"cmd+alt+down", "set-window-height +10%"},
7171 {"cmd+ctrl+shift+up", "move-workspace-prev"},
@@ -118,9 +118,13 @@ var actions = map[string]bool{
118118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
120120
121-// resizeStep is the +N% or -N% step of set-column-width and
122-// set-window-height, N from 1 to 100.
123-var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
121+// columnStep is the +N or -N step of set-column-width, in cells, N from 1
122+// to 16; heightStep is the +N% or -N% step of set-window-height, N from 1
123+// to 100.
124+var (
125+ columnStep = regexp.MustCompile(`^[+-]([1-9]|1[0-6])$`)
126+ heightStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
127+)
124128
125129 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
126130 var namedKeys = map[string]string{
@@ -170,7 +174,6 @@ func Defaults() ports.Config {
170174 c.Border.Active = "#808080"
171175 c.Border.Inactive = "#111111"
172176 c.Layout.MaxColumns = 2
173- c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
174177 c.Layout.Overflow = "cascade"
175178 c.Touchpad.Tap = true
176179 c.Touchpad.AccelProfile = ports.AccelAdaptive
@@ -780,22 +783,11 @@ func set(c *ports.Config, key, v string) error {
780783 }
781784 c.Layout.Gaps = n
782785 case "layout.default-width":
783- 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)")
784787 case "layout.max-columns":
785788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
786789 case "layout.overflow":
787790 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)
788- case "layout.presets":
789- list := splitList(v)
790- if len(list) == 0 {
791- return fmt.Errorf("must not be empty")
792- }
793- for _, s := range list {
794- if !width(s) {
795- return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
796- }
797- }
798- c.Layout.Presets = list
799791 case "touchpad.tap":
800792 b, err := onOff(v)
801793 if err != nil {
@@ -1018,10 +1010,6 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
10181010 rules = o.Over(rules)
10191011 }
10201012 }
1021- if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 {
1022- line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
1023- warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)})
1024- }
10251013 if !bound[w.Name] {
10261014 line := 0
10271015 for key, n := range seen {
@@ -1089,10 +1077,11 @@ func setRule(r *ports.WindowRule, field, v string) error {
10891077 }
10901078 r.Monitor = v
10911079 case "width":
1092- if !width(v) {
1093- return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")
1080+ n, ok := cells(v)
1081+ if !ok {
1082+ return errCells
10941083 }
1095- r.Width = v
1084+ r.Cells = n
10961085 default:
10971086 return errUnknownRuleField
10981087 }
@@ -1159,10 +1148,11 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
11591148 size, cmd, _ := strings.Cut(v, ",")
11601149 size = strings.TrimSpace(size)
11611150 argv := strings.Fields(cmd)
1162- if !width(size) || len(argv) == 0 {
1163- return fmt.Errorf("must be <width>, <command> like 67%%, foot")
1151+ count, ok := cells(size)
1152+ if !ok || len(argv) == 0 {
1153+ return fmt.Errorf("must be <cells>, <command> like 2, foot (cells from 1 to 16)")
11641154 }
1165- slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
1155+ slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv}
11661156 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
11671157 if i < len(w.Slots) && w.Slots[i].Index == index {
11681158 w.Slots[i] = slot
@@ -1231,13 +1221,17 @@ func checkAction(v string) error {
12311221 return nil
12321222 }
12331223 }
1234- for _, prefix := range []string{"set-column-width ", "set-window-height "} {
1235- if rest, ok := strings.CutPrefix(v, prefix); ok {
1236- if !resizeStep.MatchString(strings.TrimSpace(rest)) {
1237- return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix)
1238- }
1239- return nil
1224+ if rest, ok := strings.CutPrefix(v, "set-column-width "); ok {
1225+ if !columnStep.MatchString(strings.TrimSpace(rest)) {
1226+ return fmt.Errorf("set-column-width needs +N or -N cells (1-16)")
1227+ }
1228+ return nil
1229+ }
1230+ if rest, ok := strings.CutPrefix(v, "set-window-height "); ok {
1231+ if !heightStep.MatchString(strings.TrimSpace(rest)) {
1232+ return fmt.Errorf("set-window-height needs +N%% or -N%% (1-100)")
12401233 }
1234+ return nil
12411235 }
12421236 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
12431237 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
@@ -1395,23 +1389,14 @@ func ParseMode(s string) (w, h int, hz float64, err error) {
13951389 return w, h, hz, nil
13961390 }
13971391
1398-func width(s string) bool {
1399- if s == "1" {
1400- return true
1401- }
1402- if n, ok := strings.CutSuffix(s, "%"); ok {
1403- v, e := strconv.Atoi(n)
1404- return e == nil && v > 0 && v <= 100
1405- }
1406- if strings.HasSuffix(s, "px") {
1407- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
1408- return e == nil && n > 0
1409- }
1410- p := strings.Split(s, "/")
1411- if len(p) != 2 {
1412- return false
1392+// errCells is the error of a width that is not a cell count.
1393+var errCells = fmt.Errorf("must be a cell count from 1 to 16")
1394+
1395+// cells parses a column width in cells: an integer from 1 to 16.
1396+func cells(s string) (int, bool) {
1397+ if s == "" || strings.Trim(s, "0123456789") != "" {
1398+ return 0, false
14131399 }
1414- a, e1 := strconv.Atoi(p[0])
1415- b, e2 := strconv.Atoi(p[1])
1416- return e1 == nil && e2 == nil && a > 0 && a <= b
1400+ n, err := strconv.Atoi(s)
1401+ return n, err == nil && n >= 1 && n <= 16
14171402 }
@@ -64,8 +64,8 @@ var defaultBinds = []struct{ combo, action string }{
64 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},64 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
65 {"cmd+shift+up", "move-window-up"},65 {"cmd+shift+up", "move-window-up"},
66 {"cmd+shift+down", "move-window-down"},66 {"cmd+shift+down", "move-window-down"},
67- {"cmd+alt+left", "set-column-width -10%"},67+ {"cmd+alt+left", "set-column-width -1"},
68- {"cmd+alt+right", "set-column-width +10%"},68+ {"cmd+alt+right", "set-column-width +1"},
69 {"cmd+alt+up", "set-window-height -10%"},69 {"cmd+alt+up", "set-window-height -10%"},
70 {"cmd+alt+down", "set-window-height +10%"},70 {"cmd+alt+down", "set-window-height +10%"},
71 {"cmd+ctrl+shift+up", "move-workspace-prev"},71 {"cmd+ctrl+shift+up", "move-workspace-prev"},
@@ -118,9 +118,13 @@ var actions = map[string]bool{
118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
120 120
121-// resizeStep is the +N% or -N% step of set-column-width and121+// columnStep is the +N or -N step of set-column-width, in cells, N from 1
122-// set-window-height, N from 1 to 100.122+// to 16; heightStep is the +N% or -N% step of set-window-height, N from 1
123-var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)123+// to 100.
124+var (
125+ columnStep = regexp.MustCompile(`^[+-]([1-9]|1[0-6])$`)
126+ heightStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
127+)
124 128
125 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.129 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
126 var namedKeys = map[string]string{130 var namedKeys = map[string]string{
@@ -170,7 +174,6 @@ func Defaults() ports.Config {
170 c.Border.Active = "#808080"174 c.Border.Active = "#808080"
171 c.Border.Inactive = "#111111"175 c.Border.Inactive = "#111111"
172 c.Layout.MaxColumns = 2176 c.Layout.MaxColumns = 2
173- c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
174 c.Layout.Overflow = "cascade"177 c.Layout.Overflow = "cascade"
175 c.Touchpad.Tap = true178 c.Touchpad.Tap = true
176 c.Touchpad.AccelProfile = ports.AccelAdaptive179 c.Touchpad.AccelProfile = ports.AccelAdaptive
@@ -780,22 +783,11 @@ func set(c *ports.Config, key, v string) error {
780 }783 }
781 c.Layout.Gaps = n784 c.Layout.Gaps = n
782 case "layout.default-width":785 case "layout.default-width":
783- 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)")
784 case "layout.max-columns":787 case "layout.max-columns":
785 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
786 case "layout.overflow":789 case "layout.overflow":
787 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)790 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)
788- case "layout.presets":
789- list := splitList(v)
790- if len(list) == 0 {
791- return fmt.Errorf("must not be empty")
792- }
793- for _, s := range list {
794- if !width(s) {
795- return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
796- }
797- }
798- c.Layout.Presets = list
799 case "touchpad.tap":791 case "touchpad.tap":
800 b, err := onOff(v)792 b, err := onOff(v)
801 if err != nil {793 if err != nil {
@@ -1018,10 +1010,6 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
1018 rules = o.Over(rules)1010 rules = o.Over(rules)
1019 }1011 }
1020 }1012 }
1021- if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 {
1022- line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
1023- warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)})
1024- }
1025 if !bound[w.Name] {1013 if !bound[w.Name] {
1026 line := 01014 line := 0
1027 for key, n := range seen {1015 for key, n := range seen {
@@ -1089,10 +1077,11 @@ func setRule(r *ports.WindowRule, field, v string) error {
1089 }1077 }
1090 r.Monitor = v1078 r.Monitor = v
1091 case "width":1079 case "width":
1092- if !width(v) {1080+ n, ok := cells(v)
1093- return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")1081+ if !ok {
1082+ return errCells
1094 }1083 }
1095- r.Width = v1084+ r.Cells = n
1096 default:1085 default:
1097 return errUnknownRuleField1086 return errUnknownRuleField
1098 }1087 }
@@ -1159,10 +1148,11 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
1159 size, cmd, _ := strings.Cut(v, ",")1148 size, cmd, _ := strings.Cut(v, ",")
1160 size = strings.TrimSpace(size)1149 size = strings.TrimSpace(size)
1161 argv := strings.Fields(cmd)1150 argv := strings.Fields(cmd)
1162- if !width(size) || len(argv) == 0 {1151+ count, ok := cells(size)
1163- return fmt.Errorf("must be <width>, <command> like 67%%, foot")1152+ if !ok || len(argv) == 0 {
1153+ return fmt.Errorf("must be <cells>, <command> like 2, foot (cells from 1 to 16)")
1164 }1154 }
1165- slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}1155+ slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv}
1166 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })1156 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
1167 if i < len(w.Slots) && w.Slots[i].Index == index {1157 if i < len(w.Slots) && w.Slots[i].Index == index {
1168 w.Slots[i] = slot1158 w.Slots[i] = slot
@@ -1231,13 +1221,17 @@ func checkAction(v string) error {
1231 return nil1221 return nil
1232 }1222 }
1233 }1223 }
1234- for _, prefix := range []string{"set-column-width ", "set-window-height "} {1224+ if rest, ok := strings.CutPrefix(v, "set-column-width "); ok {
1235- if rest, ok := strings.CutPrefix(v, prefix); ok {1225+ if !columnStep.MatchString(strings.TrimSpace(rest)) {
1236- if !resizeStep.MatchString(strings.TrimSpace(rest)) {1226+ return fmt.Errorf("set-column-width needs +N or -N cells (1-16)")
1237- return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix)1227+ }
1238- }1228+ return nil
1239- return nil1229+ }
1230+ if rest, ok := strings.CutPrefix(v, "set-window-height "); ok {
1231+ if !heightStep.MatchString(strings.TrimSpace(rest)) {
1232+ return fmt.Errorf("set-window-height needs +N%% or -N%% (1-100)")
1240 }1233 }
1234+ return nil
1241 }1235 }
1242 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {1236 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
1243 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {1237 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
@@ -1395,23 +1389,14 @@ func ParseMode(s string) (w, h int, hz float64, err error) {
1395 return w, h, hz, nil1389 return w, h, hz, nil
1396 }1390 }
1397 1391
1398-func width(s string) bool {1392+// errCells is the error of a width that is not a cell count.
1399- if s == "1" {1393+var errCells = fmt.Errorf("must be a cell count from 1 to 16")
1400- return true1394+
1401- }1395+// cells parses a column width in cells: an integer from 1 to 16.
1402- if n, ok := strings.CutSuffix(s, "%"); ok {1396+func cells(s string) (int, bool) {
1403- v, e := strconv.Atoi(n)1397+ if s == "" || strings.Trim(s, "0123456789") != "" {
1404- return e == nil && v > 0 && v <= 1001398+ return 0, false
1405- }
1406- if strings.HasSuffix(s, "px") {
1407- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
1408- return e == nil && n > 0
1409- }
1410- p := strings.Split(s, "/")
1411- if len(p) != 2 {
1412- return false
1413 }1399 }
1414- a, e1 := strconv.Atoi(p[0])1400+ n, err := strconv.Atoi(s)
1415- b, e2 := strconv.Atoi(p[1])1401+ return n, err == nil && n >= 1 && n <= 16
1416- return e1 == nil && e2 == nil && a > 0 && a <= b
1417 }1402 }
M internal/adapters/config/config_test.go +29 -19
@@ -32,7 +32,7 @@ func TestDefaultsAndLoad(t *testing.T) {
3232 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
3333 t.Fatal(d.Binds)
3434 }
35- if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
35+ if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -1" || d.Binds["Alt+Cmd+l"] != "set-column-width +1" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
3636 t.Fatal(d.Binds)
3737 }
3838 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -224,7 +224,6 @@ keyboard.repeat-rate = 40 # inline comment
224224 terminal = foot --server
225225 background = #000000
226226 border.inactive =
227-layout.presets = 1/3, 1/2 ,1
228227 log.debug = core, input
229228 output.DP-2.scale = 4/3
230229 output.DP-2 = 5120x2160@165.058
@@ -248,7 +247,7 @@ startup = wl-paste --primary --watch cliphist store; waybar ;mako;
248247 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {
249248 t.Fatalf("%+v", c)
250249 }
251- if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
250+ if !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
252251 t.Fatalf("%+v", c)
253252 }
254253 // Scale and mode lines combine in either order.
@@ -270,7 +269,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
270269 {"layout.gaps = 201", "layout.gaps"},
271270 {"layout.max-columns = 0", "layout.max-columns"},
272271 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
273- {"layout.presets = 0px", "layout.presets"},
272+ {"layout.presets = 1/2, 1", "layout.presets: unknown key"},
274273 {"log.level = trace", "log.level"},
275274 {"log.debug = nope", "log.debug"},
276275 {"output.DP-2 = big", "output.DP-2"},
@@ -287,8 +286,12 @@ func TestWarningsKeepDefaults(t *testing.T) {
287286 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
288287 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
289288 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
290- {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
291- {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
289+ {"bind.cmd+x = set-column-width 1", "set-column-width needs"},
290+ {"bind.cmd+x = set-column-width +0", "set-column-width needs"},
291+ {"bind.cmd+x = set-column-width +17", "set-column-width needs"},
292+ {"bind.cmd+x = set-column-width +10%", "set-column-width needs"},
293+ {"bind.cmd+x = set-column-width +-1", "set-column-width needs"},
294+ {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
292295 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
293296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
294297 {"bind.cmd+x = spawn ", "unknown action"},
@@ -523,17 +526,22 @@ workspace.web.column.1 = 50%, foot
523526 func TestWorkspaceSlots(t *testing.T) {
524527 c, w := parseString(t, `layout.overflow = scroll
525528 workspace.dev.monitor = DP-2
526-workspace.dev.column.2 = 33%, foot --title x
527-workspace.dev.column.1 = 67%, code --new-window
528-workspace.dev.column.2 = 1/3, kitty
529-workspace.dev.column.0 = 50%, foot
530-workspace.dev.column.3 = 50%
529+workspace.dev.column.2 = 1, foot --title x
530+workspace.dev.column.1 = 3, code --new-window
531+workspace.dev.column.2 = 16, kitty
532+workspace.dev.column.0 = 2, foot
533+workspace.dev.column.3 = 2
531534 workspace.dev.column.4 = huge, foot
535+workspace.dev.column.5 = 67%, foot
536+workspace.dev.column.6 = 1/3, foot
537+workspace.dev.column.7 = 800px, foot
538+workspace.dev.column.8 = 0, foot
539+workspace.dev.column.9 = 17, foot
532540 bind.cmd+d = workspace dev
533541 `)
534542 want := []ports.SlotConfig{
535- {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
536- {Index: 2, Width: "1/3", Argv: []string{"kitty"}},
543+ {Index: 1, Cells: 3, Argv: []string{"code", "--new-window"}},
544+ {Index: 2, Cells: 16, Argv: []string{"kitty"}},
537545 }
538546 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
539547 t.Fatalf("%+v", c.Workspaces)
@@ -542,17 +550,19 @@ bind.cmd+d = workspace dev
542550 for _, x := range w {
543551 lines = append(lines, x.Line)
544552 }
545- if !reflect.DeepEqual(lines, []int{5, 6, 7, 8}) {
553+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 9, 10, 11, 12, 13}) {
546554 t.Fatal(w)
547555 }
548556 }
549557
550-func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
551- _, w := parseString(t, `workspace.dev.overflow = fixed
552-workspace.dev.column.1 = 67%, foot
558+// Slot widths are cells in every overflow: nothing is ignored.
559+func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) {
560+ c, w := parseString(t, `workspace.dev.overflow = fixed
561+workspace.dev.column.1 = 2, foot
562+bind.cmd+d = workspace dev
553563 `)
554- if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
555- t.Fatal(w)
564+ if len(w) != 0 || len(c.Workspaces) != 1 || len(c.Workspaces[0].Slots) != 1 || c.Workspaces[0].Slots[0].Cells != 2 {
565+ t.Fatal(w, c.Workspaces)
556566 }
557567 }
558568
@@ -32,7 +32,7 @@ func TestDefaultsAndLoad(t *testing.T) {
32 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {32 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
33 t.Fatal(d.Binds)33 t.Fatal(d.Binds)
34 }34 }
35- if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {35+ if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -1" || d.Binds["Alt+Cmd+l"] != "set-column-width +1" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
36 t.Fatal(d.Binds)36 t.Fatal(d.Binds)
37 }37 }
38 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {38 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -224,7 +224,6 @@ keyboard.repeat-rate = 40 # inline comment
224 terminal = foot --server224 terminal = foot --server
225 background = #000000225 background = #000000
226 border.inactive =226 border.inactive =
227-layout.presets = 1/3, 1/2 ,1
228 log.debug = core, input227 log.debug = core, input
229 output.DP-2.scale = 4/3228 output.DP-2.scale = 4/3
230 output.DP-2 = 5120x2160@165.058229 output.DP-2 = 5120x2160@165.058
@@ -248,7 +247,7 @@ startup = wl-paste --primary --watch cliphist store; waybar ;mako;
248 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {247 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {
249 t.Fatalf("%+v", c)248 t.Fatalf("%+v", c)
250 }249 }
251- if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {250+ if !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
252 t.Fatalf("%+v", c)251 t.Fatalf("%+v", c)
253 }252 }
254 // Scale and mode lines combine in either order.253 // Scale and mode lines combine in either order.
@@ -270,7 +269,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
270 {"layout.gaps = 201", "layout.gaps"},269 {"layout.gaps = 201", "layout.gaps"},
271 {"layout.max-columns = 0", "layout.max-columns"},270 {"layout.max-columns = 0", "layout.max-columns"},
272 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},271 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
273- {"layout.presets = 0px", "layout.presets"},272+ {"layout.presets = 1/2, 1", "layout.presets: unknown key"},
274 {"log.level = trace", "log.level"},273 {"log.level = trace", "log.level"},
275 {"log.debug = nope", "log.debug"},274 {"log.debug = nope", "log.debug"},
276 {"output.DP-2 = big", "output.DP-2"},275 {"output.DP-2 = big", "output.DP-2"},
@@ -287,8 +286,12 @@ func TestWarningsKeepDefaults(t *testing.T) {
287 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},286 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
288 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},287 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
289 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},288 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
290- {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},289+ {"bind.cmd+x = set-column-width 1", "set-column-width needs"},
291- {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},290+ {"bind.cmd+x = set-column-width +0", "set-column-width needs"},
291+ {"bind.cmd+x = set-column-width +17", "set-column-width needs"},
292+ {"bind.cmd+x = set-column-width +10%", "set-column-width needs"},
293+ {"bind.cmd+x = set-column-width +-1", "set-column-width needs"},
294+ {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
292 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},295 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
293 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
294 {"bind.cmd+x = spawn ", "unknown action"},297 {"bind.cmd+x = spawn ", "unknown action"},
@@ -523,17 +526,22 @@ workspace.web.column.1 = 50%, foot
523 func TestWorkspaceSlots(t *testing.T) {526 func TestWorkspaceSlots(t *testing.T) {
524 c, w := parseString(t, `layout.overflow = scroll527 c, w := parseString(t, `layout.overflow = scroll
525 workspace.dev.monitor = DP-2528 workspace.dev.monitor = DP-2
526-workspace.dev.column.2 = 33%, foot --title x529+workspace.dev.column.2 = 1, foot --title x
527-workspace.dev.column.1 = 67%, code --new-window530+workspace.dev.column.1 = 3, code --new-window
528-workspace.dev.column.2 = 1/3, kitty531+workspace.dev.column.2 = 16, kitty
529-workspace.dev.column.0 = 50%, foot532+workspace.dev.column.0 = 2, foot
530-workspace.dev.column.3 = 50%533+workspace.dev.column.3 = 2
531 workspace.dev.column.4 = huge, foot534 workspace.dev.column.4 = huge, foot
535+workspace.dev.column.5 = 67%, foot
536+workspace.dev.column.6 = 1/3, foot
537+workspace.dev.column.7 = 800px, foot
538+workspace.dev.column.8 = 0, foot
539+workspace.dev.column.9 = 17, foot
532 bind.cmd+d = workspace dev540 bind.cmd+d = workspace dev
533 `)541 `)
534 want := []ports.SlotConfig{542 want := []ports.SlotConfig{
535- {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},543+ {Index: 1, Cells: 3, Argv: []string{"code", "--new-window"}},
536- {Index: 2, Width: "1/3", Argv: []string{"kitty"}},544+ {Index: 2, Cells: 16, Argv: []string{"kitty"}},
537 }545 }
538 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {546 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
539 t.Fatalf("%+v", c.Workspaces)547 t.Fatalf("%+v", c.Workspaces)
@@ -542,17 +550,19 @@ bind.cmd+d = workspace dev
542 for _, x := range w {550 for _, x := range w {
543 lines = append(lines, x.Line)551 lines = append(lines, x.Line)
544 }552 }
545- if !reflect.DeepEqual(lines, []int{5, 6, 7, 8}) {553+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 9, 10, 11, 12, 13}) {
546 t.Fatal(w)554 t.Fatal(w)
547 }555 }
548 }556 }
549 557
550-func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {558+// Slot widths are cells in every overflow: nothing is ignored.
551- _, w := parseString(t, `workspace.dev.overflow = fixed559+func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) {
552-workspace.dev.column.1 = 67%, foot560+ c, w := parseString(t, `workspace.dev.overflow = fixed
561+workspace.dev.column.1 = 2, foot
562+bind.cmd+d = workspace dev
553 `)563 `)
554- if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {564+ if len(w) != 0 || len(c.Workspaces) != 1 || len(c.Workspaces[0].Slots) != 1 || c.Workspaces[0].Slots[0].Cells != 2 {
555- t.Fatal(w)565+ t.Fatal(w, c.Workspaces)
556 }566 }
557 }567 }
558 568
M internal/adapters/config/rules_test.go +16 -10
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "strconv"
45 "strings"
56 "testing"
67
@@ -17,8 +18,8 @@ rule.games.workspace = 3
1718 rule.chat.app-id = org\.telegram\..*|discord
1819 rule.chat.workspace = chat
1920 rule.chat.monitor = DP-2
20-rule.chat.width = 1/3
21-rule.games.width = 50%
21+rule.chat.width = 1
22+rule.games.width = 4
2223 `)
2324 if len(w) != 0 {
2425 t.Fatal(w)
@@ -27,10 +28,10 @@ rule.games.width = 50%
2728 t.Fatalf("%+v", c.Rules)
2829 }
2930 g, ch := c.Rules[0], c.Rules[1]
30- if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {
31+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Cells != 4 || g.Monitor != "" {
3132 t.Fatalf("%+v", g)
3233 }
33- if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {
34+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Cells != 1 {
3435 t.Fatalf("%+v", ch)
3536 }
3637 if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
@@ -71,7 +72,12 @@ func TestRuleWarnings(t *testing.T) {
7172 // An invalid duplicate keeps the earlier value and the rule.
7273 {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},
7374 {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},
74- {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
75+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a cell count from 1 to 16", 2, 0},
76+ {"percent width", "rule.a.app-id = foo\nrule.a.width = 50%\n", "rule.a.width: must be a cell count", 2, 0},
77+ {"fraction width", "rule.a.app-id = foo\nrule.a.width = 1/3\n", "rule.a.width: must be a cell count", 2, 0},
78+ {"pixel width", "rule.a.app-id = foo\nrule.a.width = 800px\n", "rule.a.width: must be a cell count", 2, 0},
79+ {"zero width", "rule.a.app-id = foo\nrule.a.width = 0\n", "rule.a.width: must be a cell count", 2, 0},
80+ {"too wide", "rule.a.app-id = foo\nrule.a.width = 17\n", "rule.a.width: must be a cell count", 2, 0},
7581 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
7682 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
7783 {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
@@ -90,11 +96,11 @@ func TestRuleWarnings(t *testing.T) {
9096 }
9197 }
9298
93-func TestRuleWidthReusesColumnWidth(t *testing.T) {
94- for _, v := range []string{"1", "33%", "1/3", "640px"} {
95- c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")
96- if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {
97- t.Fatalf("%s: %v %+v", v, w, c.Rules)
99+func TestRuleWidthIsACellCount(t *testing.T) {
100+ for _, v := range []int{1, 2, 16} {
101+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+strconv.Itoa(v)+"\n")
102+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Cells != v {
103+ t.Fatalf("%d: %v %+v", v, w, c.Rules)
98104 }
99105 }
100106 }
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "strconv"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -17,8 +18,8 @@ rule.games.workspace = 3
17 rule.chat.app-id = org\.telegram\..*|discord18 rule.chat.app-id = org\.telegram\..*|discord
18 rule.chat.workspace = chat19 rule.chat.workspace = chat
19 rule.chat.monitor = DP-220 rule.chat.monitor = DP-2
20-rule.chat.width = 1/321+rule.chat.width = 1
21-rule.games.width = 50%22+rule.games.width = 4
22 `)23 `)
23 if len(w) != 0 {24 if len(w) != 0 {
24 t.Fatal(w)25 t.Fatal(w)
@@ -27,10 +28,10 @@ rule.games.width = 50%
27 t.Fatalf("%+v", c.Rules)28 t.Fatalf("%+v", c.Rules)
28 }29 }
29 g, ch := c.Rules[0], c.Rules[1]30 g, ch := c.Rules[0], c.Rules[1]
30- if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {31+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Cells != 4 || g.Monitor != "" {
31 t.Fatalf("%+v", g)32 t.Fatalf("%+v", g)
32 }33 }
33- if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {34+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Cells != 1 {
34 t.Fatalf("%+v", ch)35 t.Fatalf("%+v", ch)
35 }36 }
36 if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {37 if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
@@ -71,7 +72,12 @@ func TestRuleWarnings(t *testing.T) {
71 // An invalid duplicate keeps the earlier value and the rule.72 // An invalid duplicate keeps the earlier value and the rule.
72 {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},73 {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},
73 {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},74 {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},
74- {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},75+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a cell count from 1 to 16", 2, 0},
76+ {"percent width", "rule.a.app-id = foo\nrule.a.width = 50%\n", "rule.a.width: must be a cell count", 2, 0},
77+ {"fraction width", "rule.a.app-id = foo\nrule.a.width = 1/3\n", "rule.a.width: must be a cell count", 2, 0},
78+ {"pixel width", "rule.a.app-id = foo\nrule.a.width = 800px\n", "rule.a.width: must be a cell count", 2, 0},
79+ {"zero width", "rule.a.app-id = foo\nrule.a.width = 0\n", "rule.a.width: must be a cell count", 2, 0},
80+ {"too wide", "rule.a.app-id = foo\nrule.a.width = 17\n", "rule.a.width: must be a cell count", 2, 0},
75 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},81 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
76 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},82 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
77 {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},83 {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
@@ -90,11 +96,11 @@ func TestRuleWarnings(t *testing.T) {
90 }96 }
91 }97 }
92 98
93-func TestRuleWidthReusesColumnWidth(t *testing.T) {99+func TestRuleWidthIsACellCount(t *testing.T) {
94- for _, v := range []string{"1", "33%", "1/3", "640px"} {100+ for _, v := range []int{1, 2, 16} {
95- c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")101+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+strconv.Itoa(v)+"\n")
96- if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {102+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Cells != v {
97- t.Fatalf("%s: %v %+v", v, w, c.Rules)103+ t.Fatalf("%d: %v %+v", v, w, c.Rules)
98 }104 }
99 }105 }
100 }106 }
M internal/core/action.go +31 -15
@@ -91,30 +91,40 @@ var resizePrefixes = [...]string{
9191 ResizeHeight: "set-window-height ",
9292 }
9393
94-// ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and
95-// "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign
96-// is required.
97-func ResizeArg(a Action) (axis, pct int, ok bool) {
94+// ResizeArg parses "set-column-width +N" (axis ResizeWidth) and
95+// "set-window-height -N%" (axis ResizeHeight). The sign is required and the
96+// unit depends on the axis: step is in cells for a width (N from 1 to 16,
97+// no %), in percent for a height (N from 1 to 100, with %).
98+func ResizeArg(a Action) (axis, step int, ok bool) {
9899 for i, prefix := range resizePrefixes {
99100 rest, found := strings.CutPrefix(string(a), prefix)
100101 if !found {
101102 continue
102103 }
103- if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok {
104- return i, p, true
104+ rest = strings.TrimSpace(rest)
105+ if i == ResizeWidth {
106+ if p, ok := parseResizeStep(rest, maxCells); ok {
107+ return i, p, true
108+ }
109+ return 0, 0, false
110+ }
111+ if num, found := strings.CutSuffix(rest, "%"); found {
112+ if p, ok := parseResizeStep(num, 100); ok {
113+ return i, p, true
114+ }
105115 }
106116 return 0, 0, false
107117 }
108118 return 0, 0, false
109119 }
110120
111-// parseResizeStep parses "+N%" or "-N%" with N from 1 to 100.
112-func parseResizeStep(s string) (int, bool) {
113- if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' {
121+// parseResizeStep parses "+N" or "-N" with N from 1 to limit.
122+func parseResizeStep(s string, limit int) (int, bool) {
123+ if len(s) < 2 || (s[0] != '+' && s[0] != '-') {
114124 return 0, false
115125 }
116- n, err := strconv.Atoi(s[1 : len(s)-1])
117- if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' {
126+ n, err := strconv.Atoi(s[1:])
127+ if err != nil || n < 1 || n > limit || s[1] == '+' || s[1] == '-' {
118128 return 0, false
119129 }
120130 if s[0] == '-' {
@@ -418,11 +428,17 @@ func (w *Workspace) Apply(a Action) Effect {
418428 if w.freeAction(a) {
419429 return Effect{}
420430 }
421- if axis, pct, ok := ResizeArg(a); ok {
431+ if axis, step, ok := ResizeArg(a); ok {
422432 if axis == ResizeWidth {
423- w.ResizeColumn(pct)
433+ for range max(step, -step) {
434+ if step > 0 {
435+ w.GrowColumn(false)
436+ } else {
437+ w.ShrinkColumn()
438+ }
439+ }
424440 } else {
425- w.ResizeRow(pct)
441+ w.ResizeRow(step)
426442 }
427443 return Effect{}
428444 }
@@ -446,7 +462,7 @@ func (w *Workspace) Apply(a Action) Effect {
446462 case ActionMoveWindowDown:
447463 w.MoveWindow(1)
448464 case ActionCycleColumnWidth:
449- w.CycleWidth()
465+ w.GrowColumn(true)
450466 case ActionMaximizeColumn:
451467 w.ToggleFullWidth()
452468 case ActionToggleFullscreen:
@@ -91,30 +91,40 @@ var resizePrefixes = [...]string{
91 ResizeHeight: "set-window-height ",91 ResizeHeight: "set-window-height ",
92 }92 }
93 93
94-// ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and94+// ResizeArg parses "set-column-width +N" (axis ResizeWidth) and
95-// "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign95+// "set-window-height -N%" (axis ResizeHeight). The sign is required and the
96-// is required.96+// unit depends on the axis: step is in cells for a width (N from 1 to 16,
97-func ResizeArg(a Action) (axis, pct int, ok bool) {97+// no %), in percent for a height (N from 1 to 100, with %).
98+func ResizeArg(a Action) (axis, step int, ok bool) {
98 for i, prefix := range resizePrefixes {99 for i, prefix := range resizePrefixes {
99 rest, found := strings.CutPrefix(string(a), prefix)100 rest, found := strings.CutPrefix(string(a), prefix)
100 if !found {101 if !found {
101 continue102 continue
102 }103 }
103- if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok {104+ rest = strings.TrimSpace(rest)
104- return i, p, true105+ if i == ResizeWidth {
106+ if p, ok := parseResizeStep(rest, maxCells); ok {
107+ return i, p, true
108+ }
109+ return 0, 0, false
110+ }
111+ if num, found := strings.CutSuffix(rest, "%"); found {
112+ if p, ok := parseResizeStep(num, 100); ok {
113+ return i, p, true
114+ }
105 }115 }
106 return 0, 0, false116 return 0, 0, false
107 }117 }
108 return 0, 0, false118 return 0, 0, false
109 }119 }
110 120
111-// parseResizeStep parses "+N%" or "-N%" with N from 1 to 100.121+// parseResizeStep parses "+N" or "-N" with N from 1 to limit.
112-func parseResizeStep(s string) (int, bool) {122+func parseResizeStep(s string, limit int) (int, bool) {
113- if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' {123+ if len(s) < 2 || (s[0] != '+' && s[0] != '-') {
114 return 0, false124 return 0, false
115 }125 }
116- n, err := strconv.Atoi(s[1 : len(s)-1])126+ n, err := strconv.Atoi(s[1:])
117- if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' {127+ if err != nil || n < 1 || n > limit || s[1] == '+' || s[1] == '-' {
118 return 0, false128 return 0, false
119 }129 }
120 if s[0] == '-' {130 if s[0] == '-' {
@@ -418,11 +428,17 @@ func (w *Workspace) Apply(a Action) Effect {
418 if w.freeAction(a) {428 if w.freeAction(a) {
419 return Effect{}429 return Effect{}
420 }430 }
421- if axis, pct, ok := ResizeArg(a); ok {431+ if axis, step, ok := ResizeArg(a); ok {
422 if axis == ResizeWidth {432 if axis == ResizeWidth {
423- w.ResizeColumn(pct)433+ for range max(step, -step) {
434+ if step > 0 {
435+ w.GrowColumn(false)
436+ } else {
437+ w.ShrinkColumn()
438+ }
439+ }
424 } else {440 } else {
425- w.ResizeRow(pct)441+ w.ResizeRow(step)
426 }442 }
427 return Effect{}443 return Effect{}
428 }444 }
@@ -446,7 +462,7 @@ func (w *Workspace) Apply(a Action) Effect {
446 case ActionMoveWindowDown:462 case ActionMoveWindowDown:
447 w.MoveWindow(1)463 w.MoveWindow(1)
448 case ActionCycleColumnWidth:464 case ActionCycleColumnWidth:
449- w.CycleWidth()465+ w.GrowColumn(true)
450 case ActionMaximizeColumn:466 case ActionMaximizeColumn:
451 w.ToggleFullWidth()467 w.ToggleFullWidth()
452 case ActionToggleFullscreen:468 case ActionToggleFullscreen:
M internal/core/action_test.go +18 -14
@@ -38,26 +38,30 @@ func TestDefaultBindsAccepted(t *testing.T) {
3838
3939 func TestResizeArg(t *testing.T) {
4040 for _, tc := range []struct {
41- a core.Action
42- axis, pct int
43- ok bool
41+ a core.Action
42+ axis, step int
43+ ok bool
4444 }{
45- {"set-column-width +10%", core.ResizeWidth, 10, true},
46- {"set-column-width -5%", core.ResizeWidth, -5, true},
45+ {"set-column-width +1", core.ResizeWidth, 1, true},
46+ {"set-column-width -5", core.ResizeWidth, -5, true},
47+ {"set-column-width +16", core.ResizeWidth, 16, true},
4748 {"set-window-height +100%", core.ResizeHeight, 100, true},
4849 {"set-window-height -1%", core.ResizeHeight, -1, true},
49- {"set-column-width 10%", 0, 0, false},
50- {"set-column-width +0%", 0, 0, false},
51- {"set-column-width +101%", 0, 0, false},
52- {"set-column-width +10", 0, 0, false},
53- {"set-column-width +-1%", 0, 0, false},
54- {"set-column-width ++1%", 0, 0, false},
50+ {"set-column-width 1", 0, 0, false},
51+ {"set-column-width +0", 0, 0, false},
52+ {"set-column-width +17", 0, 0, false},
53+ {"set-column-width +10%", 0, 0, false},
54+ {"set-column-width +-1", 0, 0, false},
55+ {"set-column-width ++1", 0, 0, false},
56+ {"set-window-height +10", 0, 0, false},
57+ {"set-window-height +101%", 0, 0, false},
58+ {"set-window-height +0%", 0, 0, false},
5559 {"set-window-height", 0, 0, false},
5660 {"cycle-column-width", 0, 0, false},
5761 } {
58- axis, pct, ok := core.ResizeArg(tc.a)
59- if axis != tc.axis || pct != tc.pct || ok != tc.ok {
60- t.Errorf("%q: %d %d %v", tc.a, axis, pct, ok)
62+ axis, step, ok := core.ResizeArg(tc.a)
63+ if axis != tc.axis || step != tc.step || ok != tc.ok {
64+ t.Errorf("%q: %d %d %v", tc.a, axis, step, ok)
6165 }
6266 }
6367 }
@@ -38,26 +38,30 @@ func TestDefaultBindsAccepted(t *testing.T) {
38 38
39 func TestResizeArg(t *testing.T) {39 func TestResizeArg(t *testing.T) {
40 for _, tc := range []struct {40 for _, tc := range []struct {
41- a core.Action41+ a core.Action
42- axis, pct int42+ axis, step int
43- ok bool43+ ok bool
44 }{44 }{
45- {"set-column-width +10%", core.ResizeWidth, 10, true},45+ {"set-column-width +1", core.ResizeWidth, 1, true},
46- {"set-column-width -5%", core.ResizeWidth, -5, true},46+ {"set-column-width -5", core.ResizeWidth, -5, true},
47+ {"set-column-width +16", core.ResizeWidth, 16, true},
47 {"set-window-height +100%", core.ResizeHeight, 100, true},48 {"set-window-height +100%", core.ResizeHeight, 100, true},
48 {"set-window-height -1%", core.ResizeHeight, -1, true},49 {"set-window-height -1%", core.ResizeHeight, -1, true},
49- {"set-column-width 10%", 0, 0, false},50+ {"set-column-width 1", 0, 0, false},
50- {"set-column-width +0%", 0, 0, false},51+ {"set-column-width +0", 0, 0, false},
51- {"set-column-width +101%", 0, 0, false},52+ {"set-column-width +17", 0, 0, false},
52- {"set-column-width +10", 0, 0, false},53+ {"set-column-width +10%", 0, 0, false},
53- {"set-column-width +-1%", 0, 0, false},54+ {"set-column-width +-1", 0, 0, false},
54- {"set-column-width ++1%", 0, 0, false},55+ {"set-column-width ++1", 0, 0, false},
56+ {"set-window-height +10", 0, 0, false},
57+ {"set-window-height +101%", 0, 0, false},
58+ {"set-window-height +0%", 0, 0, false},
55 {"set-window-height", 0, 0, false},59 {"set-window-height", 0, 0, false},
56 {"cycle-column-width", 0, 0, false},60 {"cycle-column-width", 0, 0, false},
57 } {61 } {
58- axis, pct, ok := core.ResizeArg(tc.a)62+ axis, step, ok := core.ResizeArg(tc.a)
59- if axis != tc.axis || pct != tc.pct || ok != tc.ok {63+ if axis != tc.axis || step != tc.step || ok != tc.ok {
60- t.Errorf("%q: %d %d %v", tc.a, axis, pct, ok)64+ t.Errorf("%q: %d %d %v", tc.a, axis, step, ok)
61 }65 }
62 }66 }
63 }67 }
M internal/core/cascade.go +82 -12
@@ -4,8 +4,16 @@ import "slices"
44
55 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
66 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+
8+// cellX is the x of column i in its lane, from the usable area's left edge.
79 func (w *Workspace) cellX(i int) int {
8- return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap())
10+ first, last := w.laneBounds(w.laneOf(i))
11+ S, u, sum := w.laneStats(first, last)
12+ x := w.gap()
13+ for j := first; j < i; j++ {
14+ x += w.laneWidth(j, first, last, S, u, sum) + w.gap()
15+ }
16+ return x
917 }
1018 func (w *Workspace) cascadeBands() (n, spacing int) {
1119 if len(w.Columns) == 0 || w.Usable.H <= 0 {
@@ -14,12 +22,64 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
1422 return w.band(len(w.Columns)-1) + 1, w.Usable.H
1523 }
1624
17-// cellWidth is the width of the cell of column i: the columns of its band
18-// share the usable width equally, as in fixed overflow, so a lone column
19-// fills it.
25+// cellWidth is the width of column i in its lane: the columns of a band
26+// (the whole row in fixed overflow) share the usable width by their cells,
27+// auto columns sharing what the others leave, so a lone column fills it.
2028 func (w *Workspace) cellWidth(i int) int {
21- first, last := w.laneBounds(w.band(i))
22- return w.equalShare(last - first + 1)
29+ first, last := w.laneBounds(w.laneOf(i))
30+ S, u, sum := w.laneStats(first, last)
31+ return w.laneWidth(i, first, last, S, u, sum)
32+}
33+
34+// laneStats measures the lane first..last: S is the sum of the cells of
35+// its set columns, u the count of its auto columns and sum the total of
36+// their weights (laneWeight).
37+func (w *Workspace) laneStats(first, last int) (S, u, sum int) {
38+ for i := first; i <= last; i++ {
39+ if c := w.Columns[i].Cells; c > 0 {
40+ S += c
41+ } else {
42+ u++
43+ }
44+ }
45+ for i := first; i <= last; i++ {
46+ sum += w.laneWeight(i, S, u)
47+ }
48+ return S, u, sum
49+}
50+
51+// laneWeight is the share of column i in its lane, given its stats. With
52+// room left for every auto column, they share the cells the set ones leave
53+// and the set ones keep their cells; otherwise an auto column weighs one
54+// cell.
55+func (w *Workspace) laneWeight(i, S, u int) int {
56+ k := max(w.MaxColumns, 1)
57+ c := w.Columns[i].Cells
58+ switch {
59+ case u == 0:
60+ return c
61+ case k-S >= u:
62+ if c > 0 {
63+ return c * u
64+ }
65+ return k - S
66+ case c > 0:
67+ return c
68+ }
69+ return 1
70+}
71+
72+// laneWidth is the width of column i of the lane first..last with the
73+// stats of laneStats: the usable width after gaps, split by weight. The
74+// remainder under one pixel per weight stays empty, as in equalShare.
75+func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
76+ if sum <= 0 {
77+ return 0
78+ }
79+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
80+ // floor(avail*weight/sum) without overflowing on a huge area.
81+ weight := w.laneWeight(i, S, u)
82+ return avail/sum*weight + avail%sum*weight/sum
2383 }
2484
2585 // equalShare is the width of each of k columns sharing the usable width
@@ -51,13 +111,23 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
51111 g := w.gap()
52112 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
53113 view := w.View + w.shiftPixels()
54- for i := range w.Columns {
55- r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
56- if w.Columns[i].FullWidth && !ignoreFullWidth {
57- r.X = w.Usable.X + g
58- r.W = max(w.Usable.W-2*g, 0)
114+ for lane := 0; ; lane++ {
115+ first, last := w.laneBounds(lane)
116+ if first > last {
117+ break
118+ }
119+ S, u, sum := w.laneStats(first, last)
120+ x := g
121+ for i := first; i <= last; i++ {
122+ cw := w.laneWidth(i, first, last, S, u, sum)
123+ r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
124+ if w.Columns[i].FullWidth && !ignoreFullWidth {
125+ r.X = w.Usable.X + g
126+ r.W = max(w.Usable.W-2*g, 0)
127+ }
128+ rects[i] = r
129+ x += cw + g
59130 }
60- rects[i] = r
61131 }
62132 return rects
63133 }
@@ -4,8 +4,16 @@ import "slices"
4 4
5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+
8+// cellX is the x of column i in its lane, from the usable area's left edge.
7 func (w *Workspace) cellX(i int) int {9 func (w *Workspace) cellX(i int) int {
8- return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap())10+ first, last := w.laneBounds(w.laneOf(i))
11+ S, u, sum := w.laneStats(first, last)
12+ x := w.gap()
13+ for j := first; j < i; j++ {
14+ x += w.laneWidth(j, first, last, S, u, sum) + w.gap()
15+ }
16+ return x
9 }17 }
10 func (w *Workspace) cascadeBands() (n, spacing int) {18 func (w *Workspace) cascadeBands() (n, spacing int) {
11 if len(w.Columns) == 0 || w.Usable.H <= 0 {19 if len(w.Columns) == 0 || w.Usable.H <= 0 {
@@ -14,12 +22,64 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
14 return w.band(len(w.Columns)-1) + 1, w.Usable.H22 return w.band(len(w.Columns)-1) + 1, w.Usable.H
15 }23 }
16 24
17-// cellWidth is the width of the cell of column i: the columns of its band25+// cellWidth is the width of column i in its lane: the columns of a band
18-// share the usable width equally, as in fixed overflow, so a lone column26+// (the whole row in fixed overflow) share the usable width by their cells,
19-// fills it.27+// auto columns sharing what the others leave, so a lone column fills it.
20 func (w *Workspace) cellWidth(i int) int {28 func (w *Workspace) cellWidth(i int) int {
21- first, last := w.laneBounds(w.band(i))29+ first, last := w.laneBounds(w.laneOf(i))
22- return w.equalShare(last - first + 1)30+ S, u, sum := w.laneStats(first, last)
31+ return w.laneWidth(i, first, last, S, u, sum)
32+}
33+
34+// laneStats measures the lane first..last: S is the sum of the cells of
35+// its set columns, u the count of its auto columns and sum the total of
36+// their weights (laneWeight).
37+func (w *Workspace) laneStats(first, last int) (S, u, sum int) {
38+ for i := first; i <= last; i++ {
39+ if c := w.Columns[i].Cells; c > 0 {
40+ S += c
41+ } else {
42+ u++
43+ }
44+ }
45+ for i := first; i <= last; i++ {
46+ sum += w.laneWeight(i, S, u)
47+ }
48+ return S, u, sum
49+}
50+
51+// laneWeight is the share of column i in its lane, given its stats. With
52+// room left for every auto column, they share the cells the set ones leave
53+// and the set ones keep their cells; otherwise an auto column weighs one
54+// cell.
55+func (w *Workspace) laneWeight(i, S, u int) int {
56+ k := max(w.MaxColumns, 1)
57+ c := w.Columns[i].Cells
58+ switch {
59+ case u == 0:
60+ return c
61+ case k-S >= u:
62+ if c > 0 {
63+ return c * u
64+ }
65+ return k - S
66+ case c > 0:
67+ return c
68+ }
69+ return 1
70+}
71+
72+// laneWidth is the width of column i of the lane first..last with the
73+// stats of laneStats: the usable width after gaps, split by weight. The
74+// remainder under one pixel per weight stays empty, as in equalShare.
75+func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
76+ if sum <= 0 {
77+ return 0
78+ }
79+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
80+ // floor(avail*weight/sum) without overflowing on a huge area.
81+ weight := w.laneWeight(i, S, u)
82+ return avail/sum*weight + avail%sum*weight/sum
23 }83 }
24 84
25 // equalShare is the width of each of k columns sharing the usable width85 // equalShare is the width of each of k columns sharing the usable width
@@ -51,13 +111,23 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
51 g := w.gap()111 g := w.gap()
52 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]112 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
53 view := w.View + w.shiftPixels()113 view := w.View + w.shiftPixels()
54- for i := range w.Columns {114+ for lane := 0; ; lane++ {
55- r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}115+ first, last := w.laneBounds(lane)
56- if w.Columns[i].FullWidth && !ignoreFullWidth {116+ if first > last {
57- r.X = w.Usable.X + g117+ break
58- r.W = max(w.Usable.W-2*g, 0)118+ }
119+ S, u, sum := w.laneStats(first, last)
120+ x := g
121+ for i := first; i <= last; i++ {
122+ cw := w.laneWidth(i, first, last, S, u, sum)
123+ r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
124+ if w.Columns[i].FullWidth && !ignoreFullWidth {
125+ r.X = w.Usable.X + g
126+ r.W = max(w.Usable.W-2*g, 0)
127+ }
128+ rects[i] = r
129+ x += cw + g
59 }130 }
60- rects[i] = r
61 }131 }
62 return rects132 return rects
63 }133 }
A internal/core/column_cells_test.go +398 -0
@@ -0,0 +1,398 @@
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("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+ })
342+ t.Run("scroll", func(t *testing.T) {
343+ w := cellsWorkspace(OverflowScroll, 2, 2)
344+ w.Columns[0].Cells = 3
345+ w.FocusID(1)
346+ if got := widthsOf(w)[0]; got != 800 {
347+ t.Fatalf("width %d, at most max-columns cells", got)
348+ }
349+ w.Apply(ActionCycleColumnWidth)
350+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
351+ t.Fatalf("cells %v", got)
352+ }
353+ })
354+ t.Run("a moved column keeps its cells", func(t *testing.T) {
355+ src := cellsWorkspace(OverflowScroll, 4, 2)
356+ src.Columns[1].Cells = 3
357+ dst := cellsWorkspace(OverflowScroll, 2, 1)
358+ col, ok := src.takeColumn()
359+ if !ok {
360+ t.Fatal("no column")
361+ }
362+ dst.receive(col, 1)
363+ if got := cellsOf(dst); !reflect.DeepEqual(got, []int{0, 3}) {
364+ t.Fatalf("cells %v", got)
365+ }
366+ })
367+}
368+
369+// The overview blocks the grow and resize binds, like the other window
370+// mutations, and its cascade preview keeps the columns' cells.
371+func TestOverviewKeepsCells(t *testing.T) {
372+ m := newMonitor("", "")
373+ m.SetOutput(900, 600)
374+ m.SetOverflow(OverflowCascade)
375+ m.SetMaxColumns(4)
376+ for id := WindowID(1); id <= 2; id++ {
377+ m.AddWindow(id)
378+ }
379+ w := m.Current()
380+ w.Columns[0].Cells, w.Columns[1].Cells = 1, 3
381+ screen := w.columnRects()
382+ if screen[0].W != 225 || screen[1].W != 675 {
383+ t.Fatalf("screen widths %v, want 225 and 675", screen)
384+ }
385+ m.ToggleOverview()
386+ tiles, _, _ := w.previewTiles()
387+ for i, p := range tiles {
388+ if want := screen[i]; p.Rect.W != want.W || p.Rect.X != want.X-w.Usable.X || p.Rect.H != want.H {
389+ t.Fatalf("column %d: preview %+v, screen %+v", i, p.Rect, want)
390+ }
391+ }
392+ for _, a := range []Action{ActionCycleColumnWidth, "set-column-width +1", "set-column-width -1"} {
393+ m.Apply(a)
394+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{1, 3}) {
395+ t.Fatalf("%s in the overview: cells %v", a, got)
396+ }
397+ }
398+}
@@ -0,0 +1,398 @@
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("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+ })
342+ t.Run("scroll", func(t *testing.T) {
343+ w := cellsWorkspace(OverflowScroll, 2, 2)
344+ w.Columns[0].Cells = 3
345+ w.FocusID(1)
346+ if got := widthsOf(w)[0]; got != 800 {
347+ t.Fatalf("width %d, at most max-columns cells", got)
348+ }
349+ w.Apply(ActionCycleColumnWidth)
350+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0}) {
351+ t.Fatalf("cells %v", got)
352+ }
353+ })
354+ t.Run("a moved column keeps its cells", func(t *testing.T) {
355+ src := cellsWorkspace(OverflowScroll, 4, 2)
356+ src.Columns[1].Cells = 3
357+ dst := cellsWorkspace(OverflowScroll, 2, 1)
358+ col, ok := src.takeColumn()
359+ if !ok {
360+ t.Fatal("no column")
361+ }
362+ dst.receive(col, 1)
363+ if got := cellsOf(dst); !reflect.DeepEqual(got, []int{0, 3}) {
364+ t.Fatalf("cells %v", got)
365+ }
366+ })
367+}
368+
369+// The overview blocks the grow and resize binds, like the other window
370+// mutations, and its cascade preview keeps the columns' cells.
371+func TestOverviewKeepsCells(t *testing.T) {
372+ m := newMonitor("", "")
373+ m.SetOutput(900, 600)
374+ m.SetOverflow(OverflowCascade)
375+ m.SetMaxColumns(4)
376+ for id := WindowID(1); id <= 2; id++ {
377+ m.AddWindow(id)
378+ }
379+ w := m.Current()
380+ w.Columns[0].Cells, w.Columns[1].Cells = 1, 3
381+ screen := w.columnRects()
382+ if screen[0].W != 225 || screen[1].W != 675 {
383+ t.Fatalf("screen widths %v, want 225 and 675", screen)
384+ }
385+ m.ToggleOverview()
386+ tiles, _, _ := w.previewTiles()
387+ for i, p := range tiles {
388+ if want := screen[i]; p.Rect.W != want.W || p.Rect.X != want.X-w.Usable.X || p.Rect.H != want.H {
389+ t.Fatalf("column %d: preview %+v, screen %+v", i, p.Rect, want)
390+ }
391+ }
392+ for _, a := range []Action{ActionCycleColumnWidth, "set-column-width +1", "set-column-width -1"} {
393+ m.Apply(a)
394+ if got := cellsOf(w); !reflect.DeepEqual(got, []int{1, 3}) {
395+ t.Fatalf("%s in the overview: cells %v", a, got)
396+ }
397+ }
398+}
M internal/core/core.go +1 -10
@@ -116,7 +116,6 @@ type Core struct {
116116 layerChanged bool
117117 sent sentCache
118118 specs []NamedWorkspace
119- presets []Width
120119 slots map[slotKey]*slotState
121120 rules []rule
122121 placement spawnPlacement
@@ -227,14 +226,6 @@ func (c *Core) apply(cfg ports.Config) error {
227226 if cfg.Layout.MaxColumns < 1 {
228227 return fmt.Errorf("invalid max columns")
229228 }
230- presets := make([]Width, 0, len(cfg.Layout.Presets))
231- for _, s := range cfg.Layout.Presets {
232- v, e := ParseWidth(s)
233- if e != nil {
234- return e
235- }
236- presets = append(presets, v)
237- }
238229 if cfg.Layout.Gaps < 0 {
239230 return fmt.Errorf("invalid gaps")
240231 }
@@ -328,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error {
328319 c.cfg = cfg
329320 c.binds = binds
330321 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
331- c.specs, c.presets, c.rules = named, presets, rules
322+ c.specs, c.rules = named, rules
332323 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
333324 c.named()
334325 for _, s := range c.screens {
@@ -116,7 +116,6 @@ type Core struct {
116 layerChanged bool116 layerChanged bool
117 sent sentCache117 sent sentCache
118 specs []NamedWorkspace118 specs []NamedWorkspace
119- presets []Width
120 slots map[slotKey]*slotState119 slots map[slotKey]*slotState
121 rules []rule120 rules []rule
122 placement spawnPlacement121 placement spawnPlacement
@@ -227,14 +226,6 @@ func (c *Core) apply(cfg ports.Config) error {
227 if cfg.Layout.MaxColumns < 1 {226 if cfg.Layout.MaxColumns < 1 {
228 return fmt.Errorf("invalid max columns")227 return fmt.Errorf("invalid max columns")
229 }228 }
230- presets := make([]Width, 0, len(cfg.Layout.Presets))
231- for _, s := range cfg.Layout.Presets {
232- v, e := ParseWidth(s)
233- if e != nil {
234- return e
235- }
236- presets = append(presets, v)
237- }
238 if cfg.Layout.Gaps < 0 {229 if cfg.Layout.Gaps < 0 {
239 return fmt.Errorf("invalid gaps")230 return fmt.Errorf("invalid gaps")
240 }231 }
@@ -328,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error {
328 c.cfg = cfg319 c.cfg = cfg
329 c.binds = binds320 c.binds = binds
330 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]321 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
331- c.specs, c.presets, c.rules = named, presets, rules322+ c.specs, c.rules = named, rules
332 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)323 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
333 c.named()324 c.named()
334 for _, s := range c.screens {325 for _, s := range c.screens {
M internal/core/drag.go +3 -3
@@ -463,14 +463,14 @@ func (c *Core) drop(id WindowID, t dropTarget) {
463463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
464464 w.FocusID(id)
465465 } else {
466- width := Width{}
466+ cells := 0
467467 if lone {
468- width = src.Columns[ci].Width
468+ cells = src.Columns[ci].Cells
469469 }
470470 srcIndex := ci
471471 src.dropSlotOf(id)
472472 src.RemoveWindow(id)
473- col := Column{Windows: []WindowID{id}, Width: width}
473+ col := Column{Windows: []WindowID{id}, Cells: cells}
474474 switch t.kind {
475475 case dropAbove, dropBelow:
476476 ai := w.columnOf(t.anchor)
@@ -463,14 +463,14 @@ func (c *Core) drop(id WindowID, t dropTarget) {
463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
464 w.FocusID(id)464 w.FocusID(id)
465 } else {465 } else {
466- width := Width{}466+ cells := 0
467 if lone {467 if lone {
468- width = src.Columns[ci].Width468+ cells = src.Columns[ci].Cells
469 }469 }
470 srcIndex := ci470 srcIndex := ci
471 src.dropSlotOf(id)471 src.dropSlotOf(id)
472 src.RemoveWindow(id)472 src.RemoveWindow(id)
473- col := Column{Windows: []WindowID{id}, Width: width}473+ col := Column{Windows: []WindowID{id}, Cells: cells}
474 switch t.kind {474 switch t.kind {
475 case dropAbove, dropBelow:475 case dropAbove, dropBelow:
476 ai := w.columnOf(t.anchor)476 ai := w.columnOf(t.anchor)
M internal/core/expel.go +3 -3
@@ -40,13 +40,13 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
4040 // The column goes away, and a slot with it.
4141 w.stackWindow(id, w.Columns[next].Windows[0])
4242 case !w.full():
43- c := Column{Windows: []WindowID{id}, Width: col.Width}
43+ c := Column{Windows: []WindowID{id}, Cells: col.Cells}
4444 if col.Focus == 0 && col.Slot != 0 {
4545 // The slot and its width follow the slot window.
4646 c.Slot = col.Slot
47- w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}
47+ w.Columns[src].Slot, w.Columns[src].Cells = 0, 0
4848 } else if col.Slot != 0 {
49- c.Width = Width{}
49+ c.Cells = 0
5050 }
5151 w.RemoveWindow(id)
5252 w.insertColumn(src+max(dir, 0), c)
@@ -40,13 +40,13 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
40 // The column goes away, and a slot with it.40 // The column goes away, and a slot with it.
41 w.stackWindow(id, w.Columns[next].Windows[0])41 w.stackWindow(id, w.Columns[next].Windows[0])
42 case !w.full():42 case !w.full():
43- c := Column{Windows: []WindowID{id}, Width: col.Width}43+ c := Column{Windows: []WindowID{id}, Cells: col.Cells}
44 if col.Focus == 0 && col.Slot != 0 {44 if col.Focus == 0 && col.Slot != 0 {
45 // The slot and its width follow the slot window.45 // The slot and its width follow the slot window.
46 c.Slot = col.Slot46 c.Slot = col.Slot
47- w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}47+ w.Columns[src].Slot, w.Columns[src].Cells = 0, 0
48 } else if col.Slot != 0 {48 } else if col.Slot != 0 {
49- c.Width = Width{}49+ c.Cells = 0
50 }50 }
51 w.RemoveWindow(id)51 w.RemoveWindow(id)
52 w.insertColumn(src+max(dir, 0), c)52 w.insertColumn(src+max(dir, 0), c)
M internal/core/expel_test.go +2 -2
@@ -100,10 +100,10 @@ func TestConsumeOrExpelSlots(t *testing.T) {
100100 }
101101 t.Run("the slot follows its window", func(t *testing.T) {
102102 w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
103- w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}
103+ w.Columns[0].Slot, w.Columns[0].Cells = 1, 2
104104 w.Columns[0].Focus = 0
105105 w.ConsumeOrExpel(1)
106- if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {
106+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Cells != 2 || w.Columns[0].Cells != 0 {
107107 t.Fatal(got, slots(w), w.Columns)
108108 }
109109 })
@@ -100,10 +100,10 @@ func TestConsumeOrExpelSlots(t *testing.T) {
100 }100 }
101 t.Run("the slot follows its window", func(t *testing.T) {101 t.Run("the slot follows its window", func(t *testing.T) {
102 w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})102 w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
103- w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}103+ w.Columns[0].Slot, w.Columns[0].Cells = 1, 2
104 w.Columns[0].Focus = 0104 w.Columns[0].Focus = 0
105 w.ConsumeOrExpel(1)105 w.ConsumeOrExpel(1)
106- if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {106+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Cells != 2 || w.Columns[0].Cells != 0 {
107 t.Fatal(got, slots(w), w.Columns)107 t.Fatal(got, slots(w), w.Columns)
108 }108 }
109 })109 })
M internal/core/floating_test.go +10 -10
@@ -15,7 +15,7 @@ func TestToggleWindowStash(t *testing.T) {
1515 w.border = 2
1616 w.AddWindow(1)
1717 w.AddWindow(2)
18- w.Columns[1].Width = Width{Pixels: 35}
18+ w.Columns[1].Cells = 3
1919 if stacked {
2020 w.Columns[1].Windows = []WindowID{3, 2}
2121 w.Columns[1].Focus = 1
@@ -34,7 +34,7 @@ func TestToggleWindowStash(t *testing.T) {
3434 }
3535 w.Apply(ActionToggleWindowStash)
3636 id, _ := w.Focused()
37- if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) {
37+ if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Cells != 3 || !slices.Contains(w.Columns[1].Windows, 2) {
3838 t.Fatalf("round trip: %+v", w)
3939 }
4040 })
@@ -103,7 +103,7 @@ func TestStashStrip(t *testing.T) {
103103 // Column actions do nothing while the stash has the focus.
104104 w.Apply(ActionMoveColumnLeft)
105105 w.Apply(ActionCycleColumnWidth)
106- if w.Columns[0].Windows[0] != 1 || w.Columns[0].Width != (Width{}) {
106+ if w.Columns[0].Windows[0] != 1 || w.Columns[0].Cells != 0 {
107107 t.Fatalf("column changed: %+v", w.Columns)
108108 }
109109 // A gap as wide as the margin leaves nothing to show: neighbors wait
@@ -263,7 +263,7 @@ func TestFullscreenPinsFocus(t *testing.T) {
263263 w.focusCover()
264264 w.Apply(ActionMoveColumnLeft)
265265 w.Apply(ActionCycleColumnWidth)
266- if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Width != (Width{}) {
266+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Cells != 0 {
267267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
268268 }
269269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {
@@ -353,20 +353,20 @@ func TestToggleWindowStashKeepsFullWidth(t *testing.T) {
353353 }
354354 }
355355
356-// A lone expanded column (fixed overflow) comes back expanded.
357-func TestToggleWindowStashKeepsExpanded(t *testing.T) {
356+// A lone wide column (fixed overflow) comes back at its width in cells.
357+func TestToggleWindowStashKeepsCells(t *testing.T) {
358358 w := workspace()
359359 w.Overflow = OverflowFixed
360360 w.SetMaxColumns(3)
361361 w.AddWindow(1)
362362 w.AddWindow(2)
363- w.CycleWidth()
364- if !w.Columns[w.Focus].Expanded {
365- t.Fatal("column not expanded")
363+ w.Apply(ActionCycleColumnWidth)
364+ if w.Columns[w.Focus].Cells != 2 {
365+ t.Fatal("column not wider", w.Columns)
366366 }
367367 w.Apply(ActionToggleWindowStash)
368368 w.Apply(ActionToggleWindowStash)
369- if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {
369+ if id, _ := w.Focused(); id != 2 || w.Columns[w.Focus].Cells != 2 {
370370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
371371 }
372372 }
@@ -15,7 +15,7 @@ func TestToggleWindowStash(t *testing.T) {
15 w.border = 215 w.border = 2
16 w.AddWindow(1)16 w.AddWindow(1)
17 w.AddWindow(2)17 w.AddWindow(2)
18- w.Columns[1].Width = Width{Pixels: 35}18+ w.Columns[1].Cells = 3
19 if stacked {19 if stacked {
20 w.Columns[1].Windows = []WindowID{3, 2}20 w.Columns[1].Windows = []WindowID{3, 2}
21 w.Columns[1].Focus = 121 w.Columns[1].Focus = 1
@@ -34,7 +34,7 @@ func TestToggleWindowStash(t *testing.T) {
34 }34 }
35 w.Apply(ActionToggleWindowStash)35 w.Apply(ActionToggleWindowStash)
36 id, _ := w.Focused()36 id, _ := w.Focused()
37- if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) {37+ if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Cells != 3 || !slices.Contains(w.Columns[1].Windows, 2) {
38 t.Fatalf("round trip: %+v", w)38 t.Fatalf("round trip: %+v", w)
39 }39 }
40 })40 })
@@ -103,7 +103,7 @@ func TestStashStrip(t *testing.T) {
103 // Column actions do nothing while the stash has the focus.103 // Column actions do nothing while the stash has the focus.
104 w.Apply(ActionMoveColumnLeft)104 w.Apply(ActionMoveColumnLeft)
105 w.Apply(ActionCycleColumnWidth)105 w.Apply(ActionCycleColumnWidth)
106- if w.Columns[0].Windows[0] != 1 || w.Columns[0].Width != (Width{}) {106+ if w.Columns[0].Windows[0] != 1 || w.Columns[0].Cells != 0 {
107 t.Fatalf("column changed: %+v", w.Columns)107 t.Fatalf("column changed: %+v", w.Columns)
108 }108 }
109 // A gap as wide as the margin leaves nothing to show: neighbors wait109 // A gap as wide as the margin leaves nothing to show: neighbors wait
@@ -263,7 +263,7 @@ func TestFullscreenPinsFocus(t *testing.T) {
263 w.focusCover()263 w.focusCover()
264 w.Apply(ActionMoveColumnLeft)264 w.Apply(ActionMoveColumnLeft)
265 w.Apply(ActionCycleColumnWidth)265 w.Apply(ActionCycleColumnWidth)
266- if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Width != (Width{}) {266+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Cells != 0 {
267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
268 }268 }
269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {
@@ -353,20 +353,20 @@ func TestToggleWindowStashKeepsFullWidth(t *testing.T) {
353 }353 }
354 }354 }
355 355
356-// A lone expanded column (fixed overflow) comes back expanded.356+// A lone wide column (fixed overflow) comes back at its width in cells.
357-func TestToggleWindowStashKeepsExpanded(t *testing.T) {357+func TestToggleWindowStashKeepsCells(t *testing.T) {
358 w := workspace()358 w := workspace()
359 w.Overflow = OverflowFixed359 w.Overflow = OverflowFixed
360 w.SetMaxColumns(3)360 w.SetMaxColumns(3)
361 w.AddWindow(1)361 w.AddWindow(1)
362 w.AddWindow(2)362 w.AddWindow(2)
363- w.CycleWidth()363+ w.Apply(ActionCycleColumnWidth)
364- if !w.Columns[w.Focus].Expanded {364+ if w.Columns[w.Focus].Cells != 2 {
365- t.Fatal("column not expanded")365+ t.Fatal("column not wider", w.Columns)
366 }366 }
367 w.Apply(ActionToggleWindowStash)367 w.Apply(ActionToggleWindowStash)
368 w.Apply(ActionToggleWindowStash)368 w.Apply(ActionToggleWindowStash)
369- if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {369+ if id, _ := w.Focused(); id != 2 || w.Columns[w.Focus].Cells != 2 {
370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
371 }371 }
372 }372 }
M internal/core/free.go +5 -4
@@ -74,15 +74,16 @@ func (w *Workspace) freeAction(a Action) bool {
7474 }
7575 f := &w.Floats[i]
7676 u := w.Usable
77- if axis, pct, ok := ResizeArg(a); ok {
77+ if axis, step, ok := ResizeArg(a); ok {
7878 if u.W <= 0 || u.H <= 0 {
7979 return true
8080 }
81- // The size is the client's: the border must fit too.
81+ // The size is the client's: the border must fit too. A width
82+ // step is in cells, a cell being Usable.W/MaxColumns.
8283 if axis == ResizeWidth {
83- f.W = max(min(f.W+u.W*pct/100, u.W-2*w.border), freeMinSize)
84+ f.W = max(min(f.W+step*u.W/max(w.MaxColumns, 1), u.W-2*w.border), freeMinSize)
8485 } else {
85- f.H = max(min(f.H+u.H*pct/100, u.H-2*w.border), freeMinSize)
86+ f.H = max(min(f.H+u.H*step/100, u.H-2*w.border), freeMinSize)
8687 }
8788 w.clampFree(f)
8889 return true
@@ -74,15 +74,16 @@ func (w *Workspace) freeAction(a Action) bool {
74 }74 }
75 f := &w.Floats[i]75 f := &w.Floats[i]
76 u := w.Usable76 u := w.Usable
77- if axis, pct, ok := ResizeArg(a); ok {77+ if axis, step, ok := ResizeArg(a); ok {
78 if u.W <= 0 || u.H <= 0 {78 if u.W <= 0 || u.H <= 0 {
79 return true79 return true
80 }80 }
81- // The size is the client's: the border must fit too.81+ // The size is the client's: the border must fit too. A width
82+ // step is in cells, a cell being Usable.W/MaxColumns.
82 if axis == ResizeWidth {83 if axis == ResizeWidth {
83- f.W = max(min(f.W+u.W*pct/100, u.W-2*w.border), freeMinSize)84+ f.W = max(min(f.W+step*u.W/max(w.MaxColumns, 1), u.W-2*w.border), freeMinSize)
84 } else {85 } else {
85- f.H = max(min(f.H+u.H*pct/100, u.H-2*w.border), freeMinSize)86+ f.H = max(min(f.H+u.H*step/100, u.H-2*w.border), freeMinSize)
86 }87 }
87 w.clampFree(f)88 w.clampFree(f)
88 return true89 return true
M internal/core/free_test.go +7 -7
@@ -33,7 +33,7 @@ func TestFreeFloatRect(t *testing.T) {
3333
3434 func TestToggleFloatingRoundTrip(t *testing.T) {
3535 w := freeWorkspace()
36- w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Width: Width{Num: 1, Den: 2}}}
36+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Cells: 1}}
3737 w.Focus = 1
3838 r, _ := w.placed(3)
3939 w.Apply(ActionToggleFloating)
@@ -58,11 +58,11 @@ func TestToggleFloatingRoundTrip(t *testing.T) {
5858 }
5959 // A lone column comes back at its width and index.
6060 w.Focus = 1
61- w.Columns[1] = Column{Windows: []WindowID{2}, Width: Width{Num: 1, Den: 2}}
61+ w.Columns[1] = Column{Windows: []WindowID{2}, Cells: 1}
6262 w.Columns = append(w.Columns, Column{Windows: []WindowID{3}})
6363 w.Apply(ActionToggleFloating)
6464 w.Apply(ActionToggleFloating)
65- if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Width != (Width{Num: 1, Den: 2}) || w.Focus != 1 {
65+ if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Cells != 1 || w.Focus != 1 {
6666 t.Fatal(w.Columns, w.Focus)
6767 }
6868 }
@@ -188,7 +188,7 @@ func TestFreeFloatNoUsableArea(t *testing.T) {
188188 t.Fatal(fx)
189189 }
190190 w.Apply(ActionMoveColumnLeft)
191- w.Apply("set-column-width +10%")
191+ w.Apply("set-column-width +1")
192192 if w.floatIndex(1) < 0 {
193193 t.Fatal(w.Floats)
194194 }
@@ -213,13 +213,13 @@ func TestFreeFloatKeyboard(t *testing.T) {
213213 if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X {
214214 t.Fatal(got)
215215 }
216- w.Apply("set-column-width +10%")
216+ w.Apply("set-column-width +1")
217217 w.Apply("set-window-height -10%")
218- if w.Floats[i].W != 200+w.Usable.W/10 || w.Floats[i].H != freeMinSize {
218+ if w.Floats[i].W != 200+w.Usable.W/w.MaxColumns || w.Floats[i].H != freeMinSize {
219219 t.Fatal(w.Floats[i])
220220 }
221221 // The columns did not change.
222- if len(w.Columns) != 1 || w.Columns[0].Width != (Width{}) {
222+ if len(w.Columns) != 1 || w.Columns[0].Cells != 0 {
223223 t.Fatal(w.Columns)
224224 }
225225 }
@@ -33,7 +33,7 @@ func TestFreeFloatRect(t *testing.T) {
33 33
34 func TestToggleFloatingRoundTrip(t *testing.T) {34 func TestToggleFloatingRoundTrip(t *testing.T) {
35 w := freeWorkspace()35 w := freeWorkspace()
36- w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Width: Width{Num: 1, Den: 2}}}36+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Cells: 1}}
37 w.Focus = 137 w.Focus = 1
38 r, _ := w.placed(3)38 r, _ := w.placed(3)
39 w.Apply(ActionToggleFloating)39 w.Apply(ActionToggleFloating)
@@ -58,11 +58,11 @@ func TestToggleFloatingRoundTrip(t *testing.T) {
58 }58 }
59 // A lone column comes back at its width and index.59 // A lone column comes back at its width and index.
60 w.Focus = 160 w.Focus = 1
61- w.Columns[1] = Column{Windows: []WindowID{2}, Width: Width{Num: 1, Den: 2}}61+ w.Columns[1] = Column{Windows: []WindowID{2}, Cells: 1}
62 w.Columns = append(w.Columns, Column{Windows: []WindowID{3}})62 w.Columns = append(w.Columns, Column{Windows: []WindowID{3}})
63 w.Apply(ActionToggleFloating)63 w.Apply(ActionToggleFloating)
64 w.Apply(ActionToggleFloating)64 w.Apply(ActionToggleFloating)
65- if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Width != (Width{Num: 1, Den: 2}) || w.Focus != 1 {65+ if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Cells != 1 || w.Focus != 1 {
66 t.Fatal(w.Columns, w.Focus)66 t.Fatal(w.Columns, w.Focus)
67 }67 }
68 }68 }
@@ -188,7 +188,7 @@ func TestFreeFloatNoUsableArea(t *testing.T) {
188 t.Fatal(fx)188 t.Fatal(fx)
189 }189 }
190 w.Apply(ActionMoveColumnLeft)190 w.Apply(ActionMoveColumnLeft)
191- w.Apply("set-column-width +10%")191+ w.Apply("set-column-width +1")
192 if w.floatIndex(1) < 0 {192 if w.floatIndex(1) < 0 {
193 t.Fatal(w.Floats)193 t.Fatal(w.Floats)
194 }194 }
@@ -213,13 +213,13 @@ func TestFreeFloatKeyboard(t *testing.T) {
213 if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X {213 if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X {
214 t.Fatal(got)214 t.Fatal(got)
215 }215 }
216- w.Apply("set-column-width +10%")216+ w.Apply("set-column-width +1")
217 w.Apply("set-window-height -10%")217 w.Apply("set-window-height -10%")
218- if w.Floats[i].W != 200+w.Usable.W/10 || w.Floats[i].H != freeMinSize {218+ if w.Floats[i].W != 200+w.Usable.W/w.MaxColumns || w.Floats[i].H != freeMinSize {
219 t.Fatal(w.Floats[i])219 t.Fatal(w.Floats[i])
220 }220 }
221 // The columns did not change.221 // The columns did not change.
222- if len(w.Columns) != 1 || w.Columns[0].Width != (Width{}) {222+ if len(w.Columns) != 1 || w.Columns[0].Cells != 0 {
223 t.Fatal(w.Columns)223 t.Fatal(w.Columns)
224 }224 }
225 }225 }
M internal/core/layout.go +209 -195
@@ -1,12 +1,8 @@
11 package core
22
33 import (
4- "fmt"
54 "math"
6- "math/bits"
75 "slices"
8- "strconv"
9- "strings"
106 "time"
117
128 "github.com/bnema/neferwl/internal/ports"
@@ -16,87 +12,17 @@ type WindowID = ports.WindowID
1612
1713 type Rect = ports.Rect
1814
19-type Width struct{ Num, Den, Pixels int }
15+// maxCells is the largest width a rule or a slot may declare, in cells.
16+const maxCells = 16
2017
21-func ParseWidth(s string) (Width, error) {
22- if s == "1" {
23- return Width{Num: 1, Den: 1}, nil
24- }
25- if strings.HasSuffix(s, "px") {
26- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
27- if e == nil && n > 0 {
28- return Width{Pixels: n}, nil
29- }
30- } else if strings.HasSuffix(s, "%") {
31- n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
32- if e == nil && n > 0 && n <= 100 {
33- return Width{Num: n, Den: 100}, nil
34- }
35- } else {
36- p := strings.Split(s, "/")
37- if len(p) == 2 {
38- a, e1 := strconv.Atoi(p[0])
39- b, e2 := strconv.Atoi(p[1])
40- if e1 == nil && e2 == nil && a > 0 && a <= b {
41- return Width{Num: a, Den: b}, nil
42- }
43- }
44- }
45- return Width{}, fmt.Errorf("invalid width %q", s)
46-}
47-
48-// same reports whether v and o are the same width: 1/2 and 50/100 are.
49-func (v Width) same(o Width) bool {
50- if v.Pixels > 0 || o.Pixels > 0 || v.Den == 0 || o.Den == 0 {
51- return v == o
52- }
53- return v.Num*o.Den == o.Num*v.Den
54-}
55-
56-// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
57-// Widths are clamped to the available area (zero if no space remains).
58-func (v Width) Resolve(usableW, gaps int) int {
59- if usableW <= 0 {
60- return 0
61- }
62- gaps = min(max(gaps, 0), usableW/2)
63- limit := max(usableW-2*gaps, 0)
64- if limit == 0 {
65- return 0
66- }
67- if v.Pixels > 0 {
68- return min(v.Pixels, limit)
69- }
70- if v.Den <= 0 || v.Num <= 0 {
71- return 1
72- }
73- // Cap invalid fractions above one before multiplying; the result would
74- // be clamped to the available width anyway.
75- num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
76- avail := uint64(usableW - gaps)
77- q, r := avail/den, avail%den
78- hi, lo := bits.Mul64(r, num)
79- lo, carry := bits.Add64(lo, den/2, 0)
80- hi += carry
81- // hi < den: r and num are both smaller than den.
82- fraction, _ := bits.Div64(hi, lo, den)
83- rounded := q*num + fraction // no larger than avail (plus rounding)
84- if rounded <= uint64(gaps) {
85- return 1
86- }
87- return int(min(rounded-uint64(gaps), uint64(limit)))
88-}
89-
90-// Column.Width is zero (auto) until the user picks a preset: auto columns share
91-// the usable width equally, up to MaxColumns visible at once.
18+// Column.Cells is the width of the column in cells, a cell being one
19+// max-columns-th of the usable width. Zero (auto) shares the width equally
20+// with the other auto columns, up to MaxColumns visible at once.
9221 type Column struct {
9322 Windows []WindowID
94- Width Width
23+ Cells int
9524 FullWidth bool
96- // Expanded is the fixed-overflow wide column: max-columns - 1 cells,
97- // the others share the last one. One column at most has it.
98- Expanded bool
99- Focus int
25+ Focus int
10026 // Slot is the declared column number (workspace.<name>.column.N) of a
10127 // slot window; 0 for normal columns.
10228 Slot int
@@ -192,7 +118,6 @@ type Workspace struct {
192118 MaxColumns int
193119 // border is drawn around floating windows, inside their rect.
194120 border int
195- presets []Width
196121 fullscreen WindowID
197122 // left is the window whose fullscreen the user left for another
198123 // window: its client requests are refused until it is focused again,
@@ -242,14 +167,13 @@ type Workspace struct {
242167 }
243168
244169 // origPlace remembers where a stashed window was: its column, its row in a
245-// stacked column, the column's width and slot.
170+// stacked column, the column's width in cells and slot.
246171 type origPlace struct {
247172 col, row, slot int
248173 // stacked holds the windows left in the column: the window returns to
249174 // its row in the column that still holds one of them.
250- stacked []WindowID
251- width Width
252- expanded bool
175+ stacked []WindowID
176+ cells int
253177 // fullWidth is the column's maximize-column state.
254178 fullWidth bool
255179 }
@@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) {
552476 }
553477 return c.Windows[c.Focus], true
554478 }
555-func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }
479+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, 0) }
556480
557-// addWindow is AddWindow with the width of the new column (zero: automatic).
558-func (w *Workspace) addWindow(id WindowID, width Width) {
481+// addWindow is AddWindow with the width of the new column in cells (zero:
482+// automatic).
483+func (w *Workspace) addWindow(id WindowID, cells int) {
559484 if id == 0 || w.has(id) {
560485 return
561486 }
@@ -567,10 +492,10 @@ func (w *Workspace) addWindow(id WindowID, width Width) {
567492 // A covering float or stashed window may have no column under it.
568493 at = min(w.Focus+1, len(w.Columns))
569494 }
570- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})
495+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
571496 return
572497 }
573- w.addColumn(Column{Windows: []WindowID{id}, Width: width})
498+ w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
574499 }
575500
576501 // addColumn places a new column like a new window and focuses it.
@@ -595,7 +520,7 @@ func (w *Workspace) addColumn(col Column) {
595520 // AddSlotWindow places a slot window in its own column, ordered by slot
596521 // number before the other columns. Focus stays on the focused window: slot
597522 // windows are automatic events (ADR 011 golden rule).
598-func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
523+func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
599524 if id == 0 || w.has(id) {
600525 return
601526 }
@@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
606531 at = i + 1
607532 }
608533 }
609- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
534+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
610535 if len(w.Columns) > 1 && at <= w.Focus {
611536 w.Focus++
612537 }
613538 w.scroll()
614539 }
615540
616-// setSlotWidth updates the width of slot column n, if present.
617-func (w *Workspace) setSlotWidth(n int, width Width) {
541+// setSlotCells updates the width in cells of slot column n, if present.
542+func (w *Workspace) setSlotCells(n int, cells int) {
618543 for i := range w.Columns {
619544 if w.Columns[i].Slot == n {
620- w.Columns[i].Width = width
545+ w.Columns[i].Cells = cells
621546 }
622547 }
623548 w.scroll()
@@ -791,8 +716,8 @@ func (w *Workspace) focusColumn(dir int) {
791716
792717 // columnToward returns the column focus-column-left/right (dir -1/1) goes
793718 // to, or -1 at the edge. Scroll overflow follows the column order; fixed
794-// overflow stacks columns (spiral, expanded strips), so it follows the
795-// screen: the column on that side.
719+// overflow stacks columns (spiral), so it follows the screen: the column
720+// on that side.
796721 func (w *Workspace) columnToward(dir int) int {
797722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
798723 return -1
@@ -939,25 +864,6 @@ func (w *Workspace) MoveWindow(dir int) {
939864 w.scroll()
940865 }
941866
942-// ResizeColumn changes the focused column's width by pct percent of the
943-// usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
944-// overflow shares the width equally: nothing changes there.
945-func (w *Workspace) ResizeColumn(pct int) {
946- if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
947- return
948- }
949- w.unmaximize()
950- g := w.gap()
951- avail := w.Usable.W - g
952- if avail <= 0 {
953- return
954- }
955- // Inverse of Width.Resolve: width = round(p/100 * avail) - g.
956- cur := (200*(w.columnWidth(w.Focus)+g) + avail) / (2 * avail)
957- w.Columns[w.Focus].Width = Width{Num: min(max(cur+pct, 10), 100), Den: 100}
958- w.scroll()
959-}
960-
961867 // minShare is the smallest row height set-window-height leaves, percent.
962868 const minShare = 10
963869
@@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
1038944 return Column{}, false
1039945 }
1040946 col := w.Columns[w.Focus]
1041- col.Slot, col.Expanded = 0, false
947+ col.Slot = 0
1042948 if slices.Contains(col.Windows, w.fullscreen) {
1043949 w.fullscreen = 0
1044950 }
@@ -1071,50 +977,168 @@ func (w *Workspace) insertColumn(at int, col Column) {
1071977 w.scroll()
1072978 }
1073979
1074-// CycleWidth steps the focused column through the presets. Fixed overflow
1075-// ignores presets: it toggles the expanded column instead; cascade does
1076-// nothing.
1077-func (w *Workspace) CycleWidth() {
1078- if w.policy().wraps {
980+// GrowColumn widens the focused column by one cell, taking it from another
981+// column of its lane, or from the empty space in scroll overflow. A lane
982+// that cannot grow further goes back to equal shares when cycle is set
983+// (cycle-column-width); otherwise nothing changes (set-column-width +N).
984+// A lane is the band of a cascade, or the whole row in fixed overflow while
985+// it holds MaxColumns columns at most; past that the spiral ignores cells.
986+func (w *Workspace) GrowColumn(cycle bool) {
987+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1079988 return
1080989 }
1081- if w.Overflow == OverflowFixed {
1082- w.toggleExpanded()
990+ w.unmaximize()
991+ defer w.scroll()
992+ k := max(w.MaxColumns, 1)
993+ if w.policy().equalCells {
994+ if !w.policy().wraps && !w.laneRow() {
995+ return
996+ }
997+ first, last := w.laneBounds(w.laneOf(w.Focus))
998+ n := last - first + 1
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+ }
1005+ return
1006+ }
1007+ cells, ok := w.laneCells(first, last, w.Focus)
1008+ if !ok {
1009+ w.setLaneCells(first, cells)
1010+ if k%n > 0 {
1011+ return // The remainder to the focused column was the step.
1012+ }
1013+ }
1014+ if d := w.donor(first, last, cells); d >= 0 {
1015+ w.moveCell(first, cells, d, w.Focus)
1016+ } else if cycle {
1017+ w.setLaneCells(first, make([]int, n))
1018+ }
10831019 return
10841020 }
1085- if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() {
1021+ c := &w.Columns[w.Focus]
1022+ cur := w.scrollCells(w.Focus)
1023+ switch {
1024+ case cur < k:
1025+ c.Cells = cur + 1
1026+ case cycle:
1027+ c.Cells = 0
1028+ }
1029+}
1030+
1031+// ShrinkColumn narrows the focused column by one cell, giving it to its
1032+// right neighbor in the lane (the left one at the lane's end). A column
1033+// never goes below one cell.
1034+func (w *Workspace) ShrinkColumn() {
1035+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
10861036 return
10871037 }
1088- // auto → presets in order → auto; a width set by set-column-width
1089- // restarts at the first preset.
1090- c := &w.Columns[w.Focus]
1091- next := Width{}
1092- if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) {
1093- next = w.presets[0]
1038+ w.unmaximize()
1039+ defer w.scroll()
1040+ k := max(w.MaxColumns, 1)
1041+ if w.policy().equalCells {
1042+ if !w.policy().wraps && !w.laneRow() {
1043+ return
1044+ }
1045+ first, last := w.laneBounds(w.laneOf(w.Focus))
1046+ n := last - first + 1
1047+ if n == 1 || n >= k {
1048+ return
1049+ }
1050+ cells, _ := w.laneCells(first, last, -1)
1051+ if cells[w.Focus-first] <= 1 {
1052+ return
1053+ }
1054+ to := w.Focus + 1
1055+ if to > last {
1056+ to = w.Focus - 1
1057+ }
1058+ w.moveCell(first, cells, w.Focus, to)
1059+ return
10941060 }
1095- for i, v := range w.presets {
1096- if v.same(c.Width) && i+1 < len(w.presets) {
1097- next = w.presets[i+1]
1098- break
1061+ w.Columns[w.Focus].Cells = max(w.scrollCells(w.Focus)-1, 1)
1062+}
1063+
1064+// scrollCells is the width in cells of column i in scroll overflow: its
1065+// cells, at most the ones in view for an auto column.
1066+func (w *Workspace) scrollCells(i int) int {
1067+ if c := w.Columns[i].Cells; c > 0 {
1068+ return c
1069+ }
1070+ k := max(w.MaxColumns, 1)
1071+ return k / min(len(w.Columns), k)
1072+}
1073+
1074+// laneCells is the cells of the lane first..last, summing to MaxColumns.
1075+// The stored cells are used when every column is set and they sum to it
1076+// (ok); otherwise each column gets an equal share and the remainder goes
1077+// to column remAt, or one cell each to the leftmost columns when remAt is
1078+// negative.
1079+func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1080+ k := max(w.MaxColumns, 1)
1081+ n := last - first + 1
1082+ cells = make([]int, n)
1083+ sum := 0
1084+ for i := range cells {
1085+ cells[i] = w.Columns[first+i].Cells
1086+ sum += cells[i]
1087+ if cells[i] == 0 {
1088+ sum = -1 << 30
10991089 }
11001090 }
1101- c.Width = next
1102- w.unmaximize()
1103- w.scroll()
1091+ if sum == k {
1092+ return cells, true
1093+ }
1094+ for i := range cells {
1095+ cells[i] = k / n
1096+ }
1097+ if remAt >= 0 {
1098+ cells[remAt-first] += k % n
1099+ } else {
1100+ for i := range k % n {
1101+ cells[i]++
1102+ }
1103+ }
1104+ return cells, false
11041105 }
11051106
1106-// toggleExpanded makes the focused column the expanded one, or shrinks it
1107-// back when it already is. One column at most is expanded.
1108-func (w *Workspace) toggleExpanded() {
1109- if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.onFloat() {
1110- return
1107+// setLaneCells stores cells on the lane's columns, from column first.
1108+func (w *Workspace) setLaneCells(first int, cells []int) {
1109+ for i, c := range cells {
1110+ w.Columns[first+i].Cells = c
11111111 }
1112- on := !w.Columns[w.Focus].Expanded
1113- for i := range w.Columns {
1114- w.Columns[i].Expanded = false
1112+}
1113+
1114+// donor is the lane column that gives a cell to the focused one: among the
1115+// others with more than one cell, the largest; on a tie the nearest to the
1116+// focus, the right side first. -1 when there is none.
1117+func (w *Workspace) donor(first, last int, cells []int) int {
1118+ best := -1
1119+ for i := first; i <= last; i++ {
1120+ c := cells[i-first]
1121+ if i == w.Focus || c <= 1 {
1122+ continue
1123+ }
1124+ if best < 0 {
1125+ best = i
1126+ continue
1127+ }
1128+ bc, bd, d := cells[best-first], abs(best-w.Focus), abs(i-w.Focus)
1129+ if c > bc || c == bc && (d < bd || d == bd && i > w.Focus) {
1130+ best = i
1131+ }
11151132 }
1116- w.Columns[w.Focus].Expanded = on
1117- w.unmaximize()
1133+ return best
1134+}
1135+
1136+// moveCell moves one cell from column from to column to of the lane that
1137+// starts at first, and stores the lane.
1138+func (w *Workspace) moveCell(first int, cells []int, from, to int) {
1139+ cells[from-first]--
1140+ cells[to-first]++
1141+ w.setLaneCells(first, cells)
11181142 }
11191143
11201144 // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
@@ -1320,7 +1344,6 @@ func (w *Workspace) SetGaps(g int) {
13201344 w.Gaps = g
13211345 w.scroll()
13221346 }
1323-func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
13241347 func (w *Workspace) SetMaxColumns(n int) {
13251348 w.MaxColumns = n
13261349 w.scroll()
@@ -1457,21 +1480,48 @@ func (w *Workspace) columnWidth(i int) int {
14571480 }
14581481
14591482 func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1483+ if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1484+ return width
1485+ }
1486+ if w.policy().wraps || w.laneRow() {
1487+ return w.cellWidth(i)
1488+ }
1489+ return w.scrollWidth(i)
1490+}
1491+
1492+// specialWidth is the width of a maximized or fullscreen column.
1493+func (w *Workspace) specialWidth(i int, ignoreFullWidth bool) (int, bool) {
14601494 if w.Columns[i].FullWidth && !ignoreFullWidth {
1461- return max(w.Usable.W-2*w.gap(), 0)
1495+ return max(w.Usable.W-2*w.gap(), 0), true
14621496 }
14631497 if w.fullscreenColumn(i) {
1464- return w.Output.W
1498+ return w.Output.W, true
14651499 }
1466- g := w.gap()
1467- // Fixed overflow never scrolls, so presets would push columns off screen.
1468- if w.policy().wraps {
1469- return w.cellWidth(i)
1500+ return 0, false
1501+}
1502+
1503+// laneRow reports whether the columns share one row of cells: fixed
1504+// overflow up to MaxColumns of them. Past that the spiral applies and
1505+// cells are ignored.
1506+func (w *Workspace) laneRow() bool {
1507+ return w.Overflow == OverflowFixed && len(w.Columns) <= max(w.MaxColumns, 1)
1508+}
1509+
1510+// scrollWidth is the width of column i when it is sized on its own: auto
1511+// columns share the usable width as the visible ones would, the others take
1512+// their cells (at most MaxColumns) and the gaps between them.
1513+func (w *Workspace) scrollWidth(i int) int {
1514+ k := max(w.MaxColumns, 1)
1515+ if w.policy().equalCells {
1516+ // Fixed overflow past MaxColumns: the spiral's slots.
1517+ return w.equalShare(k)
14701518 }
1471- if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1472- return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1)))
1519+ c := w.Columns[i].Cells
1520+ if c <= 0 {
1521+ return w.equalShare(min(len(w.Columns), k))
14731522 }
1474- return w.Columns[i].Width.Resolve(w.Usable.W, g)
1523+ c = min(c, k)
1524+ return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
14751525 }
14761526 func (w *Workspace) columnX(i int) int {
14771527 x := w.Usable.X + w.gap()
@@ -1542,10 +1592,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
15421592 return rects
15431593 }
15441594 k := max(w.MaxColumns, 1)
1545- if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1546- // rects is dead here: the expanded rects reuse its storage.
1547- return w.expandedRects(rects[:0], e, k, y, h)
1548- }
15491595 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
15501596 return rects
15511597 }
@@ -1558,38 +1604,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
15581604 return rects
15591605 }
15601606
1561-// expandedRects places expanded column e over k-1 cells in its place. The
1562-// columns before it stack in a strip on its left, those after it on its
1563-// right; the two strips share the last cell.
1564-func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
1565- g := w.gap()
1566- cell := w.equalShare(k)
1567- wide := (k-1)*cell + (k-2)*g
1568- rest := max(w.Usable.W-2*g-wide-g, 0)
1569- before, after := w.Columns[:e], w.Columns[e+1:]
1570- leftW, rightW := rest, rest
1571- if len(before) > 0 && len(after) > 0 {
1572- leftW = max((rest-g)/2, 0)
1573- rightW = max(rest-g-leftW, 0)
1574- }
1575- x := w.Usable.X + g
1576- if len(before) > 0 {
1577- rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
1578- x += leftW + g
1579- }
1580- rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
1581- if len(after) > 0 {
1582- rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
1583- }
1584- // Gaps wider than a narrow output would push columns past its edge.
1585- right := w.Usable.X + w.Usable.W
1586- for i := range rects {
1587- rects[i].X = min(rects[i].X, right)
1588- rects[i].W = min(rects[i].W, right-rects[i].X)
1589- }
1590- return rects
1591-}
1592-
15931607 // stackRects cuts r in n rows with a gap between them; the last row takes
15941608 // the rounding remainder. Rows past the bottom (gaps taller than r) are
15951609 // clamped to it, like windows stacked in a column.
@@ -1,12 +1,8 @@
1 package core1 package core
2 2
3 import (3 import (
4- "fmt"
5 "math"4 "math"
6- "math/bits"
7 "slices"5 "slices"
8- "strconv"
9- "strings"
10 "time"6 "time"
11 7
12 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
@@ -16,87 +12,17 @@ type WindowID = ports.WindowID
16 12
17 type Rect = ports.Rect13 type Rect = ports.Rect
18 14
19-type Width struct{ Num, Den, Pixels int }15+// maxCells is the largest width a rule or a slot may declare, in cells.
16+const maxCells = 16
20 17
21-func ParseWidth(s string) (Width, error) {18+// Column.Cells is the width of the column in cells, a cell being one
22- if s == "1" {19+// max-columns-th of the usable width. Zero (auto) shares the width equally
23- return Width{Num: 1, Den: 1}, nil20+// with the other auto columns, up to MaxColumns visible at once.
24- }
25- if strings.HasSuffix(s, "px") {
26- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
27- if e == nil && n > 0 {
28- return Width{Pixels: n}, nil
29- }
30- } else if strings.HasSuffix(s, "%") {
31- n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
32- if e == nil && n > 0 && n <= 100 {
33- return Width{Num: n, Den: 100}, nil
34- }
35- } else {
36- p := strings.Split(s, "/")
37- if len(p) == 2 {
38- a, e1 := strconv.Atoi(p[0])
39- b, e2 := strconv.Atoi(p[1])
40- if e1 == nil && e2 == nil && a > 0 && a <= b {
41- return Width{Num: a, Den: b}, nil
42- }
43- }
44- }
45- return Width{}, fmt.Errorf("invalid width %q", s)
46-}
47-
48-// same reports whether v and o are the same width: 1/2 and 50/100 are.
49-func (v Width) same(o Width) bool {
50- if v.Pixels > 0 || o.Pixels > 0 || v.Den == 0 || o.Den == 0 {
51- return v == o
52- }
53- return v.Num*o.Den == o.Num*v.Den
54-}
55-
56-// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
57-// Widths are clamped to the available area (zero if no space remains).
58-func (v Width) Resolve(usableW, gaps int) int {
59- if usableW <= 0 {
60- return 0
61- }
62- gaps = min(max(gaps, 0), usableW/2)
63- limit := max(usableW-2*gaps, 0)
64- if limit == 0 {
65- return 0
66- }
67- if v.Pixels > 0 {
68- return min(v.Pixels, limit)
69- }
70- if v.Den <= 0 || v.Num <= 0 {
71- return 1
72- }
73- // Cap invalid fractions above one before multiplying; the result would
74- // be clamped to the available width anyway.
75- num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
76- avail := uint64(usableW - gaps)
77- q, r := avail/den, avail%den
78- hi, lo := bits.Mul64(r, num)
79- lo, carry := bits.Add64(lo, den/2, 0)
80- hi += carry
81- // hi < den: r and num are both smaller than den.
82- fraction, _ := bits.Div64(hi, lo, den)
83- rounded := q*num + fraction // no larger than avail (plus rounding)
84- if rounded <= uint64(gaps) {
85- return 1
86- }
87- return int(min(rounded-uint64(gaps), uint64(limit)))
88-}
89-
90-// Column.Width is zero (auto) until the user picks a preset: auto columns share
91-// the usable width equally, up to MaxColumns visible at once.
92 type Column struct {21 type Column struct {
93 Windows []WindowID22 Windows []WindowID
94- Width Width23+ Cells int
95 FullWidth bool24 FullWidth bool
96- // Expanded is the fixed-overflow wide column: max-columns - 1 cells,25+ Focus int
97- // the others share the last one. One column at most has it.
98- Expanded bool
99- Focus int
100 // Slot is the declared column number (workspace.<name>.column.N) of a26 // Slot is the declared column number (workspace.<name>.column.N) of a
101 // slot window; 0 for normal columns.27 // slot window; 0 for normal columns.
102 Slot int28 Slot int
@@ -192,7 +118,6 @@ type Workspace struct {
192 MaxColumns int118 MaxColumns int
193 // border is drawn around floating windows, inside their rect.119 // border is drawn around floating windows, inside their rect.
194 border int120 border int
195- presets []Width
196 fullscreen WindowID121 fullscreen WindowID
197 // left is the window whose fullscreen the user left for another122 // left is the window whose fullscreen the user left for another
198 // window: its client requests are refused until it is focused again,123 // window: its client requests are refused until it is focused again,
@@ -242,14 +167,13 @@ type Workspace struct {
242 }167 }
243 168
244 // origPlace remembers where a stashed window was: its column, its row in a169 // origPlace remembers where a stashed window was: its column, its row in a
245-// stacked column, the column's width and slot.170+// stacked column, the column's width in cells and slot.
246 type origPlace struct {171 type origPlace struct {
247 col, row, slot int172 col, row, slot int
248 // stacked holds the windows left in the column: the window returns to173 // stacked holds the windows left in the column: the window returns to
249 // its row in the column that still holds one of them.174 // its row in the column that still holds one of them.
250- stacked []WindowID175+ stacked []WindowID
251- width Width176+ cells int
252- expanded bool
253 // fullWidth is the column's maximize-column state.177 // fullWidth is the column's maximize-column state.
254 fullWidth bool178 fullWidth bool
255 }179 }
@@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) {
552 }476 }
553 return c.Windows[c.Focus], true477 return c.Windows[c.Focus], true
554 }478 }
555-func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }479+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, 0) }
556 480
557-// addWindow is AddWindow with the width of the new column (zero: automatic).481+// addWindow is AddWindow with the width of the new column in cells (zero:
558-func (w *Workspace) addWindow(id WindowID, width Width) {482+// automatic).
483+func (w *Workspace) addWindow(id WindowID, cells int) {
559 if id == 0 || w.has(id) {484 if id == 0 || w.has(id) {
560 return485 return
561 }486 }
@@ -567,10 +492,10 @@ func (w *Workspace) addWindow(id WindowID, width Width) {
567 // A covering float or stashed window may have no column under it.492 // A covering float or stashed window may have no column under it.
568 at = min(w.Focus+1, len(w.Columns))493 at = min(w.Focus+1, len(w.Columns))
569 }494 }
570- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})495+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
571 return496 return
572 }497 }
573- w.addColumn(Column{Windows: []WindowID{id}, Width: width})498+ w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
574 }499 }
575 500
576 // addColumn places a new column like a new window and focuses it.501 // addColumn places a new column like a new window and focuses it.
@@ -595,7 +520,7 @@ func (w *Workspace) addColumn(col Column) {
595 // AddSlotWindow places a slot window in its own column, ordered by slot520 // AddSlotWindow places a slot window in its own column, ordered by slot
596 // number before the other columns. Focus stays on the focused window: slot521 // number before the other columns. Focus stays on the focused window: slot
597 // windows are automatic events (ADR 011 golden rule).522 // windows are automatic events (ADR 011 golden rule).
598-func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {523+func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
599 if id == 0 || w.has(id) {524 if id == 0 || w.has(id) {
600 return525 return
601 }526 }
@@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
606 at = i + 1531 at = i + 1
607 }532 }
608 }533 }
609- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})534+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
610 if len(w.Columns) > 1 && at <= w.Focus {535 if len(w.Columns) > 1 && at <= w.Focus {
611 w.Focus++536 w.Focus++
612 }537 }
613 w.scroll()538 w.scroll()
614 }539 }
615 540
616-// setSlotWidth updates the width of slot column n, if present.541+// setSlotCells updates the width in cells of slot column n, if present.
617-func (w *Workspace) setSlotWidth(n int, width Width) {542+func (w *Workspace) setSlotCells(n int, cells int) {
618 for i := range w.Columns {543 for i := range w.Columns {
619 if w.Columns[i].Slot == n {544 if w.Columns[i].Slot == n {
620- w.Columns[i].Width = width545+ w.Columns[i].Cells = cells
621 }546 }
622 }547 }
623 w.scroll()548 w.scroll()
@@ -791,8 +716,8 @@ func (w *Workspace) focusColumn(dir int) {
791 716
792 // columnToward returns the column focus-column-left/right (dir -1/1) goes717 // columnToward returns the column focus-column-left/right (dir -1/1) goes
793 // 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
794-// overflow stacks columns (spiral, expanded strips), so it follows the719+// overflow stacks columns (spiral), so it follows the screen: the column
795-// screen: the column on that side.720+// on that side.
796 func (w *Workspace) columnToward(dir int) int {721 func (w *Workspace) columnToward(dir int) int {
797 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {722 if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
798 return -1723 return -1
@@ -939,25 +864,6 @@ func (w *Workspace) MoveWindow(dir int) {
939 w.scroll()864 w.scroll()
940 }865 }
941 866
942-// ResizeColumn changes the focused column's width by pct percent of the
943-// usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
944-// overflow shares the width equally: nothing changes there.
945-func (w *Workspace) ResizeColumn(pct int) {
946- if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
947- return
948- }
949- w.unmaximize()
950- g := w.gap()
951- avail := w.Usable.W - g
952- if avail <= 0 {
953- return
954- }
955- // Inverse of Width.Resolve: width = round(p/100 * avail) - g.
956- cur := (200*(w.columnWidth(w.Focus)+g) + avail) / (2 * avail)
957- w.Columns[w.Focus].Width = Width{Num: min(max(cur+pct, 10), 100), Den: 100}
958- w.scroll()
959-}
960-
961 // minShare is the smallest row height set-window-height leaves, percent.867 // minShare is the smallest row height set-window-height leaves, percent.
962 const minShare = 10868 const minShare = 10
963 869
@@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
1038 return Column{}, false944 return Column{}, false
1039 }945 }
1040 col := w.Columns[w.Focus]946 col := w.Columns[w.Focus]
1041- col.Slot, col.Expanded = 0, false947+ col.Slot = 0
1042 if slices.Contains(col.Windows, w.fullscreen) {948 if slices.Contains(col.Windows, w.fullscreen) {
1043 w.fullscreen = 0949 w.fullscreen = 0
1044 }950 }
@@ -1071,50 +977,168 @@ func (w *Workspace) insertColumn(at int, col Column) {
1071 w.scroll()977 w.scroll()
1072 }978 }
1073 979
1074-// CycleWidth steps the focused column through the presets. Fixed overflow980+// GrowColumn widens the focused column by one cell, taking it from another
1075-// ignores presets: it toggles the expanded column instead; cascade does981+// column of its lane, or from the empty space in scroll overflow. A lane
1076-// nothing.982+// that cannot grow further goes back to equal shares when cycle is set
1077-func (w *Workspace) CycleWidth() {983+// (cycle-column-width); otherwise nothing changes (set-column-width +N).
1078- if w.policy().wraps {984+// A lane is the band of a cascade, or the whole row in fixed overflow while
985+// it holds MaxColumns columns at most; past that the spiral ignores cells.
986+func (w *Workspace) GrowColumn(cycle bool) {
987+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1079 return988 return
1080 }989 }
1081- if w.Overflow == OverflowFixed {990+ w.unmaximize()
1082- w.toggleExpanded()991+ defer w.scroll()
992+ k := max(w.MaxColumns, 1)
993+ if w.policy().equalCells {
994+ if !w.policy().wraps && !w.laneRow() {
995+ return
996+ }
997+ first, last := w.laneBounds(w.laneOf(w.Focus))
998+ n := last - first + 1
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+ }
1005+ return
1006+ }
1007+ cells, ok := w.laneCells(first, last, w.Focus)
1008+ if !ok {
1009+ w.setLaneCells(first, cells)
1010+ if k%n > 0 {
1011+ return // The remainder to the focused column was the step.
1012+ }
1013+ }
1014+ if d := w.donor(first, last, cells); d >= 0 {
1015+ w.moveCell(first, cells, d, w.Focus)
1016+ } else if cycle {
1017+ w.setLaneCells(first, make([]int, n))
1018+ }
1083 return1019 return
1084 }1020 }
1085- if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() {1021+ c := &w.Columns[w.Focus]
1022+ cur := w.scrollCells(w.Focus)
1023+ switch {
1024+ case cur < k:
1025+ c.Cells = cur + 1
1026+ case cycle:
1027+ c.Cells = 0
1028+ }
1029+}
1030+
1031+// ShrinkColumn narrows the focused column by one cell, giving it to its
1032+// right neighbor in the lane (the left one at the lane's end). A column
1033+// never goes below one cell.
1034+func (w *Workspace) ShrinkColumn() {
1035+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1086 return1036 return
1087 }1037 }
1088- // auto → presets in order → auto; a width set by set-column-width1038+ w.unmaximize()
1089- // restarts at the first preset.1039+ defer w.scroll()
1090- c := &w.Columns[w.Focus]1040+ k := max(w.MaxColumns, 1)
1091- next := Width{}1041+ if w.policy().equalCells {
1092- if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) {1042+ if !w.policy().wraps && !w.laneRow() {
1093- next = w.presets[0]1043+ return
1044+ }
1045+ first, last := w.laneBounds(w.laneOf(w.Focus))
1046+ n := last - first + 1
1047+ if n == 1 || n >= k {
1048+ return
1049+ }
1050+ cells, _ := w.laneCells(first, last, -1)
1051+ if cells[w.Focus-first] <= 1 {
1052+ return
1053+ }
1054+ to := w.Focus + 1
1055+ if to > last {
1056+ to = w.Focus - 1
1057+ }
1058+ w.moveCell(first, cells, w.Focus, to)
1059+ return
1094 }1060 }
1095- for i, v := range w.presets {1061+ w.Columns[w.Focus].Cells = max(w.scrollCells(w.Focus)-1, 1)
1096- if v.same(c.Width) && i+1 < len(w.presets) {1062+}
1097- next = w.presets[i+1]1063+
1098- break1064+// scrollCells is the width in cells of column i in scroll overflow: its
1065+// cells, at most the ones in view for an auto column.
1066+func (w *Workspace) scrollCells(i int) int {
1067+ if c := w.Columns[i].Cells; c > 0 {
1068+ return c
1069+ }
1070+ k := max(w.MaxColumns, 1)
1071+ return k / min(len(w.Columns), k)
1072+}
1073+
1074+// laneCells is the cells of the lane first..last, summing to MaxColumns.
1075+// The stored cells are used when every column is set and they sum to it
1076+// (ok); otherwise each column gets an equal share and the remainder goes
1077+// to column remAt, or one cell each to the leftmost columns when remAt is
1078+// negative.
1079+func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1080+ k := max(w.MaxColumns, 1)
1081+ n := last - first + 1
1082+ cells = make([]int, n)
1083+ sum := 0
1084+ for i := range cells {
1085+ cells[i] = w.Columns[first+i].Cells
1086+ sum += cells[i]
1087+ if cells[i] == 0 {
1088+ sum = -1 << 30
1099 }1089 }
1100 }1090 }
1101- c.Width = next1091+ if sum == k {
1102- w.unmaximize()1092+ return cells, true
1103- w.scroll()1093+ }
1094+ for i := range cells {
1095+ cells[i] = k / n
1096+ }
1097+ if remAt >= 0 {
1098+ cells[remAt-first] += k % n
1099+ } else {
1100+ for i := range k % n {
1101+ cells[i]++
1102+ }
1103+ }
1104+ return cells, false
1104 }1105 }
1105 1106
1106-// toggleExpanded makes the focused column the expanded one, or shrinks it1107+// setLaneCells stores cells on the lane's columns, from column first.
1107-// back when it already is. One column at most is expanded.1108+func (w *Workspace) setLaneCells(first int, cells []int) {
1108-func (w *Workspace) toggleExpanded() {1109+ for i, c := range cells {
1109- if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.onFloat() {1110+ w.Columns[first+i].Cells = c
1110- return
1111 }1111 }
1112- on := !w.Columns[w.Focus].Expanded1112+}
1113- for i := range w.Columns {1113+
1114- w.Columns[i].Expanded = false1114+// donor is the lane column that gives a cell to the focused one: among the
1115+// others with more than one cell, the largest; on a tie the nearest to the
1116+// focus, the right side first. -1 when there is none.
1117+func (w *Workspace) donor(first, last int, cells []int) int {
1118+ best := -1
1119+ for i := first; i <= last; i++ {
1120+ c := cells[i-first]
1121+ if i == w.Focus || c <= 1 {
1122+ continue
1123+ }
1124+ if best < 0 {
1125+ best = i
1126+ continue
1127+ }
1128+ bc, bd, d := cells[best-first], abs(best-w.Focus), abs(i-w.Focus)
1129+ if c > bc || c == bc && (d < bd || d == bd && i > w.Focus) {
1130+ best = i
1131+ }
1115 }1132 }
1116- w.Columns[w.Focus].Expanded = on1133+ return best
1117- w.unmaximize()1134+}
1135+
1136+// moveCell moves one cell from column from to column to of the lane that
1137+// starts at first, and stores the lane.
1138+func (w *Workspace) moveCell(first int, cells []int, from, to int) {
1139+ cells[from-first]--
1140+ cells[to-first]++
1141+ w.setLaneCells(first, cells)
1118 }1142 }
1119 1143
1120 // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.1144 // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
@@ -1320,7 +1344,6 @@ func (w *Workspace) SetGaps(g int) {
1320 w.Gaps = g1344 w.Gaps = g
1321 w.scroll()1345 w.scroll()
1322 }1346 }
1323-func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
1324 func (w *Workspace) SetMaxColumns(n int) {1347 func (w *Workspace) SetMaxColumns(n int) {
1325 w.MaxColumns = n1348 w.MaxColumns = n
1326 w.scroll()1349 w.scroll()
@@ -1457,21 +1480,48 @@ func (w *Workspace) columnWidth(i int) int {
1457 }1480 }
1458 1481
1459 func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {1482 func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1483+ if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1484+ return width
1485+ }
1486+ if w.policy().wraps || w.laneRow() {
1487+ return w.cellWidth(i)
1488+ }
1489+ return w.scrollWidth(i)
1490+}
1491+
1492+// specialWidth is the width of a maximized or fullscreen column.
1493+func (w *Workspace) specialWidth(i int, ignoreFullWidth bool) (int, bool) {
1460 if w.Columns[i].FullWidth && !ignoreFullWidth {1494 if w.Columns[i].FullWidth && !ignoreFullWidth {
1461- return max(w.Usable.W-2*w.gap(), 0)1495+ return max(w.Usable.W-2*w.gap(), 0), true
1462 }1496 }
1463 if w.fullscreenColumn(i) {1497 if w.fullscreenColumn(i) {
1464- return w.Output.W1498+ return w.Output.W, true
1465 }1499 }
1466- g := w.gap()1500+ return 0, false
1467- // Fixed overflow never scrolls, so presets would push columns off screen.1501+}
1468- if w.policy().wraps {1502+
1469- return w.cellWidth(i)1503+// laneRow reports whether the columns share one row of cells: fixed
1504+// overflow up to MaxColumns of them. Past that the spiral applies and
1505+// cells are ignored.
1506+func (w *Workspace) laneRow() bool {
1507+ return w.Overflow == OverflowFixed && len(w.Columns) <= max(w.MaxColumns, 1)
1508+}
1509+
1510+// scrollWidth is the width of column i when it is sized on its own: auto
1511+// columns share the usable width as the visible ones would, the others take
1512+// their cells (at most MaxColumns) and the gaps between them.
1513+func (w *Workspace) scrollWidth(i int) int {
1514+ k := max(w.MaxColumns, 1)
1515+ if w.policy().equalCells {
1516+ // Fixed overflow past MaxColumns: the spiral's slots.
1517+ return w.equalShare(k)
1470 }1518 }
1471- if w.Columns[i].Width == (Width{}) || w.policy().equalCells {1519+ c := w.Columns[i].Cells
1472- return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1)))1520+ if c <= 0 {
1521+ return w.equalShare(min(len(w.Columns), k))
1473 }1522 }
1474- return w.Columns[i].Width.Resolve(w.Usable.W, g)1523+ c = min(c, k)
1524+ return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
1475 }1525 }
1476 func (w *Workspace) columnX(i int) int {1526 func (w *Workspace) columnX(i int) int {
1477 x := w.Usable.X + w.gap()1527 x := w.Usable.X + w.gap()
@@ -1542,10 +1592,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1542 return rects1592 return rects
1543 }1593 }
1544 k := max(w.MaxColumns, 1)1594 k := max(w.MaxColumns, 1)
1545- if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1546- // rects is dead here: the expanded rects reuse its storage.
1547- return w.expandedRects(rects[:0], e, k, y, h)
1548- }
1549 if w.Overflow != OverflowFixed || len(w.Columns) <= k {1595 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
1550 return rects1596 return rects
1551 }1597 }
@@ -1558,38 +1604,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1558 return rects1604 return rects
1559 }1605 }
1560 1606
1561-// expandedRects places expanded column e over k-1 cells in its place. The
1562-// columns before it stack in a strip on its left, those after it on its
1563-// right; the two strips share the last cell.
1564-func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
1565- g := w.gap()
1566- cell := w.equalShare(k)
1567- wide := (k-1)*cell + (k-2)*g
1568- rest := max(w.Usable.W-2*g-wide-g, 0)
1569- before, after := w.Columns[:e], w.Columns[e+1:]
1570- leftW, rightW := rest, rest
1571- if len(before) > 0 && len(after) > 0 {
1572- leftW = max((rest-g)/2, 0)
1573- rightW = max(rest-g-leftW, 0)
1574- }
1575- x := w.Usable.X + g
1576- if len(before) > 0 {
1577- rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
1578- x += leftW + g
1579- }
1580- rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
1581- if len(after) > 0 {
1582- rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
1583- }
1584- // Gaps wider than a narrow output would push columns past its edge.
1585- right := w.Usable.X + w.Usable.W
1586- for i := range rects {
1587- rects[i].X = min(rects[i].X, right)
1588- rects[i].W = min(rects[i].W, right-rects[i].X)
1589- }
1590- return rects
1591-}
1592-
1593 // stackRects cuts r in n rows with a gap between them; the last row takes1607 // stackRects cuts r in n rows with a gap between them; the last row takes
1594 // the rounding remainder. Rows past the bottom (gaps taller than r) are1608 // the rounding remainder. Rows past the bottom (gaps taller than r) are
1595 // clamped to it, like windows stacked in a column.1609 // clamped to it, like windows stacked in a column.
M internal/core/layout_buffers_test.go +10 -10
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 // layoutVariants builds monitors covering the layout paths that use scratch:
14-// scroll and fixed overflow, an expanded column, row shares, floats, a stash,
14+// scroll and fixed overflow, a column set in cells, row shares, floats, a stash,
1515 // a fullscreen window, a hidden neighbor workspace, the overview and a slide
1616 // toward a neighbor workspace.
1717 func layoutVariants(t *testing.T) map[string]*Monitor {
@@ -32,9 +32,9 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
3232 v["fixed"] = base(OverflowFixed, 5)
3333 v["fixed-spiral"] = base(OverflowFixed, 7)
3434
35- m := base(OverflowFixed, 4)
36- m.Current().Columns[1].Expanded = true
37- v["fixed-expanded"] = m
35+ m := base(OverflowFixed, 3)
36+ m.Current().Columns[1].Cells = 2
37+ v["fixed-cells"] = m
3838
3939 m = base(OverflowScroll, 4)
4040 w := m.Current()
@@ -78,12 +78,12 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
7878 // The variants must hold what they are named for, on the workspace on
7979 // screen (the one whose scratch the tests exercise).
8080 for name, has := range map[string]func(*Workspace) bool{
81- "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82- "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83- "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84- "fixed-expanded": func(w *Workspace) bool { return w.Columns[1].Expanded },
85- "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86- "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
81+ "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82+ "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83+ "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84+ "fixed-cells": func(w *Workspace) bool { return w.Columns[1].Cells == 2 },
85+ "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86+ "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
8787 } {
8888 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {
8989 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 // layoutVariants builds monitors covering the layout paths that use scratch:13 // layoutVariants builds monitors covering the layout paths that use scratch:
14-// scroll and fixed overflow, an expanded column, row shares, floats, a stash,14+// scroll and fixed overflow, a column set in cells, row shares, floats, a stash,
15 // a fullscreen window, a hidden neighbor workspace, the overview and a slide15 // a fullscreen window, a hidden neighbor workspace, the overview and a slide
16 // toward a neighbor workspace.16 // toward a neighbor workspace.
17 func layoutVariants(t *testing.T) map[string]*Monitor {17 func layoutVariants(t *testing.T) map[string]*Monitor {
@@ -32,9 +32,9 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
32 v["fixed"] = base(OverflowFixed, 5)32 v["fixed"] = base(OverflowFixed, 5)
33 v["fixed-spiral"] = base(OverflowFixed, 7)33 v["fixed-spiral"] = base(OverflowFixed, 7)
34 34
35- m := base(OverflowFixed, 4)35+ m := base(OverflowFixed, 3)
36- m.Current().Columns[1].Expanded = true36+ m.Current().Columns[1].Cells = 2
37- v["fixed-expanded"] = m37+ v["fixed-cells"] = m
38 38
39 m = base(OverflowScroll, 4)39 m = base(OverflowScroll, 4)
40 w := m.Current()40 w := m.Current()
@@ -78,12 +78,12 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
78 // The variants must hold what they are named for, on the workspace on78 // The variants must hold what they are named for, on the workspace on
79 // screen (the one whose scratch the tests exercise).79 // screen (the one whose scratch the tests exercise).
80 for name, has := range map[string]func(*Workspace) bool{80 for name, has := range map[string]func(*Workspace) bool{
81- "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },81+ "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82- "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },82+ "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83- "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },83+ "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84- "fixed-expanded": func(w *Workspace) bool { return w.Columns[1].Expanded },84+ "fixed-cells": func(w *Workspace) bool { return w.Columns[1].Cells == 2 },
85- "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },85+ "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86- "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },86+ "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
87 } {87 } {
88 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {88 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {
89 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)89 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)
M internal/core/layout_test.go +140 -104
@@ -15,30 +15,95 @@ func workspace() *Workspace {
1515 w.SetMaxColumns(2)
1616 return w
1717 }
18-func TestWidths(t *testing.T) {
19- for _, tc := range []struct {
20- s string
21- ok bool
22- pixels int
23- }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {
24- v, e := ParseWidth(tc.s)
25- if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
26- t.Errorf("%q: %v %v", tc.s, v, e)
18+
19+// Columns of a lane share the usable width by their cells; unset columns
20+// share what the others leave.
21+func TestLaneWidths(t *testing.T) {
22+ cells := func(w *Workspace, c ...int) []int {
23+ for i, n := range c {
24+ w.Columns[i].Cells = n
2725 }
26+ var got []int
27+ for i := range w.Columns {
28+ got = append(got, w.cellWidth(i))
29+ }
30+ return got
2831 }
29- if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {
30- t.Fatal(n)
31- }
32- if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {
33- t.Fatal(n)
32+ for _, overflow := range []Overflow{OverflowCascade, OverflowFixed} {
33+ t.Run(string(overflow), func(t *testing.T) {
34+ mk := func(n int) *Workspace {
35+ w := &Workspace{MaxColumns: 4, Overflow: overflow, Gaps: 8}
36+ w.SetOutput(1000, 500)
37+ for id := 1; id <= n; id++ {
38+ w.AddWindow(WindowID(id))
39+ }
40+ return w
41+ }
42+ avail := 1000 - 3*8
43+ if got, want := cells(mk(2), 1, 3), []int{avail / 4, avail * 3 / 4}; !reflect.DeepEqual(got, want) {
44+ t.Errorf("1,3: got %v want %v", got, want)
45+ }
46+ // Auto columns share what the set ones leave.
47+ w := mk(3)
48+ avail = 1000 - 4*8
49+ if got, want := cells(w, 2, 0, 0), []int{avail * 2 / 4, avail / 4, avail / 4}; !reflect.DeepEqual(got, want) {
50+ t.Errorf("2,auto,auto: got %v want %v", got, want)
51+ }
52+ // No room for them: an auto column weighs one cell.
53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
54+ t.Errorf("3,auto,auto: got %v want %v", got, want)
55+ }
56+ // All auto: the equal share of n columns, pixel for pixel.
57+ for n := 1; n <= 4; n++ {
58+ w := mk(n)
59+ for i := range w.Columns {
60+ if got := w.cellWidth(i); got != w.equalShare(n) {
61+ t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
62+ }
63+ }
64+ }
65+ // Positions follow the widths.
66+ w = mk(2)
67+ cells(w, 1, 3)
68+ r := w.columnRects()
69+ if r[0].X != 8 || r[1].X != 8+r[0].W+8 {
70+ t.Errorf("rects %v", r)
71+ }
72+ })
3473 }
74+ t.Run("scroll", func(t *testing.T) {
75+ w := &Workspace{MaxColumns: 4, Gaps: 8}
76+ w.SetOutput(1000, 500)
77+ w.AddWindow(1)
78+ w.AddWindow(2)
79+ w.Columns[1].Cells = 2
80+ if got, want := w.columnWidth(1), 2*w.equalShare(4)+8; got != want {
81+ t.Errorf("2 cells: %d want %d", got, want)
82+ }
83+ if got, want := w.columnWidth(0), w.equalShare(2); got != want {
84+ t.Errorf("auto: %d want %d", got, want)
85+ }
86+ w.Columns[1].Cells = 9
87+ if got, want := w.columnWidth(1), 4*w.equalShare(4)+3*8; got != want {
88+ t.Errorf("above max-columns: %d want %d", got, want)
89+ }
90+ })
91+ t.Run("fixed past max-columns ignores cells", func(t *testing.T) {
92+ w := fixedWorkspace(2, 0, 3)
93+ before := w.columnRects()
94+ w.Columns[0].Cells = 1
95+ w.Columns[1].Cells = 3
96+ if after := w.columnRects(); !reflect.DeepEqual(before, after) {
97+ t.Errorf("%v != %v", after, before)
98+ }
99+ })
35100 }
36101 func TestOperations(t *testing.T) {
37102 w := workspace()
38103 w.FocusColumn(1)
39104 w.FocusWindow(-1)
40105 w.MoveColumn(1)
41- w.CycleWidth()
106+ w.GrowColumn(true)
42107 w.ToggleFullscreen()
43108 w.RemoveWindow(1)
44109 if _, ok := w.Focused(); ok {
@@ -112,17 +177,17 @@ func TestMaximizeColumnScroll(t *testing.T) {
112177 for id := WindowID(1); id <= 3; id++ {
113178 w.AddWindow(id)
114179 }
115- w.Columns[1].Width = Width{Pixels: 30}
180+ w.Columns[1].Cells = 2
116181 w.FocusID(2)
117182 w.Apply(ActionMaximizeColumn)
118- if !w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
183+ if !w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
119184 t.Fatal(w.Columns, w.Layout())
120185 }
121186 if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {
122187 t.Fatal(w.Layout())
123188 }
124189 w.Apply(ActionMaximizeColumn)
125- if w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect.W != 30 {
190+ if w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect.W != 89 {
126191 t.Fatal(w.Columns, w.Layout())
127192 }
128193 }
@@ -159,24 +224,17 @@ func TestMaximizeColumnFixedAddWindow(t *testing.T) {
159224 }
160225 }
161226
162-func TestPresetsAndActions(t *testing.T) {
227+func TestCellsAndActions(t *testing.T) {
163228 w := workspace()
164229 w.AddWindow(1)
165- p := []Width{{Pixels: 30}, {Pixels: 40}}
166- w.SetPresets(p)
167- p[0].Pixels = 99
168230 w.AddWindow(2)
231+ // Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto.
169232 w.Apply(ActionCycleColumnWidth)
170- if w.Columns[1].Width.Pixels != 30 {
233+ if w.Columns[1].Cells != 2 {
171234 t.Fatal(w.Columns)
172235 }
173- w.CycleWidth()
174- if w.Columns[1].Width.Pixels != 40 {
175- t.Fatal(w.Columns)
176- }
177- // After the last preset, back to the automatic share.
178- w.CycleWidth()
179- if w.Columns[1].Width != (Width{}) {
236+ w.GrowColumn(true)
237+ if w.Columns[1].Cells != 0 {
180238 t.Fatal(w.Columns)
181239 }
182240 w.RemoveWindow(2)
@@ -188,18 +246,11 @@ func TestPresetsAndActions(t *testing.T) {
188246 t.Errorf("%s: %+v", tc.a, got)
189247 }
190248 }
191- w.SetPresets(nil)
192- old := w.Columns[0].Width
193- w.CycleWidth()
194- if w.Columns[0].Width != old {
195- t.Fatal("empty presets")
196- }
197249 }
198250 func FuzzWorkspaceOps(f *testing.F) {
199251 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
200252 f.Fuzz(func(t *testing.T, ops []byte) {
201253 w := workspace()
202- w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
203254 next := WindowID(1)
204255 for _, op := range ops {
205256 switch op % 18 {
@@ -223,7 +274,7 @@ func FuzzWorkspaceOps(f *testing.F) {
223274 case 7:
224275 w.MoveColumn(1)
225276 case 8:
226- w.CycleWidth()
277+ w.GrowColumn(op%2 == 0)
227278 case 9:
228279 w.ToggleFullscreen()
229280 case 10:
@@ -240,7 +291,7 @@ func FuzzWorkspaceOps(f *testing.F) {
240291 }
241292 case 15:
242293 if len(w.Columns) > 0 {
243- w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}
294+ w.Columns[w.Focus].Cells = math.MaxInt
244295 w.scroll()
245296 }
246297 case 16:
@@ -324,20 +375,6 @@ func stackFocused(w *Workspace) {
324375 }
325376
326377 func TestLargeWidthAndBounds(t *testing.T) {
327- for _, tc := range []struct {
328- width Width
329- usable, gaps, want int
330- }{
331- {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
332- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
333- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
334- {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
335- {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
336- } {
337- if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
338- t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
339- }
340- }
341378 w := workspace()
342379 w.AddWindow(1)
343380 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
@@ -441,9 +478,9 @@ func TestFixedOverflowSpiral(t *testing.T) {
441478 }
442479 }
443480
444-// In fixed overflow, cycle-column-width expands the focused column over
445-// max-columns - 1 cells in its place; the others stack beside it.
446-func TestFixedExpandColumn(t *testing.T) {
481+// In fixed overflow, cycle-column-width grows the focused column by one
482+// cell, taking it from another column of the row.
483+func TestFixedGrowColumn(t *testing.T) {
447484 rects := func(w *Workspace) map[WindowID]Rect {
448485 got := map[WindowID]Rect{}
449486 for id, p := range placements(w) {
@@ -460,70 +497,67 @@ func TestFixedExpandColumn(t *testing.T) {
460497 t.Run("free cell", func(t *testing.T) {
461498 w := fixedWorkspace(4, 0, 2)
462499 w.FocusID(1)
463- w.CycleWidth()
500+ w.GrowColumn(true)
464501 check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})
465- w.CycleWidth()
502+ w.GrowColumn(true)
466503 check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
467504 })
468- t.Run("middle column keeps its place", func(t *testing.T) {
469- w := fixedWorkspace(4, 0, 4)
505+ t.Run("middle column", func(t *testing.T) {
506+ w := fixedWorkspace(4, 0, 3)
470507 w.FocusID(2)
471- w.CycleWidth()
472- check(t, w, map[WindowID]Rect{
473- 1: {W: 12, H: 80},
474- 2: {X: 12, W: 75, H: 80},
475- 3: {X: 87, W: 13, H: 40},
476- 4: {X: 87, Y: 40, W: 13, H: 40},
477- })
508+ w.GrowColumn(true)
509+ // The remainder cell was the step.
510+ check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511+ // No donor is left: back to equal shares.
512+ w.GrowColumn(true)
513+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
478514 })
479515 t.Run("with gaps", func(t *testing.T) {
480- w := fixedWorkspace(2, 4, 3)
516+ w := fixedWorkspace(4, 4, 2)
481517 w.FocusID(1)
482- w.CycleWidth()
483- // Two cells of 44: the wide one keeps one, the others stack in the other.
518+ w.GrowColumn(true)
519+ // Three cells and one of the 88 px left by the three gaps.
484520 check(t, w, map[WindowID]Rect{
485- 1: {X: 4, Y: 4, W: 44, H: 72},
486- 2: {X: 52, Y: 4, W: 44, H: 34},
487- 3: {X: 52, Y: 42, W: 44, H: 34},
521+ 1: {X: 4, Y: 4, W: 66, H: 72},
522+ 2: {X: 74, Y: 4, W: 22, H: 72},
488523 })
489524 })
490525 t.Run("stays on focus change and new windows", func(t *testing.T) {
491526 w := fixedWorkspace(4, 0, 2)
492527 w.FocusID(1)
493- w.CycleWidth()
528+ w.GrowColumn(true)
494529 w.FocusColumn(1)
495530 w.AddWindow(3)
496- check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 40}, 3: {X: 75, Y: 40, W: 25, H: 40}})
531+ // 3, 1 and an auto column that weighs one cell.
532+ check(t, w, map[WindowID]Rect{1: {W: 60, H: 80}, 2: {X: 60, W: 20, H: 80}, 3: {X: 80, W: 20, H: 80}})
497533 })
498- t.Run("moves to the focused column", func(t *testing.T) {
534+ t.Run("the wider column gives a cell to the focused one", func(t *testing.T) {
499535 w := fixedWorkspace(4, 0, 2)
500536 w.FocusID(1)
501- w.CycleWidth()
537+ w.GrowColumn(true)
502538 w.FocusID(2)
503- w.CycleWidth()
504- check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 75, H: 80}})
539+ w.GrowColumn(true)
540+ check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
505541 })
506542 t.Run("closing it shares again", func(t *testing.T) {
507- w := fixedWorkspace(3, 0, 3)
543+ w := fixedWorkspace(4, 0, 3)
508544 w.FocusID(1)
509- w.CycleWidth()
545+ w.GrowColumn(true)
510546 w.RemoveWindow(1)
511547 check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})
512548 })
513549 t.Run("no-op when it cannot grow", func(t *testing.T) {
514- for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(1, 0, 2)} {
550+ for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(2, 0, 2), fixedWorkspace(3, 0, 5)} {
515551 before := rects(w)
516- w.CycleWidth()
552+ w.GrowColumn(true)
517553 check(t, w, before)
518554 }
519555 })
520556 t.Run("stays on screen when gaps do not fit", func(t *testing.T) {
521- for _, w := range []*Workspace{fixedWorkspace(2, 4, 30), fixedWorkspace(4, 4, 4)} {
522- if w.MaxColumns == 4 {
523- w.SetOutput(12, 80)
524- }
557+ for _, w := range []*Workspace{fixedWorkspace(4, 4, 2), fixedWorkspace(4, 4, 3)} {
558+ w.SetOutput(12, 80)
525559 w.FocusID(2)
526- w.CycleWidth()
560+ w.GrowColumn(true)
527561 for id, p := range placements(w) {
528562 r := p.Rect
529563 if r.X < 0 || r.Y < 0 || r.W < 0 || r.H < 0 || r.X+r.W > w.Output.W || r.Y+r.H > w.Output.H {
@@ -535,7 +569,7 @@ func TestFixedExpandColumn(t *testing.T) {
535569 t.Run("maximize wins and gives it back", func(t *testing.T) {
536570 w := fixedWorkspace(4, 0, 2)
537571 w.FocusID(1)
538- w.CycleWidth()
572+ w.GrowColumn(true)
539573 w.ToggleFullWidth()
540574 if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {
541575 t.Fatal(p)
@@ -545,7 +579,7 @@ func TestFixedExpandColumn(t *testing.T) {
545579 })
546580 }
547581
548-// Up and down cross the columns the spiral or the expanded strips stack.
582+// Up and down cross the columns the spiral stacks.
549583 func TestFixedFocusStackedColumns(t *testing.T) {
550584 focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }
551585 t.Run("spiral", func(t *testing.T) {
@@ -585,18 +619,21 @@ func TestFixedFocusStackedColumns(t *testing.T) {
585619 t.Fatal("right edge has a neighbor")
586620 }
587621 })
588- t.Run("expanded strip", func(t *testing.T) {
589- // 1 wide; 2, 3 and 4 stacked on its right.
590- w := fixedWorkspace(3, 0, 4)
591- w.FocusID(1)
592- w.CycleWidth()
622+ t.Run("a row of wide and narrow columns", func(t *testing.T) {
623+ // 1 wide; 2, 3 and 4 beside it in one row.
624+ w := fixedWorkspace(4, 0, 4)
625+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells, w.Columns[3].Cells = 2, 1, 0, 0
593626 w.FocusID(2)
594- for _, want := range []WindowID{3, 4} {
595- if !w.FocusWindow(1) || focused(w) != want {
596- t.Fatal(focused(w), want)
597- }
627+ if w.FocusWindow(1) || w.FocusWindow(-1) || focused(w) != 2 {
628+ t.Fatal("a single row has no window above or below", focused(w))
598629 }
599- if !w.FocusWindow(-1) || focused(w) != 3 {
630+ w.FocusColumn(1)
631+ if focused(w) != 3 {
632+ t.Fatal(focused(w))
633+ }
634+ w.FocusColumn(-1)
635+ w.FocusColumn(-1)
636+ if focused(w) != 1 {
600637 t.Fatal(focused(w))
601638 }
602639 })
@@ -653,12 +690,11 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
653690 }
654691 })
655692 }
656- t.Run("presets ignored", func(t *testing.T) {
693+ t.Run("a full row does not grow", func(t *testing.T) {
657694 w := fixedWorkspace(2, 0, 2)
658- w.SetPresets([]Width{{Num: 2, Den: 3}})
659- w.CycleWidth()
695+ w.GrowColumn(true)
660696 w.FocusColumn(-1)
661- w.CycleWidth()
697+ w.GrowColumn(true)
662698 for id, p := range placements(w) {
663699 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
664700 t.Fatal(id, p)
@@ -15,30 +15,95 @@ func workspace() *Workspace {
15 w.SetMaxColumns(2)15 w.SetMaxColumns(2)
16 return w16 return w
17 }17 }
18-func TestWidths(t *testing.T) {18+
19- for _, tc := range []struct {19+// Columns of a lane share the usable width by their cells; unset columns
20- s string20+// share what the others leave.
21- ok bool21+func TestLaneWidths(t *testing.T) {
22- pixels int22+ cells := func(w *Workspace, c ...int) []int {
23- }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {23+ for i, n := range c {
24- v, e := ParseWidth(tc.s)24+ w.Columns[i].Cells = n
25- if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
26- t.Errorf("%q: %v %v", tc.s, v, e)
27 }25 }
26+ var got []int
27+ for i := range w.Columns {
28+ got = append(got, w.cellWidth(i))
29+ }
30+ return got
28 }31 }
29- if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {32+ for _, overflow := range []Overflow{OverflowCascade, OverflowFixed} {
30- t.Fatal(n)33+ t.Run(string(overflow), func(t *testing.T) {
31- }34+ mk := func(n int) *Workspace {
32- if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {35+ w := &Workspace{MaxColumns: 4, Overflow: overflow, Gaps: 8}
33- t.Fatal(n)36+ w.SetOutput(1000, 500)
37+ for id := 1; id <= n; id++ {
38+ w.AddWindow(WindowID(id))
39+ }
40+ return w
41+ }
42+ avail := 1000 - 3*8
43+ if got, want := cells(mk(2), 1, 3), []int{avail / 4, avail * 3 / 4}; !reflect.DeepEqual(got, want) {
44+ t.Errorf("1,3: got %v want %v", got, want)
45+ }
46+ // Auto columns share what the set ones leave.
47+ w := mk(3)
48+ avail = 1000 - 4*8
49+ if got, want := cells(w, 2, 0, 0), []int{avail * 2 / 4, avail / 4, avail / 4}; !reflect.DeepEqual(got, want) {
50+ t.Errorf("2,auto,auto: got %v want %v", got, want)
51+ }
52+ // No room for them: an auto column weighs one cell.
53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
54+ t.Errorf("3,auto,auto: got %v want %v", got, want)
55+ }
56+ // All auto: the equal share of n columns, pixel for pixel.
57+ for n := 1; n <= 4; n++ {
58+ w := mk(n)
59+ for i := range w.Columns {
60+ if got := w.cellWidth(i); got != w.equalShare(n) {
61+ t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
62+ }
63+ }
64+ }
65+ // Positions follow the widths.
66+ w = mk(2)
67+ cells(w, 1, 3)
68+ r := w.columnRects()
69+ if r[0].X != 8 || r[1].X != 8+r[0].W+8 {
70+ t.Errorf("rects %v", r)
71+ }
72+ })
34 }73 }
74+ t.Run("scroll", func(t *testing.T) {
75+ w := &Workspace{MaxColumns: 4, Gaps: 8}
76+ w.SetOutput(1000, 500)
77+ w.AddWindow(1)
78+ w.AddWindow(2)
79+ w.Columns[1].Cells = 2
80+ if got, want := w.columnWidth(1), 2*w.equalShare(4)+8; got != want {
81+ t.Errorf("2 cells: %d want %d", got, want)
82+ }
83+ if got, want := w.columnWidth(0), w.equalShare(2); got != want {
84+ t.Errorf("auto: %d want %d", got, want)
85+ }
86+ w.Columns[1].Cells = 9
87+ if got, want := w.columnWidth(1), 4*w.equalShare(4)+3*8; got != want {
88+ t.Errorf("above max-columns: %d want %d", got, want)
89+ }
90+ })
91+ t.Run("fixed past max-columns ignores cells", func(t *testing.T) {
92+ w := fixedWorkspace(2, 0, 3)
93+ before := w.columnRects()
94+ w.Columns[0].Cells = 1
95+ w.Columns[1].Cells = 3
96+ if after := w.columnRects(); !reflect.DeepEqual(before, after) {
97+ t.Errorf("%v != %v", after, before)
98+ }
99+ })
35 }100 }
36 func TestOperations(t *testing.T) {101 func TestOperations(t *testing.T) {
37 w := workspace()102 w := workspace()
38 w.FocusColumn(1)103 w.FocusColumn(1)
39 w.FocusWindow(-1)104 w.FocusWindow(-1)
40 w.MoveColumn(1)105 w.MoveColumn(1)
41- w.CycleWidth()106+ w.GrowColumn(true)
42 w.ToggleFullscreen()107 w.ToggleFullscreen()
43 w.RemoveWindow(1)108 w.RemoveWindow(1)
44 if _, ok := w.Focused(); ok {109 if _, ok := w.Focused(); ok {
@@ -112,17 +177,17 @@ func TestMaximizeColumnScroll(t *testing.T) {
112 for id := WindowID(1); id <= 3; id++ {177 for id := WindowID(1); id <= 3; id++ {
113 w.AddWindow(id)178 w.AddWindow(id)
114 }179 }
115- w.Columns[1].Width = Width{Pixels: 30}180+ w.Columns[1].Cells = 2
116 w.FocusID(2)181 w.FocusID(2)
117 w.Apply(ActionMaximizeColumn)182 w.Apply(ActionMaximizeColumn)
118- if !w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {183+ if !w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
119 t.Fatal(w.Columns, w.Layout())184 t.Fatal(w.Columns, w.Layout())
120 }185 }
121 if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {186 if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {
122 t.Fatal(w.Layout())187 t.Fatal(w.Layout())
123 }188 }
124 w.Apply(ActionMaximizeColumn)189 w.Apply(ActionMaximizeColumn)
125- if w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect.W != 30 {190+ if w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect.W != 89 {
126 t.Fatal(w.Columns, w.Layout())191 t.Fatal(w.Columns, w.Layout())
127 }192 }
128 }193 }
@@ -159,24 +224,17 @@ func TestMaximizeColumnFixedAddWindow(t *testing.T) {
159 }224 }
160 }225 }
161 226
162-func TestPresetsAndActions(t *testing.T) {227+func TestCellsAndActions(t *testing.T) {
163 w := workspace()228 w := workspace()
164 w.AddWindow(1)229 w.AddWindow(1)
165- p := []Width{{Pixels: 30}, {Pixels: 40}}
166- w.SetPresets(p)
167- p[0].Pixels = 99
168 w.AddWindow(2)230 w.AddWindow(2)
231+ // Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto.
169 w.Apply(ActionCycleColumnWidth)232 w.Apply(ActionCycleColumnWidth)
170- if w.Columns[1].Width.Pixels != 30 {233+ if w.Columns[1].Cells != 2 {
171 t.Fatal(w.Columns)234 t.Fatal(w.Columns)
172 }235 }
173- w.CycleWidth()236+ w.GrowColumn(true)
174- if w.Columns[1].Width.Pixels != 40 {237+ if w.Columns[1].Cells != 0 {
175- t.Fatal(w.Columns)
176- }
177- // After the last preset, back to the automatic share.
178- w.CycleWidth()
179- if w.Columns[1].Width != (Width{}) {
180 t.Fatal(w.Columns)238 t.Fatal(w.Columns)
181 }239 }
182 w.RemoveWindow(2)240 w.RemoveWindow(2)
@@ -188,18 +246,11 @@ func TestPresetsAndActions(t *testing.T) {
188 t.Errorf("%s: %+v", tc.a, got)246 t.Errorf("%s: %+v", tc.a, got)
189 }247 }
190 }248 }
191- w.SetPresets(nil)
192- old := w.Columns[0].Width
193- w.CycleWidth()
194- if w.Columns[0].Width != old {
195- t.Fatal("empty presets")
196- }
197 }249 }
198 func FuzzWorkspaceOps(f *testing.F) {250 func FuzzWorkspaceOps(f *testing.F) {
199 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})251 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
200 f.Fuzz(func(t *testing.T, ops []byte) {252 f.Fuzz(func(t *testing.T, ops []byte) {
201 w := workspace()253 w := workspace()
202- w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
203 next := WindowID(1)254 next := WindowID(1)
204 for _, op := range ops {255 for _, op := range ops {
205 switch op % 18 {256 switch op % 18 {
@@ -223,7 +274,7 @@ func FuzzWorkspaceOps(f *testing.F) {
223 case 7:274 case 7:
224 w.MoveColumn(1)275 w.MoveColumn(1)
225 case 8:276 case 8:
226- w.CycleWidth()277+ w.GrowColumn(op%2 == 0)
227 case 9:278 case 9:
228 w.ToggleFullscreen()279 w.ToggleFullscreen()
229 case 10:280 case 10:
@@ -240,7 +291,7 @@ func FuzzWorkspaceOps(f *testing.F) {
240 }291 }
241 case 15:292 case 15:
242 if len(w.Columns) > 0 {293 if len(w.Columns) > 0 {
243- w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}294+ w.Columns[w.Focus].Cells = math.MaxInt
244 w.scroll()295 w.scroll()
245 }296 }
246 case 16:297 case 16:
@@ -324,20 +375,6 @@ func stackFocused(w *Workspace) {
324 }375 }
325 376
326 func TestLargeWidthAndBounds(t *testing.T) {377 func TestLargeWidthAndBounds(t *testing.T) {
327- for _, tc := range []struct {
328- width Width
329- usable, gaps, want int
330- }{
331- {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
332- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
333- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
334- {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
335- {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
336- } {
337- if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
338- t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
339- }
340- }
341 w := workspace()378 w := workspace()
342 w.AddWindow(1)379 w.AddWindow(1)
343 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})380 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
@@ -441,9 +478,9 @@ func TestFixedOverflowSpiral(t *testing.T) {
441 }478 }
442 }479 }
443 480
444-// In fixed overflow, cycle-column-width expands the focused column over481+// In fixed overflow, cycle-column-width grows the focused column by one
445-// max-columns - 1 cells in its place; the others stack beside it.482+// cell, taking it from another column of the row.
446-func TestFixedExpandColumn(t *testing.T) {483+func TestFixedGrowColumn(t *testing.T) {
447 rects := func(w *Workspace) map[WindowID]Rect {484 rects := func(w *Workspace) map[WindowID]Rect {
448 got := map[WindowID]Rect{}485 got := map[WindowID]Rect{}
449 for id, p := range placements(w) {486 for id, p := range placements(w) {
@@ -460,70 +497,67 @@ func TestFixedExpandColumn(t *testing.T) {
460 t.Run("free cell", func(t *testing.T) {497 t.Run("free cell", func(t *testing.T) {
461 w := fixedWorkspace(4, 0, 2)498 w := fixedWorkspace(4, 0, 2)
462 w.FocusID(1)499 w.FocusID(1)
463- w.CycleWidth()500+ w.GrowColumn(true)
464 check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})501 check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})
465- w.CycleWidth()502+ w.GrowColumn(true)
466 check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})503 check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
467 })504 })
468- t.Run("middle column keeps its place", func(t *testing.T) {505+ t.Run("middle column", func(t *testing.T) {
469- w := fixedWorkspace(4, 0, 4)506+ w := fixedWorkspace(4, 0, 3)
470 w.FocusID(2)507 w.FocusID(2)
471- w.CycleWidth()508+ w.GrowColumn(true)
472- check(t, w, map[WindowID]Rect{509+ // The remainder cell was the step.
473- 1: {W: 12, H: 80},510+ check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
474- 2: {X: 12, W: 75, H: 80},511+ // No donor is left: back to equal shares.
475- 3: {X: 87, W: 13, H: 40},512+ w.GrowColumn(true)
476- 4: {X: 87, Y: 40, W: 13, H: 40},513+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
477- })
478 })514 })
479 t.Run("with gaps", func(t *testing.T) {515 t.Run("with gaps", func(t *testing.T) {
480- w := fixedWorkspace(2, 4, 3)516+ w := fixedWorkspace(4, 4, 2)
481 w.FocusID(1)517 w.FocusID(1)
482- w.CycleWidth()518+ w.GrowColumn(true)
483- // Two cells of 44: the wide one keeps one, the others stack in the other.519+ // Three cells and one of the 88 px left by the three gaps.
484 check(t, w, map[WindowID]Rect{520 check(t, w, map[WindowID]Rect{
485- 1: {X: 4, Y: 4, W: 44, H: 72},521+ 1: {X: 4, Y: 4, W: 66, H: 72},
486- 2: {X: 52, Y: 4, W: 44, H: 34},522+ 2: {X: 74, Y: 4, W: 22, H: 72},
487- 3: {X: 52, Y: 42, W: 44, H: 34},
488 })523 })
489 })524 })
490 t.Run("stays on focus change and new windows", func(t *testing.T) {525 t.Run("stays on focus change and new windows", func(t *testing.T) {
491 w := fixedWorkspace(4, 0, 2)526 w := fixedWorkspace(4, 0, 2)
492 w.FocusID(1)527 w.FocusID(1)
493- w.CycleWidth()528+ w.GrowColumn(true)
494 w.FocusColumn(1)529 w.FocusColumn(1)
495 w.AddWindow(3)530 w.AddWindow(3)
496- check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 40}, 3: {X: 75, Y: 40, W: 25, H: 40}})531+ // 3, 1 and an auto column that weighs one cell.
532+ check(t, w, map[WindowID]Rect{1: {W: 60, H: 80}, 2: {X: 60, W: 20, H: 80}, 3: {X: 80, W: 20, H: 80}})
497 })533 })
498- t.Run("moves to the focused column", func(t *testing.T) {534+ t.Run("the wider column gives a cell to the focused one", func(t *testing.T) {
499 w := fixedWorkspace(4, 0, 2)535 w := fixedWorkspace(4, 0, 2)
500 w.FocusID(1)536 w.FocusID(1)
501- w.CycleWidth()537+ w.GrowColumn(true)
502 w.FocusID(2)538 w.FocusID(2)
503- w.CycleWidth()539+ w.GrowColumn(true)
504- check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 75, H: 80}})540+ check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
505 })541 })
506 t.Run("closing it shares again", func(t *testing.T) {542 t.Run("closing it shares again", func(t *testing.T) {
507- w := fixedWorkspace(3, 0, 3)543+ w := fixedWorkspace(4, 0, 3)
508 w.FocusID(1)544 w.FocusID(1)
509- w.CycleWidth()545+ w.GrowColumn(true)
510 w.RemoveWindow(1)546 w.RemoveWindow(1)
511 check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})547 check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})
512 })548 })
513 t.Run("no-op when it cannot grow", func(t *testing.T) {549 t.Run("no-op when it cannot grow", func(t *testing.T) {
514- for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(1, 0, 2)} {550+ for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(2, 0, 2), fixedWorkspace(3, 0, 5)} {
515 before := rects(w)551 before := rects(w)
516- w.CycleWidth()552+ w.GrowColumn(true)
517 check(t, w, before)553 check(t, w, before)
518 }554 }
519 })555 })
520 t.Run("stays on screen when gaps do not fit", func(t *testing.T) {556 t.Run("stays on screen when gaps do not fit", func(t *testing.T) {
521- for _, w := range []*Workspace{fixedWorkspace(2, 4, 30), fixedWorkspace(4, 4, 4)} {557+ for _, w := range []*Workspace{fixedWorkspace(4, 4, 2), fixedWorkspace(4, 4, 3)} {
522- if w.MaxColumns == 4 {558+ w.SetOutput(12, 80)
523- w.SetOutput(12, 80)
524- }
525 w.FocusID(2)559 w.FocusID(2)
526- w.CycleWidth()560+ w.GrowColumn(true)
527 for id, p := range placements(w) {561 for id, p := range placements(w) {
528 r := p.Rect562 r := p.Rect
529 if r.X < 0 || r.Y < 0 || r.W < 0 || r.H < 0 || r.X+r.W > w.Output.W || r.Y+r.H > w.Output.H {563 if r.X < 0 || r.Y < 0 || r.W < 0 || r.H < 0 || r.X+r.W > w.Output.W || r.Y+r.H > w.Output.H {
@@ -535,7 +569,7 @@ func TestFixedExpandColumn(t *testing.T) {
535 t.Run("maximize wins and gives it back", func(t *testing.T) {569 t.Run("maximize wins and gives it back", func(t *testing.T) {
536 w := fixedWorkspace(4, 0, 2)570 w := fixedWorkspace(4, 0, 2)
537 w.FocusID(1)571 w.FocusID(1)
538- w.CycleWidth()572+ w.GrowColumn(true)
539 w.ToggleFullWidth()573 w.ToggleFullWidth()
540 if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {574 if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {
541 t.Fatal(p)575 t.Fatal(p)
@@ -545,7 +579,7 @@ func TestFixedExpandColumn(t *testing.T) {
545 })579 })
546 }580 }
547 581
548-// Up and down cross the columns the spiral or the expanded strips stack.582+// Up and down cross the columns the spiral stacks.
549 func TestFixedFocusStackedColumns(t *testing.T) {583 func TestFixedFocusStackedColumns(t *testing.T) {
550 focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }584 focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }
551 t.Run("spiral", func(t *testing.T) {585 t.Run("spiral", func(t *testing.T) {
@@ -585,18 +619,21 @@ func TestFixedFocusStackedColumns(t *testing.T) {
585 t.Fatal("right edge has a neighbor")619 t.Fatal("right edge has a neighbor")
586 }620 }
587 })621 })
588- t.Run("expanded strip", func(t *testing.T) {622+ t.Run("a row of wide and narrow columns", func(t *testing.T) {
589- // 1 wide; 2, 3 and 4 stacked on its right.623+ // 1 wide; 2, 3 and 4 beside it in one row.
590- w := fixedWorkspace(3, 0, 4)624+ w := fixedWorkspace(4, 0, 4)
591- w.FocusID(1)625+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells, w.Columns[3].Cells = 2, 1, 0, 0
592- w.CycleWidth()
593 w.FocusID(2)626 w.FocusID(2)
594- for _, want := range []WindowID{3, 4} {627+ if w.FocusWindow(1) || w.FocusWindow(-1) || focused(w) != 2 {
595- if !w.FocusWindow(1) || focused(w) != want {628+ t.Fatal("a single row has no window above or below", focused(w))
596- t.Fatal(focused(w), want)
597- }
598 }629 }
599- if !w.FocusWindow(-1) || focused(w) != 3 {630+ w.FocusColumn(1)
631+ if focused(w) != 3 {
632+ t.Fatal(focused(w))
633+ }
634+ w.FocusColumn(-1)
635+ w.FocusColumn(-1)
636+ if focused(w) != 1 {
600 t.Fatal(focused(w))637 t.Fatal(focused(w))
601 }638 }
602 })639 })
@@ -653,12 +690,11 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
653 }690 }
654 })691 })
655 }692 }
656- t.Run("presets ignored", func(t *testing.T) {693+ t.Run("a full row does not grow", func(t *testing.T) {
657 w := fixedWorkspace(2, 0, 2)694 w := fixedWorkspace(2, 0, 2)
658- w.SetPresets([]Width{{Num: 2, Den: 3}})695+ w.GrowColumn(true)
659- w.CycleWidth()
660 w.FocusColumn(-1)696 w.FocusColumn(-1)
661- w.CycleWidth()697+ w.GrowColumn(true)
662 for id, p := range placements(w) {698 for id, p := range placements(w) {
663 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {699 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
664 t.Fatal(id, p)700 t.Fatal(id, p)
M internal/core/monitor.go +5 -8
@@ -126,7 +126,6 @@ func (m *Monitor) take(w *Workspace) {
126126 // (clamped above the trailing empty workspace). It takes this monitor's
127127 // output-wide settings and does not change what is on screen.
128128 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
129- w.presets = append([]Width(nil), m.template.presets...)
130129 w.Gaps = m.template.Gaps
131130 w.border = m.template.border
132131 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
@@ -164,7 +163,6 @@ func (m *Monitor) Current() *Workspace {
164163 func (m *Monitor) newWorkspace() *Workspace {
165164 w := m.template
166165 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
167- w.presets = append([]Width(nil), m.template.presets...)
168166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
169167 w.overviewAfter = nil
170168 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
@@ -304,15 +302,16 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
304302
305303 // AddWindow places a new window on the workspace on screen; with
306304 // stash.capture, in its stash when shown.
307-func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }
305+func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
308306
309-// addWindow is AddWindow with the width of the new column (zero: automatic).
310-func (m *Monitor) addWindow(id WindowID, width Width) {
307+// addWindow is AddWindow with the width in cells of the new column (zero:
308+// automatic).
309+func (m *Monitor) addWindow(id WindowID, cells int) {
311310 if w, _ := m.find(id); w != nil {
312311 return
313312 }
314313 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
315- w.addWindow(id, width)
314+ w.addWindow(id, cells)
316315 }
317316 m.normalize()
318317 }
@@ -502,8 +501,6 @@ func (m *Monitor) SetStash(width, gap int) {
502501 // SetStashCapture makes new windows join the shown stash (stash.capture).
503502 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
504503
505-func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
506-
507504 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
508505 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
509506
@@ -126,7 +126,6 @@ func (m *Monitor) take(w *Workspace) {
126 // (clamped above the trailing empty workspace). It takes this monitor's126 // (clamped above the trailing empty workspace). It takes this monitor's
127 // output-wide settings and does not change what is on screen.127 // output-wide settings and does not change what is on screen.
128 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {128 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
129- w.presets = append([]Width(nil), m.template.presets...)
130 w.Gaps = m.template.Gaps129 w.Gaps = m.template.Gaps
131 w.border = m.template.border130 w.border = m.template.border
132 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap131 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
@@ -164,7 +163,6 @@ func (m *Monitor) Current() *Workspace {
164 func (m *Monitor) newWorkspace() *Workspace {163 func (m *Monitor) newWorkspace() *Workspace {
165 w := m.template164 w := m.template
166 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil165 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
167- w.presets = append([]Width(nil), m.template.presets...)
168 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
169 w.overviewAfter = nil167 w.overviewAfter = nil
170 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0168 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
@@ -304,15 +302,16 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
304 302
305 // AddWindow places a new window on the workspace on screen; with303 // AddWindow places a new window on the workspace on screen; with
306 // stash.capture, in its stash when shown.304 // stash.capture, in its stash when shown.
307-func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }305+func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
308 306
309-// addWindow is AddWindow with the width of the new column (zero: automatic).307+// addWindow is AddWindow with the width in cells of the new column (zero:
310-func (m *Monitor) addWindow(id WindowID, width Width) {308+// automatic).
309+func (m *Monitor) addWindow(id WindowID, cells int) {
311 if w, _ := m.find(id); w != nil {310 if w, _ := m.find(id); w != nil {
312 return311 return
313 }312 }
314 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {313 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
315- w.addWindow(id, width)314+ w.addWindow(id, cells)
316 }315 }
317 m.normalize()316 m.normalize()
318 }317 }
@@ -502,8 +501,6 @@ func (m *Monitor) SetStash(width, gap int) {
502 // SetStashCapture makes new windows join the shown stash (stash.capture).501 // SetStashCapture makes new windows join the shown stash (stash.capture).
503 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }502 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
504 503
505-func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
506-
507 // SetFollowMove makes moves to another workspace show it (focus.follow-move).504 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
508 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }505 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
509 506
M internal/core/monitor_test.go +9 -8
@@ -199,10 +199,10 @@ func TestEqualSharesAndBorderless(t *testing.T) {
199199 if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {
200200 t.Fatal(w.View)
201201 }
202- // A preset on one column is kept; auto columns keep their share.
203- m.SetPresets([]Width{{Num: 1, Den: 2}})
202+ // Growing one column gives it 2 of the 3 cells; auto columns keep
203+ // their share.
204204 m.Apply(ActionCycleColumnWidth)
205- if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {
205+ if w := m.Current(); w.columnWidth(w.Focus) != 66 || w.columnWidth(0) != 33 {
206206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
207207 }
208208 }
@@ -294,11 +294,11 @@ func TestWorkspaceArg(t *testing.T) {
294294 }
295295 }
296296
297-func TestLonePresetColumnKeepsWidth(t *testing.T) {
297+func TestLoneCellColumnKeepsWidth(t *testing.T) {
298298 m := monitor()
299- m.SetPresets([]Width{{Num: 1, Den: 2}})
300299 m.AddWindow(1)
301- m.Apply(ActionCycleColumnWidth)
300+ // One cell of the two: half the width, still no neighbor.
301+ m.Apply("set-column-width -1")
302302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
303303 t.Fatal(p)
304304 }
@@ -475,13 +475,14 @@ func TestMonitorAllAllocations(t *testing.T) {
475475 // exit restores the tiles with the user's focus where it was.
476476 func TestFixedFullscreenStaysInPlace(t *testing.T) {
477477 m := monitor()
478+ m.SetMaxColumns(4)
478479 m.SetOverflow(OverflowFixed)
479480 for id := WindowID(1); id <= 3; id++ {
480481 m.AddWindow(id)
481482 }
482483 w := m.Current()
483484 w.FocusID(2)
484- m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen
485+ m.Apply(ActionCycleColumnWidth) // wider: kept through fullscreen
485486 m.ToggleFullscreen()
486487 m.AddWindow(4)
487488 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {
@@ -491,7 +492,7 @@ func TestFixedFullscreenStaysInPlace(t *testing.T) {
491492 t.Fatal("focus", id)
492493 }
493494 m.ToggleFullscreen()
494- if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded {
495+ if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || w.Columns[1].Cells != 2 {
495496 t.Fatal("after bind", id, w.Columns)
496497 }
497498 }
@@ -199,10 +199,10 @@ func TestEqualSharesAndBorderless(t *testing.T) {
199 if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {199 if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {
200 t.Fatal(w.View)200 t.Fatal(w.View)
201 }201 }
202- // A preset on one column is kept; auto columns keep their share.202+ // Growing one column gives it 2 of the 3 cells; auto columns keep
203- m.SetPresets([]Width{{Num: 1, Den: 2}})203+ // their share.
204 m.Apply(ActionCycleColumnWidth)204 m.Apply(ActionCycleColumnWidth)
205- if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {205+ if w := m.Current(); w.columnWidth(w.Focus) != 66 || w.columnWidth(0) != 33 {
206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
207 }207 }
208 }208 }
@@ -294,11 +294,11 @@ func TestWorkspaceArg(t *testing.T) {
294 }294 }
295 }295 }
296 296
297-func TestLonePresetColumnKeepsWidth(t *testing.T) {297+func TestLoneCellColumnKeepsWidth(t *testing.T) {
298 m := monitor()298 m := monitor()
299- m.SetPresets([]Width{{Num: 1, Den: 2}})
300 m.AddWindow(1)299 m.AddWindow(1)
301- m.Apply(ActionCycleColumnWidth)300+ // One cell of the two: half the width, still no neighbor.
301+ m.Apply("set-column-width -1")
302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
303 t.Fatal(p)303 t.Fatal(p)
304 }304 }
@@ -475,13 +475,14 @@ func TestMonitorAllAllocations(t *testing.T) {
475 // exit restores the tiles with the user's focus where it was.475 // exit restores the tiles with the user's focus where it was.
476 func TestFixedFullscreenStaysInPlace(t *testing.T) {476 func TestFixedFullscreenStaysInPlace(t *testing.T) {
477 m := monitor()477 m := monitor()
478+ m.SetMaxColumns(4)
478 m.SetOverflow(OverflowFixed)479 m.SetOverflow(OverflowFixed)
479 for id := WindowID(1); id <= 3; id++ {480 for id := WindowID(1); id <= 3; id++ {
480 m.AddWindow(id)481 m.AddWindow(id)
481 }482 }
482 w := m.Current()483 w := m.Current()
483 w.FocusID(2)484 w.FocusID(2)
484- m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen485+ m.Apply(ActionCycleColumnWidth) // wider: kept through fullscreen
485 m.ToggleFullscreen()486 m.ToggleFullscreen()
486 m.AddWindow(4)487 m.AddWindow(4)
487 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {488 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {
@@ -491,7 +492,7 @@ func TestFixedFullscreenStaysInPlace(t *testing.T) {
491 t.Fatal("focus", id)492 t.Fatal("focus", id)
492 }493 }
493 m.ToggleFullscreen()494 m.ToggleFullscreen()
494- if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded {495+ if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || w.Columns[1].Cells != 2 {
495 t.Fatal("after bind", id, w.Columns)496 t.Fatal("after bind", id, w.Columns)
496 }497 }
497 }498 }
M internal/core/motion_test.go +3 -2
@@ -156,9 +156,10 @@ func TestSnapPointsMergeClose(t *testing.T) {
156156 // Columns narrower than the view: one point.
157157 {[]int{300, 300}, []float64{0}},
158158 } {
159- w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}
159+ // 100 cells of 10 px, no gaps: a width in px is px/10 cells.
160+ w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}, MaxColumns: 100}
160161 for _, px := range tc.widths {
161- w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162+ w.Columns = append(w.Columns, Column{Cells: px / 10, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162163 }
163164 if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164165 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
@@ -156,9 +156,10 @@ func TestSnapPointsMergeClose(t *testing.T) {
156 // Columns narrower than the view: one point.156 // Columns narrower than the view: one point.
157 {[]int{300, 300}, []float64{0}},157 {[]int{300, 300}, []float64{0}},
158 } {158 } {
159- w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}159+ // 100 cells of 10 px, no gaps: a width in px is px/10 cells.
160+ w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}, MaxColumns: 100}
160 for _, px := range tc.widths {161 for _, px := range tc.widths {
161- w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})162+ w.Columns = append(w.Columns, Column{Cells: px / 10, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162 }163 }
163 if got := w.snapPoints(); !slices.Equal(got, tc.want) {164 if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)165 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
M internal/core/overview_test.go +5 -4
@@ -467,8 +467,8 @@ func TestOverviewPileOnly(t *testing.T) {
467467 // Fixed overflow previews use the actual buffer geometry, including the
468468 // spiral and window stacks, rather than laying full-height columns in a row.
469469 func TestOverviewFixedOverflowGeometry(t *testing.T) {
470- for _, expanded := range []bool{false, true} {
471- t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) {
470+ for _, cells := range []bool{false, true} {
471+ t.Run(map[bool]string{false: "spiral", true: "cells ignored"}[cells], func(t *testing.T) {
472472 m := newMonitor("", "")
473473 m.SetOutput(600, 400)
474474 m.SetOverflow(OverflowFixed)
@@ -477,8 +477,9 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
477477 m.AddWindow(id)
478478 }
479479 w := m.Current()
480- if expanded {
481- w.Columns[2].Expanded = true
480+ if cells {
481+ // The spiral ignores cells: the preview must still match.
482+ w.Columns[0].Cells, w.Columns[2].Cells = 1, 3
482483 }
483484 w.Columns[3].Windows = append(w.Columns[3].Windows, 6)
484485 w.Columns[3].Focus = 1
@@ -467,8 +467,8 @@ func TestOverviewPileOnly(t *testing.T) {
467 // Fixed overflow previews use the actual buffer geometry, including the467 // Fixed overflow previews use the actual buffer geometry, including the
468 // spiral and window stacks, rather than laying full-height columns in a row.468 // spiral and window stacks, rather than laying full-height columns in a row.
469 func TestOverviewFixedOverflowGeometry(t *testing.T) {469 func TestOverviewFixedOverflowGeometry(t *testing.T) {
470- for _, expanded := range []bool{false, true} {470+ for _, cells := range []bool{false, true} {
471- t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) {471+ t.Run(map[bool]string{false: "spiral", true: "cells ignored"}[cells], func(t *testing.T) {
472 m := newMonitor("", "")472 m := newMonitor("", "")
473 m.SetOutput(600, 400)473 m.SetOutput(600, 400)
474 m.SetOverflow(OverflowFixed)474 m.SetOverflow(OverflowFixed)
@@ -477,8 +477,9 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
477 m.AddWindow(id)477 m.AddWindow(id)
478 }478 }
479 w := m.Current()479 w := m.Current()
480- if expanded {480+ if cells {
481- w.Columns[2].Expanded = true481+ // The spiral ignores cells: the preview must still match.
482+ w.Columns[0].Cells, w.Columns[2].Cells = 1, 3
482 }483 }
483 w.Columns[3].Windows = append(w.Columns[3].Windows, 6)484 w.Columns[3].Windows = append(w.Columns[3].Windows, 6)
484 w.Columns[3].Focus = 1485 w.Columns[3].Focus = 1
M internal/core/rules.go +9 -12
@@ -29,7 +29,7 @@ type rule struct {
2929 number int
3030 workspace string
3131 monitor string
32- width Width // zero: automatic
32+ cells int // zero: automatic
3333 }
3434
3535 // ruleEffect is the merge of every rule matching a window: per field, the
@@ -39,7 +39,7 @@ type ruleEffect struct {
3939 number int
4040 workspace string
4141 monitor string
42- width Width // zero: automatic
42+ cells int // zero: automatic
4343 }
4444
4545 func parseRules(rs []ports.WindowRule) ([]rule, error) {
@@ -59,13 +59,10 @@ func parseRules(rs []ports.WindowRule) ([]rule, error) {
5959 p.workspace = r.Workspace
6060 }
6161 }
62- if r.Width != "" {
63- width, err := ParseWidth(r.Width)
64- if err != nil {
65- return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66- }
67- p.width = width
62+ if r.Cells < 0 || r.Cells > maxCells {
63+ return nil, fmt.Errorf("rule %s: invalid width %d", r.Name, r.Cells)
6864 }
65+ p.cells = r.Cells
6966 out = append(out, p)
7067 }
7168 return out, nil
@@ -93,8 +90,8 @@ func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
9390 if r.monitor != "" {
9491 eff.monitor = r.monitor
9592 }
96- if r.width != (Width{}) {
97- eff.width = r.width
93+ if r.cells != 0 {
94+ eff.cells = r.cells
9895 }
9996 }
10097 return eff, ok
@@ -140,9 +137,9 @@ func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
140137 // floated by a rule becomes a free float, core sizing it.
141138 w.addFloat(w.ruleFloat(v), quiet)
142139 case quiet:
143- w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
140+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Cells: eff.cells})
144141 default:
145- sc.mon.addWindow(v.ID, eff.width)
142+ sc.mon.addWindow(v.ID, eff.cells)
146143 return
147144 }
148145 sc.mon.normalize()
@@ -29,7 +29,7 @@ type rule struct {
29 number int29 number int
30 workspace string30 workspace string
31 monitor string31 monitor string
32- width Width // zero: automatic32+ cells int // zero: automatic
33 }33 }
34 34
35 // ruleEffect is the merge of every rule matching a window: per field, the35 // ruleEffect is the merge of every rule matching a window: per field, the
@@ -39,7 +39,7 @@ type ruleEffect struct {
39 number int39 number int
40 workspace string40 workspace string
41 monitor string41 monitor string
42- width Width // zero: automatic42+ cells int // zero: automatic
43 }43 }
44 44
45 func parseRules(rs []ports.WindowRule) ([]rule, error) {45 func parseRules(rs []ports.WindowRule) ([]rule, error) {
@@ -59,13 +59,10 @@ func parseRules(rs []ports.WindowRule) ([]rule, error) {
59 p.workspace = r.Workspace59 p.workspace = r.Workspace
60 }60 }
61 }61 }
62- if r.Width != "" {62+ if r.Cells < 0 || r.Cells > maxCells {
63- width, err := ParseWidth(r.Width)63+ return nil, fmt.Errorf("rule %s: invalid width %d", r.Name, r.Cells)
64- if err != nil {
65- return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66- }
67- p.width = width
68 }64 }
65+ p.cells = r.Cells
69 out = append(out, p)66 out = append(out, p)
70 }67 }
71 return out, nil68 return out, nil
@@ -93,8 +90,8 @@ func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
93 if r.monitor != "" {90 if r.monitor != "" {
94 eff.monitor = r.monitor91 eff.monitor = r.monitor
95 }92 }
96- if r.width != (Width{}) {93+ if r.cells != 0 {
97- eff.width = r.width94+ eff.cells = r.cells
98 }95 }
99 }96 }
100 return eff, ok97 return eff, ok
@@ -140,9 +137,9 @@ func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
140 // floated by a rule becomes a free float, core sizing it.137 // floated by a rule becomes a free float, core sizing it.
141 w.addFloat(w.ruleFloat(v), quiet)138 w.addFloat(w.ruleFloat(v), quiet)
142 case quiet:139 case quiet:
143- w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})140+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Cells: eff.cells})
144 default:141 default:
145- sc.mon.addWindow(v.ID, eff.width)142+ sc.mon.addWindow(v.ID, eff.cells)
146 return143 return
147 }144 }
148 sc.mon.normalize()145 sc.mon.normalize()
M internal/core/rules_test.go +10 -8
@@ -225,8 +225,8 @@ func TestRulesMerge(t *testing.T) {
225225 r := startMulti(t, func(c *ports.Config) {
226226 c.Layout.MaxColumns = 4
227227 c.Rules = []ports.WindowRule{
228- winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }),
229- winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }),
228+ winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Cells = 1 }),
229+ winRule("chat", "chat", func(r *ports.WindowRule) { r.Cells = 2 }),
230230 }
231231 }, left, right)
232232 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
@@ -260,7 +260,7 @@ func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Sce
260260 func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261261 r := startMulti(t, func(c *ports.Config) {
262262 c.Layout.MaxColumns = 4
263- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })}
263+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 2 })}
264264 }, left)
265265 r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266266 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -271,7 +271,7 @@ func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
271271
272272 func TestSlotWinsOverRule(t *testing.T) {
273273 r := startMulti(t, func(c *ports.Config) {
274- c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}}
274+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Cells: 2, Argv: []string{"code"}}}}}
275275 c.Binds["Alt+d"] = "workspace dev"
276276 c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277277 }, left)
@@ -396,16 +396,18 @@ func TestRuleQuietPlacementWithAnimations(t *testing.T) {
396396 }
397397 }
398398
399-func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {
399+// A fixed row shares its width by cells: the auto column takes the cell
400+// the wide one leaves.
401+func TestRuleWidthWithFixedOverflow(t *testing.T) {
400402 r := startMulti(t, func(c *ports.Config) {
401- c.Layout.MaxColumns = 2
403+ c.Layout.MaxColumns = 4
402404 c.Layout.Overflow = "fixed"
403- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}
405+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 3 })}
404406 }, left)
405407 r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406408 r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407409 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408- if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {
410+ if s.Windows[0].Rect.W != 50 || s.Windows[1].Rect.W != 150 {
409411 t.Fatalf("%+v", s.Windows)
410412 }
411413 }
@@ -225,8 +225,8 @@ func TestRulesMerge(t *testing.T) {
225 r := startMulti(t, func(c *ports.Config) {225 r := startMulti(t, func(c *ports.Config) {
226 c.Layout.MaxColumns = 4226 c.Layout.MaxColumns = 4
227 c.Rules = []ports.WindowRule{227 c.Rules = []ports.WindowRule{
228- winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }),228+ winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Cells = 1 }),
229- winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }),229+ winRule("chat", "chat", func(r *ports.WindowRule) { r.Cells = 2 }),
230 }230 }
231 }, left, right)231 }, left, right)
232 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)232 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
@@ -260,7 +260,7 @@ func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Sce
260 func TestRuleWidthOnCurrentWorkspace(t *testing.T) {260 func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261 r := startMulti(t, func(c *ports.Config) {261 r := startMulti(t, func(c *ports.Config) {
262 c.Layout.MaxColumns = 4262 c.Layout.MaxColumns = 4
263- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })}263+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 2 })}
264 }, left)264 }, left)
265 r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}265 r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })266 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -271,7 +271,7 @@ func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
271 271
272 func TestSlotWinsOverRule(t *testing.T) {272 func TestSlotWinsOverRule(t *testing.T) {
273 r := startMulti(t, func(c *ports.Config) {273 r := startMulti(t, func(c *ports.Config) {
274- c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}}274+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Cells: 2, Argv: []string{"code"}}}}}
275 c.Binds["Alt+d"] = "workspace dev"275 c.Binds["Alt+d"] = "workspace dev"
276 c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}276 c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277 }, left)277 }, left)
@@ -396,16 +396,18 @@ func TestRuleQuietPlacementWithAnimations(t *testing.T) {
396 }396 }
397 }397 }
398 398
399-func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {399+// A fixed row shares its width by cells: the auto column takes the cell
400+// the wide one leaves.
401+func TestRuleWidthWithFixedOverflow(t *testing.T) {
400 r := startMulti(t, func(c *ports.Config) {402 r := startMulti(t, func(c *ports.Config) {
401- c.Layout.MaxColumns = 2403+ c.Layout.MaxColumns = 4
402 c.Layout.Overflow = "fixed"404 c.Layout.Overflow = "fixed"
403- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}405+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 3 })}
404 }, left)406 }, left)
405 r.client <- ports.WindowMapped{ID: 1, AppID: "term"}407 r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406 r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}408 r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })409 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408- if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {410+ if s.Windows[0].Rect.W != 50 || s.Windows[1].Rect.W != 150 {
409 t.Fatalf("%+v", s.Windows)411 t.Fatalf("%+v", s.Windows)
410 }412 }
411 }413 }
M internal/core/scene_seq_test.go +4 -1
@@ -40,7 +40,10 @@ func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
4040 // same: the key's scene draws what the previous one did.
4141 same bool
4242 }{
43- {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },
43+ {"width change", func(c *ports.Config) {
44+ c.Layout.MaxColumns = 4
45+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
46+ },
4447 func(t *testing.T, r *swipeRig) ports.Scene {
4548 twoColumns(t, r)
4649 return r.key(t, "Left", ports.ModAlt)[0]
@@ -40,7 +40,10 @@ func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
40 // same: the key's scene draws what the previous one did.40 // same: the key's scene draws what the previous one did.
41 same bool41 same bool
42 }{42 }{
43- {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },43+ {"width change", func(c *ports.Config) {
44+ c.Layout.MaxColumns = 4
45+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
46+ },
44 func(t *testing.T, r *swipeRig) ports.Scene {47 func(t *testing.T, r *swipeRig) ports.Scene {
45 twoColumns(t, r)48 twoColumns(t, r)
46 return r.key(t, "Left", ports.ModAlt)[0]49 return r.key(t, "Left", ports.ModAlt)[0]
M internal/core/screens.go +0 -1
@@ -524,7 +524,6 @@ func (c *Core) settings(m *Monitor) {
524524 }
525525 m.SetOverflow(Overflow(rules.Overflow))
526526 m.SetMaxColumns(rules.MaxColumns)
527- m.SetPresets(c.presets)
528527 m.SetGaps(c.cfg.Layout.Gaps)
529528 m.SetBorder(c.cfg.Border.Width)
530529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
@@ -524,7 +524,6 @@ func (c *Core) settings(m *Monitor) {
524 }524 }
525 m.SetOverflow(Overflow(rules.Overflow))525 m.SetOverflow(Overflow(rules.Overflow))
526 m.SetMaxColumns(rules.MaxColumns)526 m.SetMaxColumns(rules.MaxColumns)
527- m.SetPresets(c.presets)
528 m.SetGaps(c.cfg.Layout.Gaps)527 m.SetGaps(c.cfg.Layout.Gaps)
529 m.SetBorder(c.cfg.Border.Width)528 m.SetBorder(c.cfg.Border.Width)
530 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
M internal/core/slots.go +9 -10
@@ -26,7 +26,7 @@ type slotKey struct {
2626
2727 type slotState struct {
2828 argv []string
29- width Width
29+ cells int
3030 window WindowID // 0 while empty
3131 // stale is set when the workspace is shown while the spawn is pending.
3232 // Shown again still pending, the command is deemed failed and respawned;
@@ -38,21 +38,20 @@ type slotState struct {
3838 type slotSpec struct {
3939 key slotKey
4040 argv []string
41- width Width
41+ cells int
4242 }
4343
4444 func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
4545 var specs []slotSpec
4646 for _, w := range ws {
4747 for _, s := range w.Slots {
48- width, err := ParseWidth(s.Width)
49- if err != nil {
50- return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
48+ if s.Cells < 0 || s.Cells > maxCells {
49+ return nil, fmt.Errorf("workspace %s column %d: invalid width %d", w.Name, s.Index, s.Cells)
5150 }
5251 if len(s.Argv) == 0 || s.Index < 1 {
5352 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
5453 }
55- specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
54+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), cells: s.Cells})
5655 }
5756 }
5857 return specs, nil
@@ -81,14 +80,14 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
8180 for _, s := range specs {
8281 st, ok := c.slots[s.key]
8382 if !ok {
84- c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
83+ c.slots[s.key] = &slotState{argv: s.argv, cells: s.cells}
8584 spawn = append(spawn, s.key)
8685 continue
8786 }
88- if st.width != s.width {
89- st.width = s.width
87+ if st.cells != s.cells {
88+ st.cells = s.cells
9089 if _, w := c.byName(s.key.workspace); w != nil {
91- w.setSlotWidth(s.key.index, s.width)
90+ w.setSlotCells(s.key.index, s.cells)
9291 }
9392 }
9493 if !slices.Equal(st.argv, s.argv) {
@@ -26,7 +26,7 @@ type slotKey struct {
26 26
27 type slotState struct {27 type slotState struct {
28 argv []string28 argv []string
29- width Width29+ cells int
30 window WindowID // 0 while empty30 window WindowID // 0 while empty
31 // stale is set when the workspace is shown while the spawn is pending.31 // stale is set when the workspace is shown while the spawn is pending.
32 // Shown again still pending, the command is deemed failed and respawned;32 // Shown again still pending, the command is deemed failed and respawned;
@@ -38,21 +38,20 @@ type slotState struct {
38 type slotSpec struct {38 type slotSpec struct {
39 key slotKey39 key slotKey
40 argv []string40 argv []string
41- width Width41+ cells int
42 }42 }
43 43
44 func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {44 func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
45 var specs []slotSpec45 var specs []slotSpec
46 for _, w := range ws {46 for _, w := range ws {
47 for _, s := range w.Slots {47 for _, s := range w.Slots {
48- width, err := ParseWidth(s.Width)48+ if s.Cells < 0 || s.Cells > maxCells {
49- if err != nil {49+ return nil, fmt.Errorf("workspace %s column %d: invalid width %d", w.Name, s.Index, s.Cells)
50- return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
51 }50 }
52 if len(s.Argv) == 0 || s.Index < 1 {51 if len(s.Argv) == 0 || s.Index < 1 {
53 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)52 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
54 }53 }
55- specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})54+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), cells: s.Cells})
56 }55 }
57 }56 }
58 return specs, nil57 return specs, nil
@@ -81,14 +80,14 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
81 for _, s := range specs {80 for _, s := range specs {
82 st, ok := c.slots[s.key]81 st, ok := c.slots[s.key]
83 if !ok {82 if !ok {
84- c.slots[s.key] = &slotState{argv: s.argv, width: s.width}83+ c.slots[s.key] = &slotState{argv: s.argv, cells: s.cells}
85 spawn = append(spawn, s.key)84 spawn = append(spawn, s.key)
86 continue85 continue
87 }86 }
88- if st.width != s.width {87+ if st.cells != s.cells {
89- st.width = s.width88+ st.cells = s.cells
90 if _, w := c.byName(s.key.workspace); w != nil {89 if _, w := c.byName(s.key.workspace); w != nil {
91- w.setSlotWidth(s.key.index, s.width)90+ w.setSlotCells(s.key.index, s.cells)
92 }91 }
93 }92 }
94 if !slices.Equal(st.argv, s.argv) {93 if !slices.Equal(st.argv, s.argv) {
M internal/core/slots_test.go +9 -7
@@ -45,9 +45,11 @@ func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
4545 cfg.Animations.On = *animated
4646 }
4747 cfg.Border.Width = 0
48+ // A cell is a tenth of the width: the slots take 7 and 3 cells.
49+ cfg.Layout.MaxColumns = 10
4850 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
49- {Index: 1, Width: "70%", Argv: []string{"code"}},
50- {Index: 2, Width: "30%", Argv: []string{"foot"}},
51+ {Index: 1, Cells: 7, Argv: []string{"code"}},
52+ {Index: 2, Cells: 3, Argv: []string{"foot"}},
5153 }}}
5254 cfg.Binds["Alt+d"] = "workspace dev"
5355 if len(numbered) > 1 {
@@ -285,9 +287,9 @@ func TestSlotReload(t *testing.T) {
285287 cfg := r.cfg
286288 ws := cfg.Workspaces[0]
287289 ws.Slots = []ports.SlotConfig{
288- {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
289- {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
290- {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
290+ {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only
291+ {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command
292+ {Index: 3, Cells: 2, Argv: []string{"btop"}}, // new slot
291293 }
292294 cfg.Workspaces = []ports.WorkspaceConfig{ws}
293295 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -329,7 +331,7 @@ func TestInvalidSlotWidthRejected(t *testing.T) {
329331 receive(t, r.spawn)
330332 cfg := r.cfg
331333 ws := cfg.Workspaces[0]
332- ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
334+ ws.Slots = []ports.SlotConfig{{Index: 1, Cells: 17, Argv: []string{"x"}}}
333335 cfg.Workspaces = []ports.WorkspaceConfig{ws}
334336 r.reload <- ports.ConfigChanged{Config: cfg}
335337 if err := receive(t, r.errs); err == nil {
@@ -346,7 +348,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
346348 r.press(t, "d")
347349 cfg := r.cfg
348350 ws := cfg.Workspaces[0]
349- ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
351+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Cells: 2, Argv: []string{"btop"}})
350352 cfg.Workspaces = []ports.WorkspaceConfig{ws}
351353 r.reload <- ports.ConfigChanged{Config: cfg}
352354 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
@@ -45,9 +45,11 @@ func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
45 cfg.Animations.On = *animated45 cfg.Animations.On = *animated
46 }46 }
47 cfg.Border.Width = 047 cfg.Border.Width = 0
48+ // A cell is a tenth of the width: the slots take 7 and 3 cells.
49+ cfg.Layout.MaxColumns = 10
48 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{50 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
49- {Index: 1, Width: "70%", Argv: []string{"code"}},51+ {Index: 1, Cells: 7, Argv: []string{"code"}},
50- {Index: 2, Width: "30%", Argv: []string{"foot"}},52+ {Index: 2, Cells: 3, Argv: []string{"foot"}},
51 }}}53 }}}
52 cfg.Binds["Alt+d"] = "workspace dev"54 cfg.Binds["Alt+d"] = "workspace dev"
53 if len(numbered) > 1 {55 if len(numbered) > 1 {
@@ -285,9 +287,9 @@ func TestSlotReload(t *testing.T) {
285 cfg := r.cfg287 cfg := r.cfg
286 ws := cfg.Workspaces[0]288 ws := cfg.Workspaces[0]
287 ws.Slots = []ports.SlotConfig{289 ws.Slots = []ports.SlotConfig{
288- {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only290+ {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only
289- {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command291+ {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command
290- {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot292+ {Index: 3, Cells: 2, Argv: []string{"btop"}}, // new slot
291 }293 }
292 cfg.Workspaces = []ports.WorkspaceConfig{ws}294 cfg.Workspaces = []ports.WorkspaceConfig{ws}
293 r.reload <- ports.ConfigChanged{Config: cfg}295 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -329,7 +331,7 @@ func TestInvalidSlotWidthRejected(t *testing.T) {
329 receive(t, r.spawn)331 receive(t, r.spawn)
330 cfg := r.cfg332 cfg := r.cfg
331 ws := cfg.Workspaces[0]333 ws := cfg.Workspaces[0]
332- ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}334+ ws.Slots = []ports.SlotConfig{{Index: 1, Cells: 17, Argv: []string{"x"}}}
333 cfg.Workspaces = []ports.WorkspaceConfig{ws}335 cfg.Workspaces = []ports.WorkspaceConfig{ws}
334 r.reload <- ports.ConfigChanged{Config: cfg}336 r.reload <- ports.ConfigChanged{Config: cfg}
335 if err := receive(t, r.errs); err == nil {337 if err := receive(t, r.errs); err == nil {
@@ -346,7 +348,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
346 r.press(t, "d")348 r.press(t, "d")
347 cfg := r.cfg349 cfg := r.cfg
348 ws := cfg.Workspaces[0]350 ws := cfg.Workspaces[0]
349- ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})351+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Cells: 2, Argv: []string{"btop"}})
350 cfg.Workspaces = []ports.WorkspaceConfig{ws}352 cfg.Workspaces = []ports.WorkspaceConfig{ws}
351 r.reload <- ports.ConfigChanged{Config: cfg}353 r.reload <- ports.ConfigChanged{Config: cfg}
352 if v := receive(t, r.spawn); v.Argv[0] != "btop" {354 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
M internal/core/spawn_placement.go +1 -1
@@ -108,7 +108,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108108 if sc, w := c.byName(target.slot.workspace); w != nil {
109109 p.dropSlot(target.slot)
110110 st.window = v.ID
111- w.AddSlotWindow(v.ID, target.slot.index, st.width)
111+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
112112 sc.mon.normalize()
113113 return
114114 }
@@ -108,7 +108,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108 if sc, w := c.byName(target.slot.workspace); w != nil {108 if sc, w := c.byName(target.slot.workspace); w != nil {
109 p.dropSlot(target.slot)109 p.dropSlot(target.slot)
110 st.window = v.ID110 st.window = v.ID
111- w.AddSlotWindow(v.ID, target.slot.index, st.width)111+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
112 sc.mon.normalize()112 sc.mon.normalize()
113 return113 return
114 }114 }
M internal/core/stash.go +4 -6
@@ -51,7 +51,7 @@ func (w *Workspace) stashWindow(id WindowID) {
5151 func (w *Workspace) tilePlace(id WindowID) *origPlace {
5252 c := w.Columns[w.Focus]
5353 lone := len(c.Windows) == 1
54- back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone}
54+ back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, cells: c.Cells, fullWidth: c.FullWidth && lone}
5555 for _, v := range c.Windows {
5656 if v != id {
5757 back.stacked = append(back.stacked, v)
@@ -139,10 +139,8 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
139139 w.scroll()
140140 return
141141 }
142- // Its column is gone: it comes back expanded, unless another column
143- // was expanded meanwhile.
144- expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
145- w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
142+ // Its column is gone: it comes back at its width.
143+ w.insertColumn(at, Column{Windows: []WindowID{id}, Cells: back.cells, Slot: back.slot})
146144 if back.fullWidth {
147145 w.maximize(w.Focus)
148146 }
@@ -153,7 +151,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
153151 func (f Float) rehome() Float {
154152 back := &origPlace{col: math.MaxInt}
155153 if f.back != nil {
156- back.width = f.back.width
154+ back.cells = f.back.cells
157155 }
158156 f.back = back
159157 return f
@@ -51,7 +51,7 @@ func (w *Workspace) stashWindow(id WindowID) {
51 func (w *Workspace) tilePlace(id WindowID) *origPlace {51 func (w *Workspace) tilePlace(id WindowID) *origPlace {
52 c := w.Columns[w.Focus]52 c := w.Columns[w.Focus]
53 lone := len(c.Windows) == 153 lone := len(c.Windows) == 1
54- back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone}54+ back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, cells: c.Cells, fullWidth: c.FullWidth && lone}
55 for _, v := range c.Windows {55 for _, v := range c.Windows {
56 if v != id {56 if v != id {
57 back.stacked = append(back.stacked, v)57 back.stacked = append(back.stacked, v)
@@ -139,10 +139,8 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
139 w.scroll()139 w.scroll()
140 return140 return
141 }141 }
142- // Its column is gone: it comes back expanded, unless another column142+ // Its column is gone: it comes back at its width.
143- // was expanded meanwhile.143+ w.insertColumn(at, Column{Windows: []WindowID{id}, Cells: back.cells, Slot: back.slot})
144- expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
145- w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
146 if back.fullWidth {144 if back.fullWidth {
147 w.maximize(w.Focus)145 w.maximize(w.Focus)
148 }146 }
@@ -153,7 +151,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
153 func (f Float) rehome() Float {151 func (f Float) rehome() Float {
154 back := &origPlace{col: math.MaxInt}152 back := &origPlace{col: math.MaxInt}
155 if f.back != nil {153 if f.back != nil {
156- back.width = f.back.width154+ back.cells = f.back.cells
157 }155 }
158 f.back = back156 f.back = back
159 return f157 return f
M internal/core/switcher_test.go +1 -1
@@ -637,7 +637,7 @@ func TestSwitcherLayoutCards(t *testing.T) {
637637 c, _, _ := switcherCore(t, 3)
638638 c.cfg.Floating.Dim = 0.4
639639 // A wider focused column: the cards have different aspect ratios.
640- c.cur().mon.Current().ResizeColumn(30)
640+ c.cur().mon.Current().GrowColumn(false)
641641 indicatorScene(t, c)
642642 area := c.cur().mon.Current().overviewArea()
643643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
@@ -637,7 +637,7 @@ func TestSwitcherLayoutCards(t *testing.T) {
637 c, _, _ := switcherCore(t, 3)637 c, _, _ := switcherCore(t, 3)
638 c.cfg.Floating.Dim = 0.4638 c.cfg.Floating.Dim = 0.4
639 // A wider focused column: the cards have different aspect ratios.639 // A wider focused column: the cards have different aspect ratios.
640- c.cur().mon.Current().ResizeColumn(30)640+ c.cur().mon.Current().GrowColumn(false)
641 indicatorScene(t, c)641 indicatorScene(t, c)
642 area := c.cur().mon.Current().overviewArea()642 area := c.cur().mon.Current().overviewArea()
643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
M internal/core/transition_test.go +26 -19
@@ -482,7 +482,10 @@ func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
482482 // A width change animates the frame while the client gets one configure to
483483 // the final size.
484484 func TestSetColumnWidthRectTransition(t *testing.T) {
485- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
485+ r := startSwipe(t, func(c *ports.Config) {
486+ c.Layout.MaxColumns = 4
487+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
488+ })
486489 twoColumns(t, r)
487490 r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
488491 for len(r.commands) > 0 {
@@ -497,18 +500,18 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
497500 t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
498501 }
499502 s = r.frame(t, 16*time.Millisecond)
500- if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {
501- t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)
503+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 600 {
504+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 600", got)
502505 }
503- if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {
504- t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)
506+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 600 {
507+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 600", got)
505508 }
506509 s = r.settle(t)
507- if got := rectNow(t, s, 1).W; got != 480 {
508- t.Fatalf("window 1 is %d wide settled, want 480", got)
510+ if got := rectNow(t, s, 1).W; got != 600 {
511+ t.Fatalf("window 1 is %d wide settled, want 600", got)
509512 }
510- if got := rectNow(t, s, 2).X; got != 480 {
511- t.Fatalf("window 2 at %d settled, want 480", got)
513+ if got := rectNow(t, s, 2).X; got != 600 {
514+ t.Fatalf("window 2 at %d settled, want 600", got)
512515 }
513516 var widths []int
514517 for len(r.commands) > 0 {
@@ -516,8 +519,8 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
516519 widths = append(widths, v.Width)
517520 }
518521 }
519- if len(widths) != 1 || widths[0] != 480 {
520- t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)
522+ if len(widths) != 1 || widths[0] != 600 {
523+ t.Fatalf("configures of window 1 carried widths %v, want exactly [600]", widths)
521524 }
522525 }
523526
@@ -594,12 +597,16 @@ func TestChainedMovesStartFromShownRects(t *testing.T) {
594597 // after the click still shows the old view, a frame an intermediate one and
595598 // the settled scene the new one.
596599 func TestClickFocusTransition(t *testing.T) {
597- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
600+ r := startSwipe(t, func(c *ports.Config) {
601+ c.Layout.MaxColumns = 4
602+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +2"
603+ })
598604 threeColumns(t, r)
599- // Column 3 grows to 480: column 2 sticks out 80 px on the left.
605+ // Column 3 grows from 1 to 3 cells of 200 px: column 2 sticks out 66 px
606+ // on the left.
600607 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
601- if got := xOf(t, r.settle(t), 2); got != -80 {
602- t.Fatalf("column 2 at %d, want -80", got)
608+ if got := xOf(t, r.settle(t), 2); got != -66 {
609+ t.Fatalf("column 2 at %d, want -66", got)
603610 }
604611 // The release of the modifiers: a plain click, not a window drag.
605612 r.input <- ports.KeyEvent{Keysym: "Alt_L"}
@@ -607,12 +614,12 @@ func TestClickFocusTransition(t *testing.T) {
607614 r.drain()
608615 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
609616 s := scene(t, r.scenes)
610- if got := xOf(t, s, 2); got != -80 {
611- t.Fatalf("column 2 at %d right after the click, want the old -80", got)
617+ if got := xOf(t, s, 2); got != -66 {
618+ t.Fatalf("column 2 at %d right after the click, want the old -66", got)
612619 }
613620 got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
614- if got <= -80 || got >= 0 {
615- t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)
621+ if got <= -66 || got >= 0 {
622+ t.Fatalf("column 2 at %d on the first frame, want between -66 and 0", got)
616623 }
617624 if got := xOf(t, r.settle(t), 2); got != 0 {
618625 t.Fatalf("column 2 at %d settled, want 0", got)
@@ -482,7 +482,10 @@ func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
482 // A width change animates the frame while the client gets one configure to482 // A width change animates the frame while the client gets one configure to
483 // the final size.483 // the final size.
484 func TestSetColumnWidthRectTransition(t *testing.T) {484 func TestSetColumnWidthRectTransition(t *testing.T) {
485- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })485+ r := startSwipe(t, func(c *ports.Config) {
486+ c.Layout.MaxColumns = 4
487+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
488+ })
486 twoColumns(t, r)489 twoColumns(t, r)
487 r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll490 r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
488 for len(r.commands) > 0 {491 for len(r.commands) > 0 {
@@ -497,18 +500,18 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
497 t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)500 t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
498 }501 }
499 s = r.frame(t, 16*time.Millisecond)502 s = r.frame(t, 16*time.Millisecond)
500- if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {503+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 600 {
501- t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)504+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 600", got)
502 }505 }
503- if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {506+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 600 {
504- t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)507+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 600", got)
505 }508 }
506 s = r.settle(t)509 s = r.settle(t)
507- if got := rectNow(t, s, 1).W; got != 480 {510+ if got := rectNow(t, s, 1).W; got != 600 {
508- t.Fatalf("window 1 is %d wide settled, want 480", got)511+ t.Fatalf("window 1 is %d wide settled, want 600", got)
509 }512 }
510- if got := rectNow(t, s, 2).X; got != 480 {513+ if got := rectNow(t, s, 2).X; got != 600 {
511- t.Fatalf("window 2 at %d settled, want 480", got)514+ t.Fatalf("window 2 at %d settled, want 600", got)
512 }515 }
513 var widths []int516 var widths []int
514 for len(r.commands) > 0 {517 for len(r.commands) > 0 {
@@ -516,8 +519,8 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
516 widths = append(widths, v.Width)519 widths = append(widths, v.Width)
517 }520 }
518 }521 }
519- if len(widths) != 1 || widths[0] != 480 {522+ if len(widths) != 1 || widths[0] != 600 {
520- t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)523+ t.Fatalf("configures of window 1 carried widths %v, want exactly [600]", widths)
521 }524 }
522 }525 }
523 526
@@ -594,12 +597,16 @@ func TestChainedMovesStartFromShownRects(t *testing.T) {
594 // after the click still shows the old view, a frame an intermediate one and597 // after the click still shows the old view, a frame an intermediate one and
595 // the settled scene the new one.598 // the settled scene the new one.
596 func TestClickFocusTransition(t *testing.T) {599 func TestClickFocusTransition(t *testing.T) {
597- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })600+ r := startSwipe(t, func(c *ports.Config) {
601+ c.Layout.MaxColumns = 4
602+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +2"
603+ })
598 threeColumns(t, r)604 threeColumns(t, r)
599- // Column 3 grows to 480: column 2 sticks out 80 px on the left.605+ // Column 3 grows from 1 to 3 cells of 200 px: column 2 sticks out 66 px
606+ // on the left.
600 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)607 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
601- if got := xOf(t, r.settle(t), 2); got != -80 {608+ if got := xOf(t, r.settle(t), 2); got != -66 {
602- t.Fatalf("column 2 at %d, want -80", got)609+ t.Fatalf("column 2 at %d, want -66", got)
603 }610 }
604 // The release of the modifiers: a plain click, not a window drag.611 // The release of the modifiers: a plain click, not a window drag.
605 r.input <- ports.KeyEvent{Keysym: "Alt_L"}612 r.input <- ports.KeyEvent{Keysym: "Alt_L"}
@@ -607,12 +614,12 @@ func TestClickFocusTransition(t *testing.T) {
607 r.drain()614 r.drain()
608 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}615 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
609 s := scene(t, r.scenes)616 s := scene(t, r.scenes)
610- if got := xOf(t, s, 2); got != -80 {617+ if got := xOf(t, s, 2); got != -66 {
611- t.Fatalf("column 2 at %d right after the click, want the old -80", got)618+ t.Fatalf("column 2 at %d right after the click, want the old -66", got)
612 }619 }
613 got := xOf(t, r.frame(t, 16*time.Millisecond), 2)620 got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
614- if got <= -80 || got >= 0 {621+ if got <= -66 || got >= 0 {
615- t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)622+ t.Fatalf("column 2 at %d on the first frame, want between -66 and 0", got)
616 }623 }
617 if got := xOf(t, r.settle(t), 2); got != 0 {624 if got := xOf(t, r.settle(t), 2); got != 0 {
618 t.Fatalf("column 2 at %d settled, want 0", got)625 t.Fatalf("column 2 at %d settled, want 0", got)
M internal/core/window_actions_test.go +71 -64
@@ -66,101 +66,108 @@ func TestMoveWindowOnFloat(t *testing.T) {
6666 }
6767 }
6868
69-func TestResizeColumn(t *testing.T) {
69+// set-column-width +N/-N moves cells with no cycle, and a column never
70+// goes below one cell.
71+func TestSetColumnWidth(t *testing.T) {
7072 w := workspace()
7173 w.SetOutput(1000, 800)
7274 w.SetGaps(10)
75+ w.SetMaxColumns(4)
76+ w.Overflow = OverflowFixed
7377 w.AddWindow(1)
7478 w.AddWindow(2)
7579 before := w.columnWidth(w.Focus)
76- w.Apply("set-column-width +10%")
77- if w.Columns[w.Focus].Width.Den != 100 || w.columnWidth(w.Focus) <= before {
78- t.Fatal(w.Columns[w.Focus].Width, w.columnWidth(w.Focus), before)
79- }
80- w.Apply("set-column-width -10%")
81- if d := w.columnWidth(w.Focus) - before; d < -1 || d > 1 {
82- t.Fatal(w.columnWidth(w.Focus), before)
83- }
84- for range 20 {
85- w.Apply("set-column-width +10%")
86- }
87- if w.Columns[w.Focus].Width != (Width{Num: 100, Den: 100}) {
88- t.Fatal(w.Columns[w.Focus].Width)
89- }
90- for range 20 {
91- w.Apply("set-column-width -10%")
92- }
93- if w.Columns[w.Focus].Width != (Width{Num: 10, Den: 100}) {
94- t.Fatal(w.Columns[w.Focus].Width)
95- }
96- // A preset snaps to the nearest whole percent once; steps then
97- // round-trip exactly.
98- for _, p := range []Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 2, Den: 3}} {
99- w.Columns[w.Focus].Width = p
100- px := w.columnWidth(w.Focus)
101- w.Apply("set-column-width +7%")
102- w.Apply("set-column-width -7%")
103- snapped := w.columnWidth(w.Focus)
104- if d := snapped - px; d < -w.Usable.W/100 || d > w.Usable.W/100 {
105- t.Fatal(p, snapped, px)
106- }
107- w.Apply("set-column-width +7%")
108- w.Apply("set-column-width -7%")
109- if w.columnWidth(w.Focus) != snapped {
110- t.Fatal(p, w.columnWidth(w.Focus), snapped)
111- }
80+ w.Apply("set-column-width +1")
81+ if w.Columns[w.Focus].Cells != 3 || w.columnWidth(w.Focus) <= before {
82+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
83+ }
84+ // No donor is left: the width stays, it does not cycle back.
85+ w.Apply("set-column-width +1")
86+ if w.Columns[0].Cells != 1 || w.Columns[1].Cells != 3 {
87+ t.Fatal(w.Columns)
88+ }
89+ w.Apply("set-column-width -1")
90+ if w.Columns[0].Cells != 2 || w.Columns[1].Cells != 2 || w.columnWidth(w.Focus) != before {
91+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
92+ }
93+ for range 5 {
94+ w.Apply("set-column-width -1")
95+ }
96+ if w.Columns[1].Cells != 1 || w.Columns[0].Cells != 3 {
97+ t.Fatal(w.Columns)
98+ }
99+ w.Apply("set-column-width +2")
100+ if w.Columns[1].Cells != 3 || w.Columns[0].Cells != 1 {
101+ t.Fatal(w.Columns)
112102 }
113103 }
114104
115-func TestResizeColumnUnmaximizesAndCycles(t *testing.T) {
105+func TestSetColumnWidthScroll(t *testing.T) {
116106 w := workspace()
117107 w.SetOutput(1000, 800)
118- w.SetPresets([]Width{{Num: 1, Den: 2}, {Num: 1, Den: 1}})
108+ w.SetMaxColumns(4)
109+ w.Overflow = OverflowScroll
119110 w.AddWindow(1)
120- w.ToggleFullWidth()
121- w.Apply("set-column-width -10%")
122- if w.Columns[0].FullWidth {
123- t.Fatal("still maximized")
111+ w.AddWindow(2)
112+ w.Apply("set-column-width +1")
113+ if w.Columns[w.Focus].Cells != 3 {
114+ t.Fatal(w.Columns)
115+ }
116+ for range 5 {
117+ w.Apply("set-column-width +1")
118+ }
119+ if w.Columns[w.Focus].Cells != 4 {
120+ t.Fatal("grows up to max-columns", w.Columns)
124121 }
125- // A width outside the presets restarts the cycle at the first preset.
126- w.CycleWidth()
127- if w.Columns[0].Width != (Width{Num: 1, Den: 2}) {
128- t.Fatal(w.Columns[0].Width)
122+ for range 9 {
123+ w.Apply("set-column-width -1")
124+ }
125+ if w.Columns[w.Focus].Cells != 1 {
126+ t.Fatal("never below one cell", w.Columns)
129127 }
130128 }
131129
132-func TestResizeColumnSameValueAsPreset(t *testing.T) {
133- w := workspace()
134- w.SetOutput(1000, 800)
135- w.SetPresets([]Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 1, Den: 1}})
136- w.AddWindow(1)
137- w.Columns[0].Width = Width{Num: 50, Den: 100}
138- w.CycleWidth()
139- if w.Columns[0].Width != (Width{Num: 1, Den: 1}) {
140- t.Fatal(w.Columns[0].Width)
130+func TestSetColumnWidthUnmaximizes(t *testing.T) {
131+ for _, a := range []Action{"set-column-width +1", "set-column-width -1", ActionCycleColumnWidth} {
132+ w := workspace()
133+ w.SetOutput(1000, 800)
134+ w.SetMaxColumns(4)
135+ w.AddWindow(1)
136+ w.ToggleFullWidth()
137+ w.Apply(a)
138+ if w.Columns[0].FullWidth {
139+ t.Fatalf("%s: still maximized", a)
140+ }
141141 }
142142 }
143143
144144 func TestColumnEditsUnderFullscreen(t *testing.T) {
145145 w := workspace()
146146 w.SetOutput(1000, 800)
147- w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: Width{Num: 1, Den: 2}}}
147+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Cells: 1}}
148148 w.SetFullscreen(1, true)
149- w.Apply("set-column-width -10%")
149+ w.Apply("set-column-width -1")
150+ w.Apply(ActionCycleColumnWidth)
150151 w.Apply(ActionMoveWindowDown)
151152 c := w.Columns[0]
152- if c.Width != (Width{Num: 1, Den: 2}) || !slices.Equal(c.Windows, []WindowID{1, 2}) {
153+ if c.Cells != 1 || !slices.Equal(c.Windows, []WindowID{1, 2}) {
153154 t.Fatal(c)
154155 }
155156 }
156157
157-func TestResizeColumnFixedNoop(t *testing.T) {
158+func TestSetColumnWidthFixedPastMax(t *testing.T) {
158159 w := workspace()
159160 w.Overflow = OverflowFixed
160- w.AddWindow(1)
161- w.Apply("set-column-width +10%")
162- if w.Columns[0].Width != (Width{}) {
163- t.Fatal(w.Columns[0].Width)
161+ for id := WindowID(1); id <= 3; id++ {
162+ w.AddWindow(id)
163+ }
164+ w.Apply("set-column-width +1")
165+ w.Apply("set-column-width -1")
166+ w.Apply(ActionCycleColumnWidth)
167+ for _, c := range w.Columns {
168+ if c.Cells != 0 {
169+ t.Fatal(w.Columns)
170+ }
164171 }
165172 }
166173
@@ -66,101 +66,108 @@ func TestMoveWindowOnFloat(t *testing.T) {
66 }66 }
67 }67 }
68 68
69-func TestResizeColumn(t *testing.T) {69+// set-column-width +N/-N moves cells with no cycle, and a column never
70+// goes below one cell.
71+func TestSetColumnWidth(t *testing.T) {
70 w := workspace()72 w := workspace()
71 w.SetOutput(1000, 800)73 w.SetOutput(1000, 800)
72 w.SetGaps(10)74 w.SetGaps(10)
75+ w.SetMaxColumns(4)
76+ w.Overflow = OverflowFixed
73 w.AddWindow(1)77 w.AddWindow(1)
74 w.AddWindow(2)78 w.AddWindow(2)
75 before := w.columnWidth(w.Focus)79 before := w.columnWidth(w.Focus)
76- w.Apply("set-column-width +10%")80+ w.Apply("set-column-width +1")
77- if w.Columns[w.Focus].Width.Den != 100 || w.columnWidth(w.Focus) <= before {81+ if w.Columns[w.Focus].Cells != 3 || w.columnWidth(w.Focus) <= before {
78- t.Fatal(w.Columns[w.Focus].Width, w.columnWidth(w.Focus), before)82+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
79- }83+ }
80- w.Apply("set-column-width -10%")84+ // No donor is left: the width stays, it does not cycle back.
81- if d := w.columnWidth(w.Focus) - before; d < -1 || d > 1 {85+ w.Apply("set-column-width +1")
82- t.Fatal(w.columnWidth(w.Focus), before)86+ if w.Columns[0].Cells != 1 || w.Columns[1].Cells != 3 {
83- }87+ t.Fatal(w.Columns)
84- for range 20 {88+ }
85- w.Apply("set-column-width +10%")89+ w.Apply("set-column-width -1")
86- }90+ if w.Columns[0].Cells != 2 || w.Columns[1].Cells != 2 || w.columnWidth(w.Focus) != before {
87- if w.Columns[w.Focus].Width != (Width{Num: 100, Den: 100}) {91+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
88- t.Fatal(w.Columns[w.Focus].Width)92+ }
89- }93+ for range 5 {
90- for range 20 {94+ w.Apply("set-column-width -1")
91- w.Apply("set-column-width -10%")95+ }
92- }96+ if w.Columns[1].Cells != 1 || w.Columns[0].Cells != 3 {
93- if w.Columns[w.Focus].Width != (Width{Num: 10, Den: 100}) {97+ t.Fatal(w.Columns)
94- t.Fatal(w.Columns[w.Focus].Width)98+ }
95- }99+ w.Apply("set-column-width +2")
96- // A preset snaps to the nearest whole percent once; steps then100+ if w.Columns[1].Cells != 3 || w.Columns[0].Cells != 1 {
97- // round-trip exactly.101+ t.Fatal(w.Columns)
98- for _, p := range []Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 2, Den: 3}} {
99- w.Columns[w.Focus].Width = p
100- px := w.columnWidth(w.Focus)
101- w.Apply("set-column-width +7%")
102- w.Apply("set-column-width -7%")
103- snapped := w.columnWidth(w.Focus)
104- if d := snapped - px; d < -w.Usable.W/100 || d > w.Usable.W/100 {
105- t.Fatal(p, snapped, px)
106- }
107- w.Apply("set-column-width +7%")
108- w.Apply("set-column-width -7%")
109- if w.columnWidth(w.Focus) != snapped {
110- t.Fatal(p, w.columnWidth(w.Focus), snapped)
111- }
112 }102 }
113 }103 }
114 104
115-func TestResizeColumnUnmaximizesAndCycles(t *testing.T) {105+func TestSetColumnWidthScroll(t *testing.T) {
116 w := workspace()106 w := workspace()
117 w.SetOutput(1000, 800)107 w.SetOutput(1000, 800)
118- w.SetPresets([]Width{{Num: 1, Den: 2}, {Num: 1, Den: 1}})108+ w.SetMaxColumns(4)
109+ w.Overflow = OverflowScroll
119 w.AddWindow(1)110 w.AddWindow(1)
120- w.ToggleFullWidth()111+ w.AddWindow(2)
121- w.Apply("set-column-width -10%")112+ w.Apply("set-column-width +1")
122- if w.Columns[0].FullWidth {113+ if w.Columns[w.Focus].Cells != 3 {
123- t.Fatal("still maximized")114+ t.Fatal(w.Columns)
115+ }
116+ for range 5 {
117+ w.Apply("set-column-width +1")
118+ }
119+ if w.Columns[w.Focus].Cells != 4 {
120+ t.Fatal("grows up to max-columns", w.Columns)
124 }121 }
125- // A width outside the presets restarts the cycle at the first preset.122+ for range 9 {
126- w.CycleWidth()123+ w.Apply("set-column-width -1")
127- if w.Columns[0].Width != (Width{Num: 1, Den: 2}) {124+ }
128- t.Fatal(w.Columns[0].Width)125+ if w.Columns[w.Focus].Cells != 1 {
126+ t.Fatal("never below one cell", w.Columns)
129 }127 }
130 }128 }
131 129
132-func TestResizeColumnSameValueAsPreset(t *testing.T) {130+func TestSetColumnWidthUnmaximizes(t *testing.T) {
133- w := workspace()131+ for _, a := range []Action{"set-column-width +1", "set-column-width -1", ActionCycleColumnWidth} {
134- w.SetOutput(1000, 800)132+ w := workspace()
135- w.SetPresets([]Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 1, Den: 1}})133+ w.SetOutput(1000, 800)
136- w.AddWindow(1)134+ w.SetMaxColumns(4)
137- w.Columns[0].Width = Width{Num: 50, Den: 100}135+ w.AddWindow(1)
138- w.CycleWidth()136+ w.ToggleFullWidth()
139- if w.Columns[0].Width != (Width{Num: 1, Den: 1}) {137+ w.Apply(a)
140- t.Fatal(w.Columns[0].Width)138+ if w.Columns[0].FullWidth {
139+ t.Fatalf("%s: still maximized", a)
140+ }
141 }141 }
142 }142 }
143 143
144 func TestColumnEditsUnderFullscreen(t *testing.T) {144 func TestColumnEditsUnderFullscreen(t *testing.T) {
145 w := workspace()145 w := workspace()
146 w.SetOutput(1000, 800)146 w.SetOutput(1000, 800)
147- w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: Width{Num: 1, Den: 2}}}147+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Cells: 1}}
148 w.SetFullscreen(1, true)148 w.SetFullscreen(1, true)
149- w.Apply("set-column-width -10%")149+ w.Apply("set-column-width -1")
150+ w.Apply(ActionCycleColumnWidth)
150 w.Apply(ActionMoveWindowDown)151 w.Apply(ActionMoveWindowDown)
151 c := w.Columns[0]152 c := w.Columns[0]
152- if c.Width != (Width{Num: 1, Den: 2}) || !slices.Equal(c.Windows, []WindowID{1, 2}) {153+ if c.Cells != 1 || !slices.Equal(c.Windows, []WindowID{1, 2}) {
153 t.Fatal(c)154 t.Fatal(c)
154 }155 }
155 }156 }
156 157
157-func TestResizeColumnFixedNoop(t *testing.T) {158+func TestSetColumnWidthFixedPastMax(t *testing.T) {
158 w := workspace()159 w := workspace()
159 w.Overflow = OverflowFixed160 w.Overflow = OverflowFixed
160- w.AddWindow(1)161+ for id := WindowID(1); id <= 3; id++ {
161- w.Apply("set-column-width +10%")162+ w.AddWindow(id)
162- if w.Columns[0].Width != (Width{}) {163+ }
163- t.Fatal(w.Columns[0].Width)164+ w.Apply("set-column-width +1")
165+ w.Apply("set-column-width -1")
166+ w.Apply(ActionCycleColumnWidth)
167+ for _, c := range w.Columns {
168+ if c.Cells != 0 {
169+ t.Fatal(w.Columns)
170+ }
164 }171 }
165 }172 }
166 173
M internal/ports/config.go +7 -6
@@ -134,8 +134,7 @@ type Config struct {
134134 Inactive string
135135 }
136136 Layout struct {
137- Gaps int
138- Presets []string
137+ Gaps int
139138 // LayoutRules are the defaults; Outputs override them per screen and
140139 // named workspaces override them again.
141140 LayoutRules
@@ -254,10 +253,11 @@ func SplitCommands(v string) [][]string {
254253
255254 // SlotConfig reserves column N of a workspace for the window of one command.
256255 // neferwl spawns the command at startup; the window it opens goes to the
257-// slot. Width is a layout width (fraction, percentage or pixels).
256+// slot. Cells is its width in cells, a cell being one max-columns-th of
257+// the usable width (1 to 16; 0 shares the width with the other columns).
258258 type SlotConfig struct {
259259 Index int
260- Width string
260+ Cells int
261261 Argv []string
262262 }
263263
@@ -274,6 +274,7 @@ type WindowRule struct {
274274 Workspace string
275275 // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276276 Monitor string
277- // Width is the layout width of the new column of a tiled window.
278- Width string
277+ // Cells is the width in cells of the new column of a tiled window
278+ // (1 to 16; 0 leaves it automatic).
279+ Cells int
279280 }
@@ -134,8 +134,7 @@ type Config struct {
134 Inactive string134 Inactive string
135 }135 }
136 Layout struct {136 Layout struct {
137- Gaps int137+ Gaps int
138- Presets []string
139 // LayoutRules are the defaults; Outputs override them per screen and138 // LayoutRules are the defaults; Outputs override them per screen and
140 // named workspaces override them again.139 // named workspaces override them again.
141 LayoutRules140 LayoutRules
@@ -254,10 +253,11 @@ func SplitCommands(v string) [][]string {
254 253
255 // SlotConfig reserves column N of a workspace for the window of one command.254 // SlotConfig reserves column N of a workspace for the window of one command.
256 // neferwl spawns the command at startup; the window it opens goes to the255 // neferwl spawns the command at startup; the window it opens goes to the
257-// slot. Width is a layout width (fraction, percentage or pixels).256+// slot. Cells is its width in cells, a cell being one max-columns-th of
257+// the usable width (1 to 16; 0 shares the width with the other columns).
258 type SlotConfig struct {258 type SlotConfig struct {
259 Index int259 Index int
260- Width string260+ Cells int
261 Argv []string261 Argv []string
262 }262 }
263 263
@@ -274,6 +274,7 @@ type WindowRule struct {
274 Workspace string274 Workspace string
275 // Monitor is a connector (DP-2) or a monitor key ("make model serial").275 // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276 Monitor string276 Monitor string
277- // Width is the layout width of the new column of a tiled window.277+ // Cells is the width in cells of the new column of a tiled window
278- Width string278+ // (1 to 16; 0 leaves it automatic).
279+ Cells int
279 }280 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close