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
38 files · +1226 -666
M README.md +4 -5
| @@ -204,7 +204,6 @@ floating.dim = 0.3 | |||
| 204 | 204 | layout.gaps = 0 | |
| 205 | 205 | layout.max-columns = 2 | |
| 206 | 206 | layout.overflow = cascade | |
| 207 | - | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 208 | 207 | stash.width = 80 | |
| 209 | 208 | stash.gap = 2 | |
| 210 | 209 | stash.dim = 0.5 | |
| @@ -267,12 +266,12 @@ bind.cmd+bracketleft = consume-or-expel-window-left | |||
| 267 | 266 | bind.cmd+bracketright = consume-or-expel-window-right | |
| 268 | 267 | ||
| 269 | 268 | # 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 | |
| 272 | 271 | bind.cmd+alt+up = set-window-height -10% | |
| 273 | 272 | 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 | |
| 276 | 275 | bind.cmd+alt+k = set-window-height -10% | |
| 277 | 276 | bind.cmd+alt+j = set-window-height +10% | |
| 278 | 277 | bind.cmd+shift+space = toggle-floating | |
| @@ -204,7 +204,6 @@ floating.dim = 0.3 | |||||
| 204 | layout.gaps = 0 | 204 | layout.gaps = 0 | ||
| 205 | layout.max-columns = 2 | 205 | layout.max-columns = 2 | ||
| 206 | layout.overflow = cascade | 206 | layout.overflow = cascade | ||
| 207 | - | layout.presets = 1/3, 1/2, 2/3, 1 | |||
| 208 | stash.width = 80 | 207 | stash.width = 80 | ||
| 209 | stash.gap = 2 | 208 | stash.gap = 2 | ||
| 210 | stash.dim = 0.5 | 209 | stash.dim = 0.5 | ||
| @@ -267,12 +266,12 @@ bind.cmd+bracketleft = consume-or-expel-window-left | |||||
| 267 | bind.cmd+bracketright = consume-or-expel-window-right | 266 | bind.cmd+bracketright = consume-or-expel-window-right | ||
| 268 | 267 | ||||
| 269 | # Resize and window modes | 268 | # 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-floating | 277 | 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`). | |||
| 29 | 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 | 30 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Colors for focused / other window lines; only the focused output lights up | | |
| 31 | 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 | 33 | | `layout.overflow` | `cascade` | Past the max: `cascade` into vertically scrolling bands, `scroll` right, or `fixed` spiral split | | |
| 34 | 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 | 35 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | |
| 37 | 36 | | `touchpad.tap` | `on` | Tap to click: one finger left, two right, three middle | | |
| 38 | 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 | 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 | 54 | | `workspace.<name>.size` | `inherit` | Logical `WxH` viewport, centered on the monitor; inherits monitor size by default and always keeps its scale | | |
| 56 | 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 | 57 | | `rule.<name>.*` | none | Window rules: place an app's windows by app ID when they first open; see [Window rules](#window-rules) | | |
| 59 | 58 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 60 | 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 | 80 | | `rule.<name>.floating` | `on` floats the window (centred, free floating, sized by NeferWL); `off` tiles a fixed-size window that would float | | |
| 82 | 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 | 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 | 85 | ```ini | |
| 87 | 86 | rule.games.app-id = steam_app_.* | |
| @@ -89,7 +88,7 @@ rule.games.workspace = 3 | |||
| 89 | 88 | rule.games.monitor = DP-2 | |
| 90 | 89 | rule.chat.app-id = discord|org\.telegram\..* | |
| 91 | 90 | rule.chat.workspace = chat | |
| 92 | - | rule.chat.width = 1/3 | |
| 91 | + | rule.chat.width = 1 | |
| 93 | 92 | rule.calc.app-id = org\.gnome\.Calculator | |
| 94 | 93 | rule.calc.floating = on | |
| 95 | 94 | ``` | |
| @@ -99,7 +98,6 @@ rule.calc.floating = on | |||
| 99 | 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 | 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 | 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 | 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 | 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 | 226 | ### 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 | 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 | 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 | 252 | ### Actions | |
| 243 | 253 | ||
| 244 | 254 | | Action | Effect | | |
| @@ -247,19 +257,19 @@ Cascade supports dropping onto tiles, but not gap insertion or edge scrolling du | |||
| 247 | 257 | | `spawn-terminal` | Run `terminal` | | |
| 248 | 258 | | `close-window` | Close the focused window | | |
| 249 | 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 | 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 | 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 | 263 | | `toggle-overview` | Open the [overview](#overview), or close it on the selected window | | |
| 254 | 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 | 265 | | `switch-column-prev` | The same, in the opposite direction | | |
| 256 | 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 | 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 | 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 | 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 | 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 | 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 | 274 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | |
| 265 | 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 | 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 | 299 | ### 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 | 350 | | `cmd+bracketright` | `consume-or-expel-window-right` | | |
| 341 | 351 | | `cmd+shift+up` / `cmd+shift+k` | `move-window-up` | | |
| 342 | 352 | | `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` | | |
| 345 | 355 | | `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` | | |
| 346 | 356 | | `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` | | |
| 347 | 357 | | `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 | ```ini | 85 | ```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-2 | 88 | 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 = chat | 90 | rule.chat.workspace = chat | ||
| 92 | - | rule.chat.width = 1/3 | 91 | + | rule.chat.width = 1 |
| 93 | rule.calc.app-id = org\.gnome\.Calculator | 92 | rule.calc.app-id = org\.gnome\.Calculator | ||
| 94 | rule.calc.floating = on | 93 | 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 | ### Cascade | 226 | ### 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 | ### Actions | 252 | ### 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 pointer | 299 | ### 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 | |||
| 141 | 141 | - the focused output and window; | |
| 142 | 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 | 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 | 146 | ```sh | |
| 147 | 147 | 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 | ```sh | 146 | ```sh | ||
| 147 | neferwl state # the whole state as JSON | 147 | neferwl state # the whole state as JSON | ||
M examples/config +15 -15
| @@ -37,15 +37,14 @@ border.inactive = #111111 | |||
| 37 | 37 | floating.dim = 0.3 | |
| 38 | 38 | ||
| 39 | 39 | 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. | |
| 42 | 43 | layout.max-columns = 2 | |
| 43 | 44 | # Past max-columns: cascade starts a new band of columns below, scrolling | |
| 44 | 45 | # vertically; scroll opens columns to the right; fixed keeps everything on | |
| 45 | 46 | # screen, each new window splitting the newest one (spiral). | |
| 46 | 47 | 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 | 49 | # Touchpad. A three-finger swipe up or down slides between workspaces, left | |
| 51 | 50 | # or right scrolls the columns (cascade swaps the two axes). natural-scroll | |
| @@ -96,21 +95,20 @@ focus.strength = 0.04 | |||
| 96 | 95 | # workspace.web.max-columns = 3 | |
| 97 | 96 | # workspace.web.overflow = fixed | |
| 98 | 97 | # | |
| 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 | |
| 100 | 99 | # background and puts its first window in column N, without taking focus. | |
| 101 | 100 | # 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. | |
| 104 | 102 | # 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 | |
| 107 | 105 | # bind.cmd+d = workspace dev | |
| 108 | 106 | ||
| 109 | 107 | # Window rules place an app's windows when they first open, without moving | |
| 110 | 108 | # the focus or the view. app-id is a regular expression matching the whole | |
| 111 | 109 | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |
| 112 | 110 | # 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 | 112 | # rule.games.app-id = steam_app_.* | |
| 115 | 113 | # rule.games.workspace = 3 | |
| 116 | 114 | # rule.games.monitor = DP-2 | |
| @@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up | |||
| 172 | 170 | bind.cmd+shift+down = move-window-down | |
| 173 | 171 | bind.cmd+shift+k = move-window-up | |
| 174 | 172 | 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 | |
| 178 | 177 | bind.cmd+alt+up = set-window-height -10% | |
| 179 | 178 | 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 | |
| 182 | 181 | bind.cmd+alt+k = set-window-height -10% | |
| 183 | 182 | bind.cmd+alt+j = set-window-height +10% | |
| 184 | 183 | # A tile becomes a free floating window, or returns to its column. | |
| 185 | 184 | bind.cmd+shift+space = toggle-floating | |
| 185 | + | # Grow the focused column by one cell, then back to equal shares. | |
| 186 | 186 | bind.cmd+r = cycle-column-width | |
| 187 | 187 | bind.cmd+f = maximize-column | |
| 188 | 188 | bind.cmd+shift+f = toggle-fullscreen | |
| @@ -37,15 +37,14 @@ border.inactive = #111111 | |||||
| 37 | floating.dim = 0.3 | 37 | floating.dim = 0.3 | ||
| 38 | 38 | ||||
| 39 | layout.gaps = 0 | 39 | 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 = 2 | 43 | layout.max-columns = 2 | ||
| 43 | # Past max-columns: cascade starts a new band of columns below, scrolling | 44 | # Past max-columns: cascade starts a new band of columns below, scrolling | ||
| 44 | # vertically; scroll opens columns to the right; fixed keeps everything on | 45 | # 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 = cascade | 47 | 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, left | 49 | # Touchpad. A three-finger swipe up or down slides between workspaces, left | ||
| 51 | # or right scrolls the columns (cascade swaps the two axes). natural-scroll | 50 | # 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 = 3 | 95 | # workspace.web.max-columns = 3 | ||
| 97 | # workspace.web.overflow = fixed | 96 | # workspace.web.overflow = fixed | ||
| 98 | # | 97 | # | ||
| 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 |
| 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 that | 100 | # 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 | 101 | + | # reuse a running instance need a new-instance flag. |
| 103 | - | # overflow = scroll. | |||
| 104 | # workspace.dev.monitor = DP-2 | 102 | # workspace.dev.monitor = DP-2 | ||
| 105 | - | # workspace.dev.column.1 = 67%, code --new-window | 103 | + | # workspace.dev.column.1 = 2, code --new-window |
| 106 | - | # workspace.dev.column.2 = 33%, foot | 104 | + | # workspace.dev.column.2 = 1, foot |
| 107 | # bind.cmd+d = workspace dev | 105 | # bind.cmd+d = workspace dev | ||
| 108 | 106 | ||||
| 109 | # Window rules place an app's windows when they first open, without moving | 107 | # 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 whole | 108 | # 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 a | 110 | # 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 = 3 | 113 | # rule.games.workspace = 3 | ||
| 116 | # rule.games.monitor = DP-2 | 114 | # rule.games.monitor = DP-2 | ||
| @@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up | |||||
| 172 | bind.cmd+shift+down = move-window-down | 170 | bind.cmd+shift+down = move-window-down | ||
| 173 | bind.cmd+shift+k = move-window-up | 171 | bind.cmd+shift+k = move-window-up | ||
| 174 | bind.cmd+shift+j = move-window-down | 172 | 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-floating | 184 | 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-width | 186 | bind.cmd+r = cycle-column-width | ||
| 187 | bind.cmd+f = maximize-column | 187 | bind.cmd+f = maximize-column | ||
| 188 | bind.cmd+shift+f = toggle-fullscreen | 188 | bind.cmd+shift+f = toggle-fullscreen | ||
M internal/adapters/config/cascade_test.go +8 -3
| @@ -21,9 +21,14 @@ func TestCascadeOverflowInherited(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 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") { | |
| 27 | 32 | t.Fatalf("warnings=%v", w) | |
| 28 | 33 | } | |
| 29 | 34 | } | |
| @@ -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 }{ | |||
| 64 | 64 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 65 | 65 | {"cmd+shift+up", "move-window-up"}, | |
| 66 | 66 | {"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"}, | |
| 69 | 69 | {"cmd+alt+up", "set-window-height -10%"}, | |
| 70 | 70 | {"cmd+alt+down", "set-window-height +10%"}, | |
| 71 | 71 | {"cmd+ctrl+shift+up", "move-workspace-prev"}, | |
| @@ -118,9 +118,13 @@ var actions = map[string]bool{ | |||
| 118 | 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 | 119 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 120 | 120 | ||
| 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 | + | ) | |
| 124 | 128 | ||
| 125 | 129 | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | |
| 126 | 130 | var namedKeys = map[string]string{ | |
| @@ -170,7 +174,6 @@ func Defaults() ports.Config { | |||
| 170 | 174 | c.Border.Active = "#808080" | |
| 171 | 175 | c.Border.Inactive = "#111111" | |
| 172 | 176 | c.Layout.MaxColumns = 2 | |
| 173 | - | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 174 | 177 | c.Layout.Overflow = "cascade" | |
| 175 | 178 | c.Touchpad.Tap = true | |
| 176 | 179 | c.Touchpad.AccelProfile = ports.AccelAdaptive | |
| @@ -780,22 +783,11 @@ func set(c *ports.Config, key, v string) error { | |||
| 780 | 783 | } | |
| 781 | 784 | c.Layout.Gaps = n | |
| 782 | 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 | 787 | case "layout.max-columns": | |
| 785 | 788 | return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v) | |
| 786 | 789 | case "layout.overflow": | |
| 787 | 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 | 791 | case "touchpad.tap": | |
| 800 | 792 | b, err := onOff(v) | |
| 801 | 793 | if err != nil { | |
| @@ -1018,10 +1010,6 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 1018 | 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 | 1013 | if !bound[w.Name] { | |
| 1026 | 1014 | line := 0 | |
| 1027 | 1015 | for key, n := range seen { | |
| @@ -1089,10 +1077,11 @@ func setRule(r *ports.WindowRule, field, v string) error { | |||
| 1089 | 1077 | } | |
| 1090 | 1078 | r.Monitor = v | |
| 1091 | 1079 | 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 | |
| 1094 | 1083 | } | |
| 1095 | - | r.Width = v | |
| 1084 | + | r.Cells = n | |
| 1096 | 1085 | default: | |
| 1097 | 1086 | return errUnknownRuleField | |
| 1098 | 1087 | } | |
| @@ -1159,10 +1148,11 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||
| 1159 | 1148 | size, cmd, _ := strings.Cut(v, ",") | |
| 1160 | 1149 | size = strings.TrimSpace(size) | |
| 1161 | 1150 | 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)") | |
| 1164 | 1154 | } | |
| 1165 | - | slot := ports.SlotConfig{Index: index, Width: size, Argv: argv} | |
| 1155 | + | slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv} | |
| 1166 | 1156 | i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index }) | |
| 1167 | 1157 | if i < len(w.Slots) && w.Slots[i].Index == index { | |
| 1168 | 1158 | w.Slots[i] = slot | |
| @@ -1231,13 +1221,17 @@ func checkAction(v string) error { | |||
| 1231 | 1221 | return nil | |
| 1232 | 1222 | } | |
| 1233 | 1223 | } | |
| 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)") | |
| 1240 | 1233 | } | |
| 1234 | + | return nil | |
| 1241 | 1235 | } | |
| 1242 | 1236 | for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} { | |
| 1243 | 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 | 1389 | return w, h, hz, nil | |
| 1396 | 1390 | } | |
| 1397 | 1391 | ||
| 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 | |
| 1413 | 1399 | } | |
| 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 | |
| 1417 | 1402 | } | |
| @@ -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 and | 121 | + | // 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 = 2 | 176 | 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 = true | 178 | c.Touchpad.Tap = true | ||
| 176 | c.Touchpad.AccelProfile = ports.AccelAdaptive | 179 | c.Touchpad.AccelProfile = ports.AccelAdaptive | ||
| @@ -780,22 +783,11 @@ func set(c *ports.Config, key, v string) error { | |||||
| 780 | } | 783 | } | ||
| 781 | c.Layout.Gaps = n | 784 | 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 := 0 | 1014 | 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 = v | 1078 | 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 = v | 1084 | + | r.Cells = n |
| 1096 | default: | 1085 | default: | ||
| 1097 | return errUnknownRuleField | 1086 | 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] = slot | 1158 | w.Slots[i] = slot | ||
| @@ -1231,13 +1221,17 @@ func checkAction(v string) error { | |||||
| 1231 | return nil | 1221 | 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 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)") | |||
| 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, nil | 1389 | 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 true | 1394 | + | |
| 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 <= 100 | 1398 | + | 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) { | |||
| 32 | 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 | 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 | 36 | t.Fatal(d.Binds) | |
| 37 | 37 | } | |
| 38 | 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 | 224 | terminal = foot --server | |
| 225 | 225 | background = #000000 | |
| 226 | 226 | border.inactive = | |
| 227 | - | layout.presets = 1/3, 1/2 ,1 | |
| 228 | 227 | log.debug = core, input | |
| 229 | 228 | output.DP-2.scale = 4/3 | |
| 230 | 229 | output.DP-2 = 5120x2160@165.058 | |
| @@ -248,7 +247,7 @@ startup = wl-paste --primary --watch cliphist store; waybar ;mako; | |||
| 248 | 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 | 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 | 251 | t.Fatalf("%+v", c) | |
| 253 | 252 | } | |
| 254 | 253 | // Scale and mode lines combine in either order. | |
| @@ -270,7 +269,7 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 270 | 269 | {"layout.gaps = 201", "layout.gaps"}, | |
| 271 | 270 | {"layout.max-columns = 0", "layout.max-columns"}, | |
| 272 | 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 | 273 | {"log.level = trace", "log.level"}, | |
| 275 | 274 | {"log.debug = nope", "log.debug"}, | |
| 276 | 275 | {"output.DP-2 = big", "output.DP-2"}, | |
| @@ -287,8 +286,12 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 287 | 286 | {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`}, | |
| 288 | 287 | {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`}, | |
| 289 | 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"}, | |
| 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"}, | |
| 292 | 295 | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | |
| 293 | 296 | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | |
| 294 | 297 | {"bind.cmd+x = spawn ", "unknown action"}, | |
| @@ -523,17 +526,22 @@ workspace.web.column.1 = 50%, foot | |||
| 523 | 526 | func TestWorkspaceSlots(t *testing.T) { | |
| 524 | 527 | c, w := parseString(t, `layout.overflow = scroll | |
| 525 | 528 | 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 | |
| 531 | 534 | 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 | 540 | bind.cmd+d = workspace dev | |
| 533 | 541 | `) | |
| 534 | 542 | 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"}}, | |
| 537 | 545 | } | |
| 538 | 546 | if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) { | |
| 539 | 547 | t.Fatalf("%+v", c.Workspaces) | |
| @@ -542,17 +550,19 @@ bind.cmd+d = workspace dev | |||
| 542 | 550 | for _, x := range w { | |
| 543 | 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 | 554 | t.Fatal(w) | |
| 547 | 555 | } | |
| 548 | 556 | } | |
| 549 | 557 | ||
| 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 | |
| 553 | 563 | `) | |
| 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) | |
| 556 | 566 | } | |
| 557 | 567 | } | |
| 558 | 568 | ||
| @@ -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 --server | 224 | terminal = foot --server | ||
| 225 | background = #000000 | 225 | background = #000000 | ||
| 226 | border.inactive = | 226 | border.inactive = | ||
| 227 | - | layout.presets = 1/3, 1/2 ,1 | |||
| 228 | log.debug = core, input | 227 | log.debug = core, input | ||
| 229 | output.DP-2.scale = 4/3 | 228 | output.DP-2.scale = 4/3 | ||
| 230 | output.DP-2 = 5120x2160@165.058 | 229 | 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 = scroll | 527 | c, w := parseString(t, `layout.overflow = scroll | ||
| 525 | workspace.dev.monitor = DP-2 | 528 | workspace.dev.monitor = DP-2 | ||
| 526 | - | workspace.dev.column.2 = 33%, foot --title x | 529 | + | workspace.dev.column.2 = 1, foot --title x |
| 527 | - | workspace.dev.column.1 = 67%, code --new-window | 530 | + | workspace.dev.column.1 = 3, code --new-window |
| 528 | - | workspace.dev.column.2 = 1/3, kitty | 531 | + | workspace.dev.column.2 = 16, kitty |
| 529 | - | workspace.dev.column.0 = 50%, foot | 532 | + | workspace.dev.column.0 = 2, foot |
| 530 | - | workspace.dev.column.3 = 50% | 533 | + | workspace.dev.column.3 = 2 |
| 531 | workspace.dev.column.4 = huge, foot | 534 | 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 dev | 540 | 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 = fixed | 559 | + | func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) { |
| 552 | - | workspace.dev.column.1 = 67%, foot | 560 | + | 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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -17,8 +18,8 @@ rule.games.workspace = 3 | |||
| 17 | 18 | rule.chat.app-id = org\.telegram\..*|discord | |
| 18 | 19 | rule.chat.workspace = chat | |
| 19 | 20 | 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 | |
| 22 | 23 | `) | |
| 23 | 24 | if len(w) != 0 { | |
| 24 | 25 | t.Fatal(w) | |
| @@ -27,10 +28,10 @@ rule.games.width = 50% | |||
| 27 | 28 | t.Fatalf("%+v", c.Rules) | |
| 28 | 29 | } | |
| 29 | 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 | 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 | 35 | t.Fatalf("%+v", ch) | |
| 35 | 36 | } | |
| 36 | 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 | 72 | // An invalid duplicate keeps the earlier value and the rule. | |
| 72 | 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 | 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 | 81 | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |
| 76 | 82 | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |
| 77 | 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) { | |
| 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) | |
| 98 | 104 | } | |
| 99 | 105 | } | |
| 100 | 106 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | 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\..*|discord | 18 | rule.chat.app-id = org\.telegram\..*|discord | ||
| 18 | rule.chat.workspace = chat | 19 | rule.chat.workspace = chat | ||
| 19 | rule.chat.monitor = DP-2 | 20 | rule.chat.monitor = DP-2 | ||
| 20 | - | rule.chat.width = 1/3 | 21 | + | 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{ | |||
| 91 | 91 | ResizeHeight: "set-window-height ", | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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) { | |
| 98 | 99 | for i, prefix := range resizePrefixes { | |
| 99 | 100 | rest, found := strings.CutPrefix(string(a), prefix) | |
| 100 | 101 | if !found { | |
| 101 | 102 | continue | |
| 102 | 103 | } | |
| 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 | + | } | |
| 105 | 115 | } | |
| 106 | 116 | return 0, 0, false | |
| 107 | 117 | } | |
| 108 | 118 | return 0, 0, false | |
| 109 | 119 | } | |
| 110 | 120 | ||
| 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] != '-') { | |
| 114 | 124 | return 0, false | |
| 115 | 125 | } | |
| 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] == '-' { | |
| 118 | 128 | return 0, false | |
| 119 | 129 | } | |
| 120 | 130 | if s[0] == '-' { | |
| @@ -418,11 +428,17 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 418 | 428 | if w.freeAction(a) { | |
| 419 | 429 | return Effect{} | |
| 420 | 430 | } | |
| 421 | - | if axis, pct, ok := ResizeArg(a); ok { | |
| 431 | + | if axis, step, ok := ResizeArg(a); ok { | |
| 422 | 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 | 440 | } else { | |
| 425 | - | w.ResizeRow(pct) | |
| 441 | + | w.ResizeRow(step) | |
| 426 | 442 | } | |
| 427 | 443 | return Effect{} | |
| 428 | 444 | } | |
| @@ -446,7 +462,7 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 446 | 462 | case ActionMoveWindowDown: | |
| 447 | 463 | w.MoveWindow(1) | |
| 448 | 464 | case ActionCycleColumnWidth: | |
| 449 | - | w.CycleWidth() | |
| 465 | + | w.GrowColumn(true) | |
| 450 | 466 | case ActionMaximizeColumn: | |
| 451 | 467 | w.ToggleFullWidth() | |
| 452 | 468 | 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) and | 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 | 95 | + | // "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 | continue | 102 | continue | ||
| 102 | } | 103 | } | ||
| 103 | - | if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok { | 104 | + | rest = strings.TrimSpace(rest) |
| 104 | - | return i, p, true | 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 | + | } | |||
| 105 | } | 115 | } | ||
| 106 | return 0, 0, false | 116 | return 0, 0, false | ||
| 107 | } | 117 | } | ||
| 108 | return 0, 0, false | 118 | 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, false | 124 | 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, false | 128 | 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) { | |||
| 38 | 38 | ||
| 39 | 39 | func TestResizeArg(t *testing.T) { | |
| 40 | 40 | 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 | |
| 44 | 44 | }{ | |
| 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}, | |
| 47 | 48 | {"set-window-height +100%", core.ResizeHeight, 100, true}, | |
| 48 | 49 | {"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}, | |
| 55 | 59 | {"set-window-height", 0, 0, false}, | |
| 56 | 60 | {"cycle-column-width", 0, 0, false}, | |
| 57 | 61 | } { | |
| 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) | |
| 61 | 65 | } | |
| 62 | 66 | } | |
| 63 | 67 | } | |
| @@ -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.Action | 41 | + | a core.Action |
| 42 | - | axis, pct int | 42 | + | axis, step int |
| 43 | - | ok bool | 43 | + | 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" | |||
| 4 | 4 | ||
| 5 | 5 | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | |
| 6 | 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 | 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 | 18 | func (w *Workspace) cascadeBands() (n, spacing int) { | |
| 11 | 19 | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |
| @@ -14,12 +22,64 @@ func (w *Workspace) cascadeBands() (n, spacing int) { | |||
| 14 | 22 | 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 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. | |
| 20 | 28 | 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 | |
| 23 | 83 | } | |
| 24 | 84 | ||
| 25 | 85 | // 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 | 111 | g := w.gap() | |
| 52 | 112 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 53 | 113 | 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 | |
| 59 | 130 | } | |
| 60 | - | rects[i] = r | |
| 61 | 131 | } | |
| 62 | 132 | return rects | |
| 63 | 133 | } | |
| @@ -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.H | 22 | 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 band | 25 | + | // 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 column | 26 | + | // (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 width | 85 | // 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 + g | 117 | + | 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 rects | 132 | 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 { | |||
| 116 | 116 | layerChanged bool | |
| 117 | 117 | sent sentCache | |
| 118 | 118 | specs []NamedWorkspace | |
| 119 | - | presets []Width | |
| 120 | 119 | slots map[slotKey]*slotState | |
| 121 | 120 | rules []rule | |
| 122 | 121 | placement spawnPlacement | |
| @@ -227,14 +226,6 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 227 | 226 | if cfg.Layout.MaxColumns < 1 { | |
| 228 | 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 | 229 | if cfg.Layout.Gaps < 0 { | |
| 239 | 230 | return fmt.Errorf("invalid gaps") | |
| 240 | 231 | } | |
| @@ -328,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 328 | 319 | c.cfg = cfg | |
| 329 | 320 | c.binds = binds | |
| 330 | 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, rules | |
| 322 | + | c.specs, c.rules = named, rules | |
| 332 | 323 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 333 | 324 | c.named() | |
| 334 | 325 | for _, s := range c.screens { | |
| @@ -116,7 +116,6 @@ type Core struct { | |||||
| 116 | layerChanged bool | 116 | layerChanged bool | ||
| 117 | sent sentCache | 117 | sent sentCache | ||
| 118 | specs []NamedWorkspace | 118 | specs []NamedWorkspace | ||
| 119 | - | presets []Width | |||
| 120 | slots map[slotKey]*slotState | 119 | slots map[slotKey]*slotState | ||
| 121 | rules []rule | 120 | rules []rule | ||
| 122 | placement spawnPlacement | 121 | 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 = cfg | 319 | c.cfg = cfg | ||
| 329 | c.binds = binds | 320 | 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, rules | 322 | + | 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) { | |||
| 463 | 463 | w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci] | |
| 464 | 464 | w.FocusID(id) | |
| 465 | 465 | } else { | |
| 466 | - | width := Width{} | |
| 466 | + | cells := 0 | |
| 467 | 467 | if lone { | |
| 468 | - | width = src.Columns[ci].Width | |
| 468 | + | cells = src.Columns[ci].Cells | |
| 469 | 469 | } | |
| 470 | 470 | srcIndex := ci | |
| 471 | 471 | src.dropSlotOf(id) | |
| 472 | 472 | src.RemoveWindow(id) | |
| 473 | - | col := Column{Windows: []WindowID{id}, Width: width} | |
| 473 | + | col := Column{Windows: []WindowID{id}, Cells: cells} | |
| 474 | 474 | switch t.kind { | |
| 475 | 475 | case dropAbove, dropBelow: | |
| 476 | 476 | 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].Width | 468 | + | cells = src.Columns[ci].Cells |
| 469 | } | 469 | } | ||
| 470 | srcIndex := ci | 470 | 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 { | |||
| 40 | 40 | // The column goes away, and a slot with it. | |
| 41 | 41 | w.stackWindow(id, w.Columns[next].Windows[0]) | |
| 42 | 42 | case !w.full(): | |
| 43 | - | c := Column{Windows: []WindowID{id}, Width: col.Width} | |
| 43 | + | c := Column{Windows: []WindowID{id}, Cells: col.Cells} | |
| 44 | 44 | if col.Focus == 0 && col.Slot != 0 { | |
| 45 | 45 | // The slot and its width follow the slot window. | |
| 46 | 46 | 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 | 48 | } else if col.Slot != 0 { | |
| 49 | - | c.Width = Width{} | |
| 49 | + | c.Cells = 0 | |
| 50 | 50 | } | |
| 51 | 51 | w.RemoveWindow(id) | |
| 52 | 52 | 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.Slot | 46 | 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) { | |||
| 100 | 100 | } | |
| 101 | 101 | t.Run("the slot follows its window", func(t *testing.T) { | |
| 102 | 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 | 104 | w.Columns[0].Focus = 0 | |
| 105 | 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 | 107 | t.Fatal(got, slots(w), w.Columns) | |
| 108 | 108 | } | |
| 109 | 109 | }) | |
| @@ -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 = 0 | 104 | 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) { | |||
| 15 | 15 | w.border = 2 | |
| 16 | 16 | w.AddWindow(1) | |
| 17 | 17 | w.AddWindow(2) | |
| 18 | - | w.Columns[1].Width = Width{Pixels: 35} | |
| 18 | + | w.Columns[1].Cells = 3 | |
| 19 | 19 | if stacked { | |
| 20 | 20 | w.Columns[1].Windows = []WindowID{3, 2} | |
| 21 | 21 | w.Columns[1].Focus = 1 | |
| @@ -34,7 +34,7 @@ func TestToggleWindowStash(t *testing.T) { | |||
| 34 | 34 | } | |
| 35 | 35 | w.Apply(ActionToggleWindowStash) | |
| 36 | 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 | 38 | t.Fatalf("round trip: %+v", w) | |
| 39 | 39 | } | |
| 40 | 40 | }) | |
| @@ -103,7 +103,7 @@ func TestStashStrip(t *testing.T) { | |||
| 103 | 103 | // Column actions do nothing while the stash has the focus. | |
| 104 | 104 | w.Apply(ActionMoveColumnLeft) | |
| 105 | 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 | 107 | t.Fatalf("column changed: %+v", w.Columns) | |
| 108 | 108 | } | |
| 109 | 109 | // A gap as wide as the margin leaves nothing to show: neighbors wait | |
| @@ -263,7 +263,7 @@ func TestFullscreenPinsFocus(t *testing.T) { | |||
| 263 | 263 | w.focusCover() | |
| 264 | 264 | w.Apply(ActionMoveColumnLeft) | |
| 265 | 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 | 267 | t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns) | |
| 268 | 268 | } | |
| 269 | 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. | |
| 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) { | |
| 358 | 358 | w := workspace() | |
| 359 | 359 | w.Overflow = OverflowFixed | |
| 360 | 360 | w.SetMaxColumns(3) | |
| 361 | 361 | w.AddWindow(1) | |
| 362 | 362 | 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) | |
| 366 | 366 | } | |
| 367 | 367 | w.Apply(ActionToggleWindowStash) | |
| 368 | 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 | 370 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | |
| 371 | 371 | } | |
| 372 | 372 | } | |
| @@ -15,7 +15,7 @@ func TestToggleWindowStash(t *testing.T) { | |||||
| 15 | w.border = 2 | 15 | 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 = 1 | 21 | 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 wait | 109 | // 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 = OverflowFixed | 359 | 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 { | |||
| 74 | 74 | } | |
| 75 | 75 | f := &w.Floats[i] | |
| 76 | 76 | u := w.Usable | |
| 77 | - | if axis, pct, ok := ResizeArg(a); ok { | |
| 77 | + | if axis, step, ok := ResizeArg(a); ok { | |
| 78 | 78 | if u.W <= 0 || u.H <= 0 { | |
| 79 | 79 | 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 | 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 | 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 | 88 | w.clampFree(f) | |
| 88 | 89 | 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.Usable | 76 | 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 true | 79 | 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 true | 89 | return true | ||
M internal/core/free_test.go +7 -7
| @@ -33,7 +33,7 @@ func TestFreeFloatRect(t *testing.T) { | |||
| 33 | 33 | ||
| 34 | 34 | func TestToggleFloatingRoundTrip(t *testing.T) { | |
| 35 | 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 | 37 | w.Focus = 1 | |
| 38 | 38 | r, _ := w.placed(3) | |
| 39 | 39 | w.Apply(ActionToggleFloating) | |
| @@ -58,11 +58,11 @@ func TestToggleFloatingRoundTrip(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | // A lone column comes back at its width and index. | |
| 60 | 60 | 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 | 62 | w.Columns = append(w.Columns, Column{Windows: []WindowID{3}}) | |
| 63 | 63 | w.Apply(ActionToggleFloating) | |
| 64 | 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 | 66 | t.Fatal(w.Columns, w.Focus) | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| @@ -188,7 +188,7 @@ func TestFreeFloatNoUsableArea(t *testing.T) { | |||
| 188 | 188 | t.Fatal(fx) | |
| 189 | 189 | } | |
| 190 | 190 | w.Apply(ActionMoveColumnLeft) | |
| 191 | - | w.Apply("set-column-width +10%") | |
| 191 | + | w.Apply("set-column-width +1") | |
| 192 | 192 | if w.floatIndex(1) < 0 { | |
| 193 | 193 | t.Fatal(w.Floats) | |
| 194 | 194 | } | |
| @@ -213,13 +213,13 @@ func TestFreeFloatKeyboard(t *testing.T) { | |||
| 213 | 213 | if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X { | |
| 214 | 214 | t.Fatal(got) | |
| 215 | 215 | } | |
| 216 | - | w.Apply("set-column-width +10%") | |
| 216 | + | w.Apply("set-column-width +1") | |
| 217 | 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 | 219 | t.Fatal(w.Floats[i]) | |
| 220 | 220 | } | |
| 221 | 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 | 223 | t.Fatal(w.Columns) | |
| 224 | 224 | } | |
| 225 | 225 | } | |
| @@ -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 = 1 | 37 | 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 = 1 | 60 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | 4 | "math" | |
| 6 | - | "math/bits" | |
| 7 | 5 | "slices" | |
| 8 | - | "strconv" | |
| 9 | - | "strings" | |
| 10 | 6 | "time" | |
| 11 | 7 | ||
| 12 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -16,87 +12,17 @@ type WindowID = ports.WindowID | |||
| 16 | 12 | ||
| 17 | 13 | 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) { | |
| 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. | |
| 92 | 21 | type Column struct { | |
| 93 | 22 | Windows []WindowID | |
| 94 | - | Width Width | |
| 23 | + | Cells int | |
| 95 | 24 | 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 | |
| 100 | 26 | // Slot is the declared column number (workspace.<name>.column.N) of a | |
| 101 | 27 | // slot window; 0 for normal columns. | |
| 102 | 28 | Slot int | |
| @@ -192,7 +118,6 @@ type Workspace struct { | |||
| 192 | 118 | MaxColumns int | |
| 193 | 119 | // border is drawn around floating windows, inside their rect. | |
| 194 | 120 | border int | |
| 195 | - | presets []Width | |
| 196 | 121 | fullscreen WindowID | |
| 197 | 122 | // left is the window whose fullscreen the user left for another | |
| 198 | 123 | // window: its client requests are refused until it is focused again, | |
| @@ -242,14 +167,13 @@ type Workspace struct { | |||
| 242 | 167 | } | |
| 243 | 168 | ||
| 244 | 169 | // 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 | 171 | type origPlace struct { | |
| 247 | 172 | col, row, slot int | |
| 248 | 173 | // stacked holds the windows left in the column: the window returns to | |
| 249 | 174 | // 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 | |
| 253 | 177 | // fullWidth is the column's maximize-column state. | |
| 254 | 178 | fullWidth bool | |
| 255 | 179 | } | |
| @@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 552 | 476 | } | |
| 553 | 477 | 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). | |
| 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) { | |
| 559 | 484 | if id == 0 || w.has(id) { | |
| 560 | 485 | return | |
| 561 | 486 | } | |
| @@ -567,10 +492,10 @@ func (w *Workspace) addWindow(id WindowID, width Width) { | |||
| 567 | 492 | // A covering float or stashed window may have no column under it. | |
| 568 | 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 | 496 | 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 | 501 | // addColumn places a new column like a new window and focuses it. | |
| @@ -595,7 +520,7 @@ func (w *Workspace) addColumn(col Column) { | |||
| 595 | 520 | // AddSlotWindow places a slot window in its own column, ordered by slot | |
| 596 | 521 | // number before the other columns. Focus stays on the focused window: slot | |
| 597 | 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 | 524 | if id == 0 || w.has(id) { | |
| 600 | 525 | return | |
| 601 | 526 | } | |
| @@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) { | |||
| 606 | 531 | 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 | 535 | if len(w.Columns) > 1 && at <= w.Focus { | |
| 611 | 536 | w.Focus++ | |
| 612 | 537 | } | |
| 613 | 538 | w.scroll() | |
| 614 | 539 | } | |
| 615 | 540 | ||
| 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) { | |
| 618 | 543 | for i := range w.Columns { | |
| 619 | 544 | if w.Columns[i].Slot == n { | |
| 620 | - | w.Columns[i].Width = width | |
| 545 | + | w.Columns[i].Cells = cells | |
| 621 | 546 | } | |
| 622 | 547 | } | |
| 623 | 548 | w.scroll() | |
| @@ -791,8 +716,8 @@ func (w *Workspace) focusColumn(dir int) { | |||
| 791 | 716 | ||
| 792 | 717 | // columnToward returns the column focus-column-left/right (dir -1/1) goes | |
| 793 | 718 | // 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. | |
| 796 | 721 | func (w *Workspace) columnToward(dir int) int { | |
| 797 | 722 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() { | |
| 798 | 723 | return -1 | |
| @@ -939,25 +864,6 @@ func (w *Workspace) MoveWindow(dir int) { | |||
| 939 | 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 | 867 | // minShare is the smallest row height set-window-height leaves, percent. | |
| 962 | 868 | const minShare = 10 | |
| 963 | 869 | ||
| @@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 1038 | 944 | return Column{}, false | |
| 1039 | 945 | } | |
| 1040 | 946 | col := w.Columns[w.Focus] | |
| 1041 | - | col.Slot, col.Expanded = 0, false | |
| 947 | + | col.Slot = 0 | |
| 1042 | 948 | if slices.Contains(col.Windows, w.fullscreen) { | |
| 1043 | 949 | w.fullscreen = 0 | |
| 1044 | 950 | } | |
| @@ -1071,50 +977,168 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 1071 | 977 | w.scroll() | |
| 1072 | 978 | } | |
| 1073 | 979 | ||
| 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() { | |
| 1079 | 988 | return | |
| 1080 | 989 | } | |
| 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 | + | } | |
| 1083 | 1019 | 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 | 1036 | return | |
| 1087 | 1037 | } | |
| 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 | |
| 1094 | 1060 | } | |
| 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 | |
| 1099 | 1089 | } | |
| 1100 | 1090 | } | |
| 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 | |
| 1104 | 1105 | } | |
| 1105 | 1106 | ||
| 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 | |
| 1111 | 1111 | } | |
| 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 | + | } | |
| 1115 | 1132 | } | |
| 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) | |
| 1118 | 1142 | } | |
| 1119 | 1143 | ||
| 1120 | 1144 | // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles. | |
| @@ -1320,7 +1344,6 @@ func (w *Workspace) SetGaps(g int) { | |||
| 1320 | 1344 | w.Gaps = g | |
| 1321 | 1345 | w.scroll() | |
| 1322 | 1346 | } | |
| 1323 | - | func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) } | |
| 1324 | 1347 | func (w *Workspace) SetMaxColumns(n int) { | |
| 1325 | 1348 | w.MaxColumns = n | |
| 1326 | 1349 | w.scroll() | |
| @@ -1457,21 +1480,48 @@ func (w *Workspace) columnWidth(i int) int { | |||
| 1457 | 1480 | } | |
| 1458 | 1481 | ||
| 1459 | 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 | 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 | 1497 | if w.fullscreenColumn(i) { | |
| 1464 | - | return w.Output.W | |
| 1498 | + | return w.Output.W, true | |
| 1465 | 1499 | } | |
| 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) | |
| 1470 | 1518 | } | |
| 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)) | |
| 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 | 1526 | func (w *Workspace) columnX(i int) int { | |
| 1477 | 1527 | x := w.Usable.X + w.gap() | |
| @@ -1542,10 +1592,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 1542 | 1592 | return rects | |
| 1543 | 1593 | } | |
| 1544 | 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 | 1595 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 1550 | 1596 | return rects | |
| 1551 | 1597 | } | |
| @@ -1558,38 +1604,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 1558 | 1604 | 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 | 1607 | // stackRects cuts r in n rows with a gap between them; the last row takes | |
| 1594 | 1608 | // the rounding remainder. Rows past the bottom (gaps taller than r) are | |
| 1595 | 1609 | // clamped to it, like windows stacked in a column. | |
| @@ -1,12 +1,8 @@ | |||||
| 1 | package core | 1 | 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.Rect | 13 | 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}, nil | 20 | + | // 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 []WindowID | 22 | Windows []WindowID | ||
| 94 | - | Width Width | 23 | + | Cells int |
| 95 | FullWidth bool | 24 | 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 a | 26 | // 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 int | 28 | Slot int | ||
| @@ -192,7 +118,6 @@ type Workspace struct { | |||||
| 192 | MaxColumns int | 118 | MaxColumns int | ||
| 193 | // border is drawn around floating windows, inside their rect. | 119 | // border is drawn around floating windows, inside their rect. | ||
| 194 | border int | 120 | border int | ||
| 195 | - | presets []Width | |||
| 196 | fullscreen WindowID | 121 | fullscreen WindowID | ||
| 197 | // left is the window whose fullscreen the user left for another | 122 | // 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 a | 169 | // 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 int | 172 | col, row, slot int | ||
| 248 | // stacked holds the windows left in the column: the window returns to | 173 | // 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 []WindowID | 175 | + | stacked []WindowID |
| 251 | - | width Width | 176 | + | 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 bool | 178 | fullWidth bool | ||
| 255 | } | 179 | } | ||
| @@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||||
| 552 | } | 476 | } | ||
| 553 | return c.Windows[c.Focus], true | 477 | 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 | return | 485 | 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 | return | 496 | 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 slot | 520 | // AddSlotWindow places a slot window in its own column, ordered by slot | ||
| 596 | // number before the other columns. Focus stays on the focused window: slot | 521 | // 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 | return | 525 | return | ||
| 601 | } | 526 | } | ||
| @@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) { | |||||
| 606 | at = i + 1 | 531 | 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 = width | 545 | + | 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) goes | 717 | // 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; fixed | 718 | // to, or -1 at the edge. Scroll overflow follows the column order; fixed | ||
| 794 | - | // overflow stacks columns (spiral, expanded strips), so it follows the | 719 | + | // 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 -1 | 723 | 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 = 10 | 868 | const minShare = 10 | ||
| 963 | 869 | ||||
| @@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 1038 | return Column{}, false | 944 | return Column{}, false | ||
| 1039 | } | 945 | } | ||
| 1040 | col := w.Columns[w.Focus] | 946 | col := w.Columns[w.Focus] | ||
| 1041 | - | col.Slot, col.Expanded = 0, false | 947 | + | col.Slot = 0 |
| 1042 | if slices.Contains(col.Windows, w.fullscreen) { | 948 | if slices.Contains(col.Windows, w.fullscreen) { | ||
| 1043 | w.fullscreen = 0 | 949 | 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 overflow | 980 | + | // GrowColumn widens the focused column by one cell, taking it from another |
| 1075 | - | // ignores presets: it toggles the expanded column instead; cascade does | 981 | + | // 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 | return | 988 | 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 | return | 1019 | 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 | return | 1036 | return | ||
| 1087 | } | 1037 | } | ||
| 1088 | - | // auto → presets in order → auto; a width set by set-column-width | 1038 | + | 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 | - | break | 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 | |||
| 1099 | } | 1089 | } | ||
| 1100 | } | 1090 | } | ||
| 1101 | - | c.Width = next | 1091 | + | 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 it | 1107 | + | // 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].Expanded | 1112 | + | } |
| 1113 | - | for i := range w.Columns { | 1113 | + | |
| 1114 | - | w.Columns[i].Expanded = false | 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 | + | } | |||
| 1115 | } | 1132 | } | ||
| 1116 | - | w.Columns[w.Focus].Expanded = on | 1133 | + | 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 = g | 1344 | 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 = n | 1348 | 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.W | 1498 | + | 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 rects | 1592 | 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 rects | 1596 | return rects | ||
| 1551 | } | 1597 | } | ||
| @@ -1558,38 +1604,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||||
| 1558 | return rects | 1604 | 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 takes | 1607 | // 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) are | 1608 | // 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 ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 15 | // a fullscreen window, a hidden neighbor workspace, the overview and a slide | |
| 16 | 16 | // toward a neighbor workspace. | |
| 17 | 17 | func layoutVariants(t *testing.T) map[string]*Monitor { | |
| @@ -32,9 +32,9 @@ func layoutVariants(t *testing.T) map[string]*Monitor { | |||
| 32 | 32 | v["fixed"] = base(OverflowFixed, 5) | |
| 33 | 33 | v["fixed-spiral"] = base(OverflowFixed, 7) | |
| 34 | 34 | ||
| 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 | |
| 38 | 38 | ||
| 39 | 39 | m = base(OverflowScroll, 4) | |
| 40 | 40 | w := m.Current() | |
| @@ -78,12 +78,12 @@ func layoutVariants(t *testing.T) map[string]*Monitor { | |||
| 78 | 78 | // The variants must hold what they are named for, on the workspace on | |
| 79 | 79 | // screen (the one whose scratch the tests exercise). | |
| 80 | 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 }, | |
| 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 }, | |
| 87 | 87 | } { | |
| 88 | 88 | if w := v[name].Current(); len(w.Columns) == 0 || !has(w) { | |
| 89 | 89 | 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 slide | 15 | // 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 = true | 36 | + | m.Current().Columns[1].Cells = 2 |
| 37 | - | v["fixed-expanded"] = m | 37 | + | 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 on | 78 | // 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 { | |||
| 15 | 15 | w.SetMaxColumns(2) | |
| 16 | 16 | return w | |
| 17 | 17 | } | |
| 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 | |
| 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 { | |
| 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 | + | }) | |
| 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 | 101 | func TestOperations(t *testing.T) { | |
| 37 | 102 | w := workspace() | |
| 38 | 103 | w.FocusColumn(1) | |
| 39 | 104 | w.FocusWindow(-1) | |
| 40 | 105 | w.MoveColumn(1) | |
| 41 | - | w.CycleWidth() | |
| 106 | + | w.GrowColumn(true) | |
| 42 | 107 | w.ToggleFullscreen() | |
| 43 | 108 | w.RemoveWindow(1) | |
| 44 | 109 | if _, ok := w.Focused(); ok { | |
| @@ -112,17 +177,17 @@ func TestMaximizeColumnScroll(t *testing.T) { | |||
| 112 | 177 | for id := WindowID(1); id <= 3; id++ { | |
| 113 | 178 | w.AddWindow(id) | |
| 114 | 179 | } | |
| 115 | - | w.Columns[1].Width = Width{Pixels: 30} | |
| 180 | + | w.Columns[1].Cells = 2 | |
| 116 | 181 | w.FocusID(2) | |
| 117 | 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 | 184 | t.Fatal(w.Columns, w.Layout()) | |
| 120 | 185 | } | |
| 121 | 186 | if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen { | |
| 122 | 187 | t.Fatal(w.Layout()) | |
| 123 | 188 | } | |
| 124 | 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 | 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 | 228 | w := workspace() | |
| 164 | 229 | w.AddWindow(1) | |
| 165 | - | p := []Width{{Pixels: 30}, {Pixels: 40}} | |
| 166 | - | w.SetPresets(p) | |
| 167 | - | p[0].Pixels = 99 | |
| 168 | 230 | w.AddWindow(2) | |
| 231 | + | // Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto. | |
| 169 | 232 | w.Apply(ActionCycleColumnWidth) | |
| 170 | - | if w.Columns[1].Width.Pixels != 30 { | |
| 233 | + | if w.Columns[1].Cells != 2 { | |
| 171 | 234 | t.Fatal(w.Columns) | |
| 172 | 235 | } | |
| 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 { | |
| 180 | 238 | t.Fatal(w.Columns) | |
| 181 | 239 | } | |
| 182 | 240 | w.RemoveWindow(2) | |
| @@ -188,18 +246,11 @@ func TestPresetsAndActions(t *testing.T) { | |||
| 188 | 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 | 250 | func FuzzWorkspaceOps(f *testing.F) { | |
| 199 | 251 | f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) | |
| 200 | 252 | f.Fuzz(func(t *testing.T, ops []byte) { | |
| 201 | 253 | w := workspace() | |
| 202 | - | w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}}) | |
| 203 | 254 | next := WindowID(1) | |
| 204 | 255 | for _, op := range ops { | |
| 205 | 256 | switch op % 18 { | |
| @@ -223,7 +274,7 @@ func FuzzWorkspaceOps(f *testing.F) { | |||
| 223 | 274 | case 7: | |
| 224 | 275 | w.MoveColumn(1) | |
| 225 | 276 | case 8: | |
| 226 | - | w.CycleWidth() | |
| 277 | + | w.GrowColumn(op%2 == 0) | |
| 227 | 278 | case 9: | |
| 228 | 279 | w.ToggleFullscreen() | |
| 229 | 280 | case 10: | |
| @@ -240,7 +291,7 @@ func FuzzWorkspaceOps(f *testing.F) { | |||
| 240 | 291 | } | |
| 241 | 292 | case 15: | |
| 242 | 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 | 295 | w.scroll() | |
| 245 | 296 | } | |
| 246 | 297 | case 16: | |
| @@ -324,20 +375,6 @@ func stackFocused(w *Workspace) { | |||
| 324 | 375 | } | |
| 325 | 376 | ||
| 326 | 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 | 378 | w := workspace() | |
| 342 | 379 | w.AddWindow(1) | |
| 343 | 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 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) { | |
| 447 | 484 | rects := func(w *Workspace) map[WindowID]Rect { | |
| 448 | 485 | got := map[WindowID]Rect{} | |
| 449 | 486 | for id, p := range placements(w) { | |
| @@ -460,70 +497,67 @@ func TestFixedExpandColumn(t *testing.T) { | |||
| 460 | 497 | t.Run("free cell", func(t *testing.T) { | |
| 461 | 498 | w := fixedWorkspace(4, 0, 2) | |
| 462 | 499 | w.FocusID(1) | |
| 463 | - | w.CycleWidth() | |
| 500 | + | w.GrowColumn(true) | |
| 464 | 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 | 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) { | |
| 469 | - | w := fixedWorkspace(4, 0, 4) | |
| 505 | + | t.Run("middle column", func(t *testing.T) { | |
| 506 | + | w := fixedWorkspace(4, 0, 3) | |
| 470 | 507 | 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}}) | |
| 478 | 514 | }) | |
| 479 | 515 | t.Run("with gaps", func(t *testing.T) { | |
| 480 | - | w := fixedWorkspace(2, 4, 3) | |
| 516 | + | w := fixedWorkspace(4, 4, 2) | |
| 481 | 517 | 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. | |
| 484 | 520 | 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}, | |
| 488 | 523 | }) | |
| 489 | 524 | }) | |
| 490 | 525 | t.Run("stays on focus change and new windows", func(t *testing.T) { | |
| 491 | 526 | w := fixedWorkspace(4, 0, 2) | |
| 492 | 527 | w.FocusID(1) | |
| 493 | - | w.CycleWidth() | |
| 528 | + | w.GrowColumn(true) | |
| 494 | 529 | w.FocusColumn(1) | |
| 495 | 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 | 535 | w := fixedWorkspace(4, 0, 2) | |
| 500 | 536 | w.FocusID(1) | |
| 501 | - | w.CycleWidth() | |
| 537 | + | w.GrowColumn(true) | |
| 502 | 538 | 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}}) | |
| 505 | 541 | }) | |
| 506 | 542 | t.Run("closing it shares again", func(t *testing.T) { | |
| 507 | - | w := fixedWorkspace(3, 0, 3) | |
| 543 | + | w := fixedWorkspace(4, 0, 3) | |
| 508 | 544 | w.FocusID(1) | |
| 509 | - | w.CycleWidth() | |
| 545 | + | w.GrowColumn(true) | |
| 510 | 546 | w.RemoveWindow(1) | |
| 511 | 547 | check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}}) | |
| 512 | 548 | }) | |
| 513 | 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 | 551 | before := rects(w) | |
| 516 | - | w.CycleWidth() | |
| 552 | + | w.GrowColumn(true) | |
| 517 | 553 | check(t, w, before) | |
| 518 | 554 | } | |
| 519 | 555 | }) | |
| 520 | 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)} { | |
| 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) | |
| 525 | 559 | w.FocusID(2) | |
| 526 | - | w.CycleWidth() | |
| 560 | + | w.GrowColumn(true) | |
| 527 | 561 | for id, p := range placements(w) { | |
| 528 | 562 | r := p.Rect | |
| 529 | 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 | 569 | t.Run("maximize wins and gives it back", func(t *testing.T) { | |
| 536 | 570 | w := fixedWorkspace(4, 0, 2) | |
| 537 | 571 | w.FocusID(1) | |
| 538 | - | w.CycleWidth() | |
| 572 | + | w.GrowColumn(true) | |
| 539 | 573 | w.ToggleFullWidth() | |
| 540 | 574 | if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden { | |
| 541 | 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 | 583 | func TestFixedFocusStackedColumns(t *testing.T) { | |
| 550 | 584 | focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id } | |
| 551 | 585 | t.Run("spiral", func(t *testing.T) { | |
| @@ -585,18 +619,21 @@ func TestFixedFocusStackedColumns(t *testing.T) { | |||
| 585 | 619 | t.Fatal("right edge has a neighbor") | |
| 586 | 620 | } | |
| 587 | 621 | }) | |
| 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 | |
| 593 | 626 | 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)) | |
| 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 | 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 | 694 | w := fixedWorkspace(2, 0, 2) | |
| 658 | - | w.SetPresets([]Width{{Num: 2, Den: 3}}) | |
| 659 | - | w.CycleWidth() | |
| 695 | + | w.GrowColumn(true) | |
| 660 | 696 | w.FocusColumn(-1) | |
| 661 | - | w.CycleWidth() | |
| 697 | + | w.GrowColumn(true) | |
| 662 | 698 | for id, p := range placements(w) { | |
| 663 | 699 | if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 { | |
| 664 | 700 | t.Fatal(id, p) | |
| @@ -15,30 +15,95 @@ func workspace() *Workspace { | |||||
| 15 | w.SetMaxColumns(2) | 15 | w.SetMaxColumns(2) | ||
| 16 | return w | 16 | 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 string | 20 | + | // share what the others leave. |
| 21 | - | ok bool | 21 | + | func TestLaneWidths(t *testing.T) { |
| 22 | - | pixels int | 22 | + | 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 over | 481 | + | // 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.Rect | 562 | 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) { | |||
| 126 | 126 | // (clamped above the trailing empty workspace). It takes this monitor's | |
| 127 | 127 | // output-wide settings and does not change what is on screen. | |
| 128 | 128 | func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | |
| 129 | - | w.presets = append([]Width(nil), m.template.presets...) | |
| 130 | 129 | w.Gaps = m.template.Gaps | |
| 131 | 130 | w.border = m.template.border | |
| 132 | 131 | w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap | |
| @@ -164,7 +163,6 @@ func (m *Monitor) Current() *Workspace { | |||
| 164 | 163 | func (m *Monitor) newWorkspace() *Workspace { | |
| 165 | 164 | w := m.template | |
| 166 | 165 | w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil | |
| 167 | - | w.presets = append([]Width(nil), m.template.presets...) | |
| 168 | 166 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, "" | |
| 169 | 167 | w.overviewAfter = nil | |
| 170 | 168 | 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 | 303 | // AddWindow places a new window on the workspace on screen; with | |
| 306 | 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). | |
| 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) { | |
| 311 | 310 | if w, _ := m.find(id); w != nil { | |
| 312 | 311 | return | |
| 313 | 312 | } | |
| 314 | 313 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | |
| 315 | - | w.addWindow(id, width) | |
| 314 | + | w.addWindow(id, cells) | |
| 316 | 315 | } | |
| 317 | 316 | m.normalize() | |
| 318 | 317 | } | |
| @@ -502,8 +501,6 @@ func (m *Monitor) SetStash(width, gap int) { | |||
| 502 | 501 | // SetStashCapture makes new windows join the shown stash (stash.capture). | |
| 503 | 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 | 504 | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | |
| 508 | 505 | func (m *Monitor) SetFollowMove(on bool) { m.followMove = on } | |
| 509 | 506 | ||
| @@ -126,7 +126,6 @@ func (m *Monitor) take(w *Workspace) { | |||||
| 126 | // (clamped above the trailing empty workspace). It takes this monitor's | 126 | // (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.Gaps | 129 | w.Gaps = m.template.Gaps | ||
| 131 | w.border = m.template.border | 130 | w.border = m.template.border | ||
| 132 | w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap | 131 | 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.template | 164 | w := m.template | ||
| 166 | w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil | 165 | 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 = nil | 167 | w.overviewAfter = nil | ||
| 170 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | 168 | 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; with | 303 | // 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 | return | 311 | 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) { | |||
| 199 | 199 | if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 { | |
| 200 | 200 | t.Fatal(w.View) | |
| 201 | 201 | } | |
| 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. | |
| 204 | 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 | 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 | 298 | m := monitor() | |
| 299 | - | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 300 | 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 | 302 | if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 { | |
| 303 | 303 | t.Fatal(p) | |
| 304 | 304 | } | |
| @@ -475,13 +475,14 @@ func TestMonitorAllAllocations(t *testing.T) { | |||
| 475 | 475 | // exit restores the tiles with the user's focus where it was. | |
| 476 | 476 | func TestFixedFullscreenStaysInPlace(t *testing.T) { | |
| 477 | 477 | m := monitor() | |
| 478 | + | m.SetMaxColumns(4) | |
| 478 | 479 | m.SetOverflow(OverflowFixed) | |
| 479 | 480 | for id := WindowID(1); id <= 3; id++ { | |
| 480 | 481 | m.AddWindow(id) | |
| 481 | 482 | } | |
| 482 | 483 | w := m.Current() | |
| 483 | 484 | w.FocusID(2) | |
| 484 | - | m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen | |
| 485 | + | m.Apply(ActionCycleColumnWidth) // wider: kept through fullscreen | |
| 485 | 486 | m.ToggleFullscreen() | |
| 486 | 487 | m.AddWindow(4) | |
| 487 | 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 | 492 | t.Fatal("focus", id) | |
| 492 | 493 | } | |
| 493 | 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 | 496 | t.Fatal("after bind", id, w.Columns) | |
| 496 | 497 | } | |
| 497 | 498 | } | |
| @@ -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 fullscreen | 485 | + | 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) { | |||
| 156 | 156 | // Columns narrower than the view: one point. | |
| 157 | 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 | 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 | 164 | if got := w.snapPoints(); !slices.Equal(got, tc.want) { | |
| 164 | 165 | 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) { | |||
| 467 | 467 | // Fixed overflow previews use the actual buffer geometry, including the | |
| 468 | 468 | // spiral and window stacks, rather than laying full-height columns in a row. | |
| 469 | 469 | 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) { | |
| 472 | 472 | m := newMonitor("", "") | |
| 473 | 473 | m.SetOutput(600, 400) | |
| 474 | 474 | m.SetOverflow(OverflowFixed) | |
| @@ -477,8 +477,9 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) { | |||
| 477 | 477 | m.AddWindow(id) | |
| 478 | 478 | } | |
| 479 | 479 | 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 | |
| 482 | 483 | } | |
| 483 | 484 | w.Columns[3].Windows = append(w.Columns[3].Windows, 6) | |
| 484 | 485 | w.Columns[3].Focus = 1 | |
| @@ -467,8 +467,8 @@ func TestOverviewPileOnly(t *testing.T) { | |||||
| 467 | // Fixed overflow previews use the actual buffer geometry, including the | 467 | // 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 = true | 481 | + | // 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 = 1 | 485 | w.Columns[3].Focus = 1 | ||
M internal/core/rules.go +9 -12
| @@ -29,7 +29,7 @@ type rule struct { | |||
| 29 | 29 | number int | |
| 30 | 30 | workspace string | |
| 31 | 31 | monitor string | |
| 32 | - | width Width // zero: automatic | |
| 32 | + | cells int // zero: automatic | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // ruleEffect is the merge of every rule matching a window: per field, the | |
| @@ -39,7 +39,7 @@ type ruleEffect struct { | |||
| 39 | 39 | number int | |
| 40 | 40 | workspace string | |
| 41 | 41 | monitor string | |
| 42 | - | width Width // zero: automatic | |
| 42 | + | cells int // zero: automatic | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func parseRules(rs []ports.WindowRule) ([]rule, error) { | |
| @@ -59,13 +59,10 @@ func parseRules(rs []ports.WindowRule) ([]rule, error) { | |||
| 59 | 59 | p.workspace = r.Workspace | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 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) | |
| 68 | 64 | } | |
| 65 | + | p.cells = r.Cells | |
| 69 | 66 | out = append(out, p) | |
| 70 | 67 | } | |
| 71 | 68 | return out, nil | |
| @@ -93,8 +90,8 @@ func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) { | |||
| 93 | 90 | if r.monitor != "" { | |
| 94 | 91 | eff.monitor = r.monitor | |
| 95 | 92 | } | |
| 96 | - | if r.width != (Width{}) { | |
| 97 | - | eff.width = r.width | |
| 93 | + | if r.cells != 0 { | |
| 94 | + | eff.cells = r.cells | |
| 98 | 95 | } | |
| 99 | 96 | } | |
| 100 | 97 | return eff, ok | |
| @@ -140,9 +137,9 @@ func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) { | |||
| 140 | 137 | // floated by a rule becomes a free float, core sizing it. | |
| 141 | 138 | w.addFloat(w.ruleFloat(v), quiet) | |
| 142 | 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 | 141 | default: | |
| 145 | - | sc.mon.addWindow(v.ID, eff.width) | |
| 142 | + | sc.mon.addWindow(v.ID, eff.cells) | |
| 146 | 143 | return | |
| 147 | 144 | } | |
| 148 | 145 | sc.mon.normalize() | |
| @@ -29,7 +29,7 @@ type rule struct { | |||||
| 29 | number int | 29 | number int | ||
| 30 | workspace string | 30 | workspace string | ||
| 31 | monitor string | 31 | monitor string | ||
| 32 | - | width Width // zero: automatic | 32 | + | cells int // zero: automatic |
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | // ruleEffect is the merge of every rule matching a window: per field, the | 35 | // ruleEffect is the merge of every rule matching a window: per field, the | ||
| @@ -39,7 +39,7 @@ type ruleEffect struct { | |||||
| 39 | number int | 39 | number int | ||
| 40 | workspace string | 40 | workspace string | ||
| 41 | monitor string | 41 | monitor string | ||
| 42 | - | width Width // zero: automatic | 42 | + | 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.Workspace | 59 | 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, nil | 68 | 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.monitor | 91 | eff.monitor = r.monitor | ||
| 95 | } | 92 | } | ||
| 96 | - | if r.width != (Width{}) { | 93 | + | if r.cells != 0 { |
| 97 | - | eff.width = r.width | 94 | + | eff.cells = r.cells |
| 98 | } | 95 | } | ||
| 99 | } | 96 | } | ||
| 100 | return eff, ok | 97 | 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 | return | 143 | 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) { | |||
| 225 | 225 | r := startMulti(t, func(c *ports.Config) { | |
| 226 | 226 | c.Layout.MaxColumns = 4 | |
| 227 | 227 | 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 }), | |
| 230 | 230 | } | |
| 231 | 231 | }, left, right) | |
| 232 | 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 | 260 | func TestRuleWidthOnCurrentWorkspace(t *testing.T) { | |
| 261 | 261 | r := startMulti(t, func(c *ports.Config) { | |
| 262 | 262 | 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 | 264 | }, left) | |
| 265 | 265 | r.client <- ports.WindowMapped{ID: 1, AppID: "wide"} | |
| 266 | 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 | 272 | func TestSlotWinsOverRule(t *testing.T) { | |
| 273 | 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 | 275 | c.Binds["Alt+d"] = "workspace dev" | |
| 276 | 276 | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })} | |
| 277 | 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 | 402 | r := startMulti(t, func(c *ports.Config) { | |
| 401 | - | c.Layout.MaxColumns = 2 | |
| 403 | + | c.Layout.MaxColumns = 4 | |
| 402 | 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 | 406 | }, left) | |
| 405 | 407 | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |
| 406 | 408 | r.client <- ports.WindowMapped{ID: 2, AppID: "wide"} | |
| 407 | 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 | 411 | t.Fatalf("%+v", s.Windows) | |
| 410 | 412 | } | |
| 411 | 413 | } | |
| @@ -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 = 4 | 226 | 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 = 4 | 262 | 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 = 2 | 403 | + | 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) { | |||
| 40 | 40 | // same: the key's scene draws what the previous one did. | |
| 41 | 41 | 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 | 47 | func(t *testing.T, r *swipeRig) ports.Scene { | |
| 45 | 48 | twoColumns(t, r) | |
| 46 | 49 | 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 bool | 41 | 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) { | |||
| 524 | 524 | } | |
| 525 | 525 | m.SetOverflow(Overflow(rules.Overflow)) | |
| 526 | 526 | m.SetMaxColumns(rules.MaxColumns) | |
| 527 | - | m.SetPresets(c.presets) | |
| 528 | 527 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 529 | 528 | m.SetBorder(c.cfg.Border.Width) | |
| 530 | 529 | 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 { | |||
| 26 | 26 | ||
| 27 | 27 | type slotState struct { | |
| 28 | 28 | argv []string | |
| 29 | - | width Width | |
| 29 | + | cells int | |
| 30 | 30 | window WindowID // 0 while empty | |
| 31 | 31 | // stale is set when the workspace is shown while the spawn is pending. | |
| 32 | 32 | // Shown again still pending, the command is deemed failed and respawned; | |
| @@ -38,21 +38,20 @@ type slotState struct { | |||
| 38 | 38 | type slotSpec struct { | |
| 39 | 39 | key slotKey | |
| 40 | 40 | argv []string | |
| 41 | - | width Width | |
| 41 | + | cells int | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) { | |
| 45 | 45 | var specs []slotSpec | |
| 46 | 46 | for _, w := range ws { | |
| 47 | 47 | 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) | |
| 51 | 50 | } | |
| 52 | 51 | if len(s.Argv) == 0 || s.Index < 1 { | |
| 53 | 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 | 57 | return specs, nil | |
| @@ -81,14 +80,14 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 81 | 80 | for _, s := range specs { | |
| 82 | 81 | st, ok := c.slots[s.key] | |
| 83 | 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 | 84 | spawn = append(spawn, s.key) | |
| 86 | 85 | continue | |
| 87 | 86 | } | |
| 88 | - | if st.width != s.width { | |
| 89 | - | st.width = s.width | |
| 87 | + | if st.cells != s.cells { | |
| 88 | + | st.cells = s.cells | |
| 90 | 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 | 93 | 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 []string | 28 | argv []string | ||
| 29 | - | width Width | 29 | + | cells int |
| 30 | window WindowID // 0 while empty | 30 | 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 slotKey | 39 | key slotKey | ||
| 40 | argv []string | 40 | argv []string | ||
| 41 | - | width Width | 41 | + | 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 []slotSpec | 45 | 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, nil | 57 | 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 | continue | 85 | continue | ||
| 87 | } | 86 | } | ||
| 88 | - | if st.width != s.width { | 87 | + | if st.cells != s.cells { |
| 89 | - | st.width = s.width | 88 | + | 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 { | |||
| 45 | 45 | cfg.Animations.On = *animated | |
| 46 | 46 | } | |
| 47 | 47 | 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 | 50 | 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"}}, | |
| 51 | 53 | }}} | |
| 52 | 54 | cfg.Binds["Alt+d"] = "workspace dev" | |
| 53 | 55 | if len(numbered) > 1 { | |
| @@ -285,9 +287,9 @@ func TestSlotReload(t *testing.T) { | |||
| 285 | 287 | cfg := r.cfg | |
| 286 | 288 | ws := cfg.Workspaces[0] | |
| 287 | 289 | 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 | |
| 291 | 293 | } | |
| 292 | 294 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 293 | 295 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| @@ -329,7 +331,7 @@ func TestInvalidSlotWidthRejected(t *testing.T) { | |||
| 329 | 331 | receive(t, r.spawn) | |
| 330 | 332 | cfg := r.cfg | |
| 331 | 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 | 335 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 334 | 336 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 335 | 337 | if err := receive(t, r.errs); err == nil { | |
| @@ -346,7 +348,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) { | |||
| 346 | 348 | r.press(t, "d") | |
| 347 | 349 | cfg := r.cfg | |
| 348 | 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 | 352 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 351 | 353 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 352 | 354 | 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 = *animated | 45 | cfg.Animations.On = *animated | ||
| 46 | } | 46 | } | ||
| 47 | cfg.Border.Width = 0 | 47 | 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.cfg | 287 | 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 only | 290 | + | {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only |
| 289 | - | {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command | 291 | + | {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command |
| 290 | - | {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot | 292 | + | {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.cfg | 332 | 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.cfg | 349 | 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) { | |||
| 108 | 108 | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| 109 | 109 | p.dropSlot(target.slot) | |
| 110 | 110 | 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 | 112 | sc.mon.normalize() | |
| 113 | 113 | return | |
| 114 | 114 | } | |
| @@ -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.ID | 110 | 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 | return | 113 | return | ||
| 114 | } | 114 | } | ||
M internal/core/stash.go +4 -6
| @@ -51,7 +51,7 @@ func (w *Workspace) stashWindow(id WindowID) { | |||
| 51 | 51 | func (w *Workspace) tilePlace(id WindowID) *origPlace { | |
| 52 | 52 | c := w.Columns[w.Focus] | |
| 53 | 53 | 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 | 55 | for _, v := range c.Windows { | |
| 56 | 56 | if v != id { | |
| 57 | 57 | back.stacked = append(back.stacked, v) | |
| @@ -139,10 +139,8 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 139 | 139 | w.scroll() | |
| 140 | 140 | return | |
| 141 | 141 | } | |
| 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}) | |
| 146 | 144 | if back.fullWidth { | |
| 147 | 145 | w.maximize(w.Focus) | |
| 148 | 146 | } | |
| @@ -153,7 +151,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 153 | 151 | func (f Float) rehome() Float { | |
| 154 | 152 | back := &origPlace{col: math.MaxInt} | |
| 155 | 153 | if f.back != nil { | |
| 156 | - | back.width = f.back.width | |
| 154 | + | back.cells = f.back.cells | |
| 157 | 155 | } | |
| 158 | 156 | f.back = back | |
| 159 | 157 | 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) == 1 | 53 | 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 | return | 140 | return | ||
| 141 | } | 141 | } | ||
| 142 | - | // Its column is gone: it comes back expanded, unless another column | 142 | + | // 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.width | 154 | + | back.cells = f.back.cells |
| 157 | } | 155 | } | ||
| 158 | f.back = back | 156 | f.back = back | ||
| 159 | return f | 157 | return f | ||
M internal/core/switcher_test.go +1 -1
| @@ -637,7 +637,7 @@ func TestSwitcherLayoutCards(t *testing.T) { | |||
| 637 | 637 | c, _, _ := switcherCore(t, 3) | |
| 638 | 638 | c.cfg.Floating.Dim = 0.4 | |
| 639 | 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 | 641 | indicatorScene(t, c) | |
| 642 | 642 | area := c.cur().mon.Current().overviewArea() | |
| 643 | 643 | 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.4 | 638 | 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) { | |||
| 482 | 482 | // A width change animates the frame while the client gets one configure to | |
| 483 | 483 | // the final size. | |
| 484 | 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 | 489 | twoColumns(t, r) | |
| 487 | 490 | r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll | |
| 488 | 491 | for len(r.commands) > 0 { | |
| @@ -497,18 +500,18 @@ func TestSetColumnWidthRectTransition(t *testing.T) { | |||
| 497 | 500 | t.Fatalf("window 2 at %d right after the key, want the old 400", got.X) | |
| 498 | 501 | } | |
| 499 | 502 | 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) | |
| 502 | 505 | } | |
| 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) | |
| 505 | 508 | } | |
| 506 | 509 | 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) | |
| 509 | 512 | } | |
| 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) | |
| 512 | 515 | } | |
| 513 | 516 | var widths []int | |
| 514 | 517 | for len(r.commands) > 0 { | |
| @@ -516,8 +519,8 @@ func TestSetColumnWidthRectTransition(t *testing.T) { | |||
| 516 | 519 | widths = append(widths, v.Width) | |
| 517 | 520 | } | |
| 518 | 521 | } | |
| 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) | |
| 521 | 524 | } | |
| 522 | 525 | } | |
| 523 | 526 | ||
| @@ -594,12 +597,16 @@ func TestChainedMovesStartFromShownRects(t *testing.T) { | |||
| 594 | 597 | // after the click still shows the old view, a frame an intermediate one and | |
| 595 | 598 | // the settled scene the new one. | |
| 596 | 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 | 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 | 607 | 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) | |
| 603 | 610 | } | |
| 604 | 611 | // The release of the modifiers: a plain click, not a window drag. | |
| 605 | 612 | r.input <- ports.KeyEvent{Keysym: "Alt_L"} | |
| @@ -607,12 +614,12 @@ func TestClickFocusTransition(t *testing.T) { | |||
| 607 | 614 | r.drain() | |
| 608 | 615 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 609 | 616 | 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) | |
| 612 | 619 | } | |
| 613 | 620 | 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) | |
| 616 | 623 | } | |
| 617 | 624 | if got := xOf(t, r.settle(t), 2); got != 0 { | |
| 618 | 625 | 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 to | 482 | // 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 scroll | 490 | 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 []int | 516 | 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 and | 597 | // 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) { | |||
| 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 | 72 | w := workspace() | |
| 71 | 73 | w.SetOutput(1000, 800) | |
| 72 | 74 | w.SetGaps(10) | |
| 75 | + | w.SetMaxColumns(4) | |
| 76 | + | w.Overflow = OverflowFixed | |
| 73 | 77 | w.AddWindow(1) | |
| 74 | 78 | w.AddWindow(2) | |
| 75 | 79 | 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) | |
| 112 | 102 | } | |
| 113 | 103 | } | |
| 114 | 104 | ||
| 115 | - | func TestResizeColumnUnmaximizesAndCycles(t *testing.T) { | |
| 105 | + | func TestSetColumnWidthScroll(t *testing.T) { | |
| 116 | 106 | w := workspace() | |
| 117 | 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 | 110 | 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) | |
| 124 | 121 | } | |
| 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) | |
| 129 | 127 | } | |
| 130 | 128 | } | |
| 131 | 129 | ||
| 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 | + | } | |
| 141 | 141 | } | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | func TestColumnEditsUnderFullscreen(t *testing.T) { | |
| 145 | 145 | w := workspace() | |
| 146 | 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 | 148 | w.SetFullscreen(1, true) | |
| 149 | - | w.Apply("set-column-width -10%") | |
| 149 | + | w.Apply("set-column-width -1") | |
| 150 | + | w.Apply(ActionCycleColumnWidth) | |
| 150 | 151 | w.Apply(ActionMoveWindowDown) | |
| 151 | 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 | 154 | t.Fatal(c) | |
| 154 | 155 | } | |
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | - | func TestResizeColumnFixedNoop(t *testing.T) { | |
| 158 | + | func TestSetColumnWidthFixedPastMax(t *testing.T) { | |
| 158 | 159 | w := workspace() | |
| 159 | 160 | 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 | + | } | |
| 164 | 171 | } | |
| 165 | 172 | } | |
| 166 | 173 | ||
| @@ -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 then | 100 | + | 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 = OverflowFixed | 160 | 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 { | |||
| 134 | 134 | Inactive string | |
| 135 | 135 | } | |
| 136 | 136 | Layout struct { | |
| 137 | - | Gaps int | |
| 138 | - | Presets []string | |
| 137 | + | Gaps int | |
| 139 | 138 | // LayoutRules are the defaults; Outputs override them per screen and | |
| 140 | 139 | // named workspaces override them again. | |
| 141 | 140 | LayoutRules | |
| @@ -254,10 +253,11 @@ func SplitCommands(v string) [][]string { | |||
| 254 | 253 | ||
| 255 | 254 | // SlotConfig reserves column N of a workspace for the window of one command. | |
| 256 | 255 | // 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 | 258 | type SlotConfig struct { | |
| 259 | 259 | Index int | |
| 260 | - | Width string | |
| 260 | + | Cells int | |
| 261 | 261 | Argv []string | |
| 262 | 262 | } | |
| 263 | 263 | ||
| @@ -274,6 +274,7 @@ type WindowRule struct { | |||
| 274 | 274 | Workspace string | |
| 275 | 275 | // Monitor is a connector (DP-2) or a monitor key ("make model serial"). | |
| 276 | 276 | 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 | |
| 279 | 280 | } | |
| @@ -134,8 +134,7 @@ type Config struct { | |||||
| 134 | Inactive string | 134 | Inactive string | ||
| 135 | } | 135 | } | ||
| 136 | Layout struct { | 136 | Layout struct { | ||
| 137 | - | Gaps int | 137 | + | Gaps int |
| 138 | - | Presets []string | |||
| 139 | // LayoutRules are the defaults; Outputs override them per screen and | 138 | // LayoutRules are the defaults; Outputs override them per screen and | ||
| 140 | // named workspaces override them again. | 139 | // named workspaces override them again. | ||
| 141 | LayoutRules | 140 | 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 the | 255 | // 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 int | 259 | Index int | ||
| 260 | - | Width string | 260 | + | Cells int |
| 261 | Argv []string | 261 | Argv []string | ||
| 262 | } | 262 | } | ||
| 263 | 263 | ||||
| @@ -274,6 +274,7 @@ type WindowRule struct { | |||||
| 274 | Workspace string | 274 | 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 string | 276 | 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 string | 278 | + | // (1 to 16; 0 leaves it automatic). |
| 279 | + | Cells int | |||
| 279 | } | 280 | } | ||