detached a67deb8

Merge pull request 'feat(core): give a maximized cascade column its own band' (#43) from feat/cascade-cell-bands into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/43
commit
a67deb897aefb6c95795d1e73aa377b60ca03b4a
author
bnema · · 2026-10-13 14:11 UTC
merge of
5385c18 + d4955b9 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
15 files · +234 -458
M docs/config.md +6 -6
@@ -227,13 +227,13 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
227227
228228 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.
229229
230-Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go first to the previous or next occupied workspace; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.
230+Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go first to the previous or next occupied workspace; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, 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. A maximized column takes a band of its own: the columns after it continue in the following bands, and unmaximizing packs them back. It stays maximized while focus moves.
231231
232232 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.
233233
234-In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.
234+In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot.
235235
236-In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the previous or next occupied workspace at the band's edge, then to the neighbor monitor, then to the trailing empty workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
236+In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the previous or next occupied workspace at the band's edge, then to the neighbor monitor, then to the trailing empty workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
237237
238238 Directional focus past the edge of the content switches workspace along the layout's workspace axis: up/down in scroll and fixed overflow, left/right in cascade.
239239
@@ -263,9 +263,9 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
263263 | `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 |
264264 | `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. An activation of a stashed window (an app asking for attention, a taskbar click) shows the stash over the fullscreen window the same way. New windows never join or show over it; dialogs of the fullscreen window or of stashed windows, and their own dialogs, show above it |
265265 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
266-| `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 |
266+| `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. Outside cascade, a maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
267267 | `switch-column-prev` | The same, in the opposite direction |
268-| `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 |
268+| `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; in cascade, the column takes a band of its own. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
269269 | `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 |
270270 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it comes the previous or next occupied workspace, then the neighbor monitor, then the trailing empty workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
271271 | `focus-window-up/down` | Focus in the column; past the edge, the previous or next workspace (in cascade overflow, the adjacent band, stopping at the first and last). 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 |
@@ -312,7 +312,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
312312
313313 ### Overview
314314
315-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans them left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear beside it along that axis; in a vertical overview, rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
315+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans covering floats left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear beside it along that axis; in a vertical overview, rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
316316
317317 | Key | Action |
318318 | --- | --- |
@@ -227,13 +227,13 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
227 227
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.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.
229 229
230-Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go first to the previous or next occupied workspace; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, or, with none, follow column order into the adjacent band. Up/down visits stacked windows first, then the closest column in the adjacent band, and stops at the first and last band; `move-window-up/down` takes the same path, moving the whole column to the adjacent band by swapping it with that column. In a partial last band that column is the closest one, so a column moved down and back up may land at another place in its band. Column widths are cells within the band (see [Column widths](#column-widths)); window height shares apply too. Maximizing a column hides its same-band siblings until focus moves or maximization ends.230+Left/right focus and moves stay in the band; at its edge, `focus-column-left/right` go first to the previous or next occupied workspace; past the first or last one, to the neighbor monitor on that side; with none there, to the trailing empty workspace; `move-column-left/right` go to the neighbor monitor, 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. A maximized column takes a band of its own: the columns after it continue in the following bands, and unmaximizing packs them back. It stays maximized while focus moves.
231 231
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.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.
233 233
234-In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot. A maximized column shows the columns it hides as dimmed cards behind it, peeking out on their side of the band: the nearest one on each side, then the next ones stacked at the band's edge. Selecting a card (left/right, a three-finger swipe or a click on a visible card) moves the maximization to it; Return keeps it there and Escape gives it back.234+In the [overview](#overview), the selected band is centred at the usual overview zoom, whatever the band count. The other bands of the workspace sit above and below it, dimmed, and may run off screen. Neighbor workspaces sit left and right, fully dimmed, with their selected row level with the centred band. The stash pile stays left of the band, inside the workspace's slot.
235 235
236-In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the previous or next occupied workspace at the band's edge, then to the neighbor monitor, then to the trailing empty workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. Over a maximized column that hides cards, horizontal moves one step per gesture like left/right: the next card, then past the band's edge the stash or a workspace. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.236+In the overview, up/down move to the previous or next band and stop at the first and last; with a stash card selected they browse the pile. Left/right move along the band; past its first column they enter the stash, or go to the previous workspace when there is none; past its last column they go to the next workspace (with the overview closed, focus goes to the previous or next occupied workspace at the band's edge, then to the neighbor monitor, then to the trailing empty workspace). Covering floats fan left and right of the band: left/right traverse them before entering the stash or changing workspace, and up/down do nothing on a float. `h`/`j`/`k`/`l` and the focus binds follow the arrows; `focus-workspace-prev/next` always change workspace, except over a selected stash card, where they browse the pile. Three-finger swipes follow the fingers: vertical moves one band (or one stash card) and horizontal one workspace per gesture, even over a stash card. A vertical wheel or continuous scroll changes workspace, except over a selected stash card, where it browses the pile; a horizontal one moves along the band.
237 237
238 Directional focus past the edge of the content switches workspace along the layout's workspace axis: up/down in scroll and fixed overflow, left/right in cascade.238 Directional focus past the edge of the content switches workspace along the layout's workspace axis: up/down in scroll and fixed overflow, left/right in cascade.
239 239
@@ -263,9 +263,9 @@ In a row, the auto columns share the cells the set ones leave; when no cell is l
263 | `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 |263 | `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 |
264 | `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. An activation of a stashed window (an app asking for attention, a taskbar click) shows the stash over the fullscreen window the same way. New windows never join or show over it; dialogs of the fullscreen window or of stashed windows, and their own dialogs, show above it |264 | `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. An activation of a stashed window (an app asking for attention, a taskbar click) shows the stash over the fullscreen window the same way. New windows never join or show over it; dialogs of the fullscreen window or of stashed windows, and their own dialogs, show above it |
265 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |265 | `toggle-overview` | Open the [overview](#overview), or close it on the selected window |
266-| `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 |266+| `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. Outside cascade, a maximized column hands its maximization to the target. A workspace capture session pauses while the strip shows, as in the overview |
267 | `switch-column-prev` | The same, in the opposite direction |267 | `switch-column-prev` | The same, in the opposite direction |
268-| `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 |268+| `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; in cascade, the column takes a band of its own. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column |
269 | `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 |269 | `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 |
270 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it comes the previous or next occupied workspace, then the neighbor monitor, then the trailing empty workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |270 | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In cascade overflow the edge is the band's, and past it comes the previous or next occupied workspace, then the neighbor monitor, then the trailing empty workspace. In fixed overflow, the neighbor on screen. From a fullscreen window, the neighbor column comes first and fullscreen ends; at the edge it stays |
271 | `focus-window-up/down` | Focus in the column; past the edge, the previous or next workspace (in cascade overflow, the adjacent band, stopping at the first and last). 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 |271 | `focus-window-up/down` | Focus in the column; past the edge, the previous or next workspace (in cascade overflow, the adjacent band, stopping at the first and last). 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 |
@@ -312,7 +312,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
312 312
313 ### Overview313 ### Overview
314 314
315-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans them left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear beside it along that axis; in a vertical overview, rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.315+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows, over a background darkened by `floating.dim`. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, and in a vertical overview up to two cards behind peek above it and passed cards peek below (a Cascade overview fans covering floats left and right). Neighbor workspaces show dimmed stacks. Each monitor has one overview, laid out along the navigation axis of the current workspace's layout: neighbors appear above and below a `scroll` or `fixed` workspace and left and right of a `cascade` one (see [Cascade](#cascade)); stepping to a workspace with another layout turns the overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with rules across the axis (horizontal in a vertical overview, vertical in a side-by-side one) separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear beside it along that axis; in a vertical overview, rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal previous/next workspace navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
316 316
317 | Key | Action |317 | Key | Action |
318 | --- | --- |318 | --- | --- |
M internal/core/cascade.go +86 -25
@@ -2,8 +2,62 @@ package core
22
33 import "slices"
44
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) }
5+// Cascade bands pack columns in order: a band ends when the next column
6+// would take it past MaxColumns slots. A column takes one slot and a
7+// maximized one all of them, so it sits alone in its band. Cells only
8+// share a band's width (laneWidth); they never move a column to a band.
9+
10+// bandSlots is the number of slots column i takes in its band.
11+func (w *Workspace) bandSlots(i int) int {
12+ if w.Columns[i].FullWidth {
13+ return max(w.MaxColumns, 1)
14+ }
15+ return 1
16+}
17+
18+// walkBands calls f with each band and its column range, in order, until f
19+// returns false.
20+func (w *Workspace) walkBands(f func(b, first, last int) bool) {
21+ k := max(w.MaxColumns, 1)
22+ b, first, sum := 0, 0, 0
23+ for i := range w.Columns {
24+ c := w.bandSlots(i)
25+ if i > first && sum+c > k {
26+ if !f(b, first, i-1) {
27+ return
28+ }
29+ b, first, sum = b+1, i, 0
30+ }
31+ sum += c
32+ }
33+ if len(w.Columns) > 0 {
34+ f(b, first, len(w.Columns)-1)
35+ }
36+}
37+
38+// band is the cascade band of column i.
39+func (w *Workspace) band(i int) (band int) {
40+ w.walkBands(func(b, _, last int) bool {
41+ band = b
42+ return last < i
43+ })
44+ return band
45+}
46+
47+// bandBounds is the column range of band b, first > last past the bands.
48+func (w *Workspace) bandBounds(b int) (first, last int) {
49+ first, last = 0, -1
50+ if b < 0 {
51+ return first, last
52+ }
53+ w.walkBands(func(n, f, l int) bool {
54+ if n == b {
55+ first, last = f, l
56+ }
57+ return n < b
58+ })
59+ return first, last
60+}
761
862 // cellX is the x of column i in its lane, from the usable area's left edge.
963 func (w *Workspace) cellX(i int) int {
@@ -88,16 +142,14 @@ func (w *Workspace) equalShare(k int) int {
88142 k = max(k, 1)
89143 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
90144 }
91-func (w *Workspace) cellInBand(b int) int {
92- k := max(w.MaxColumns, 1)
93- return min(b*k+w.Focus%k, len(w.Columns)-1)
94-}
145+
146+// focusBand focuses the column in the band dir away; a maximized column
147+// keeps its own band, so it stays maximized.
95148 func (w *Workspace) focusBand(dir int) bool {
96149 i := w.laneNeighbor(dir)
97150 if i < 0 {
98151 return false
99152 }
100- w.unmaximize()
101153 w.Focus = i
102154 w.Columns[i].Focus = 0
103155 if dir < 0 {
@@ -111,16 +163,12 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
111163 g := w.gap()
112164 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
113165 view := w.View + w.shiftPixels()
114- for lane := 0; ; lane++ {
115- first, last := w.laneBounds(lane)
116- if first > last {
117- break
118- }
166+ w.walkBands(func(lane, first, last int) bool {
119167 S, u, sum := w.laneStats(first, last)
120168 x := g
121169 for i := first; i <= last; i++ {
122170 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)}
171+ r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
124172 if w.Columns[i].FullWidth && !ignoreFullWidth {
125173 r.X = w.Usable.X + g
126174 r.W = max(w.Usable.W-2*g, 0)
@@ -128,7 +176,8 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
128176 rects[i] = r
129177 x += cw + g
130178 }
131- }
179+ return true
180+ })
132181 return rects
133182 }
134183
@@ -146,7 +195,7 @@ func (w *Workspace) cascadeFocus(view int) int {
146195 return 0
147196 }
148197 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
149- return w.cellInBand(b)
198+ return w.laneNeighbor(b - w.band(w.Focus))
150199 }
151200
152201 // laneOf is the lane of column i in the overview: its band, or the only
@@ -161,35 +210,47 @@ func (w *Workspace) laneOf(i int) int {
161210 // laneBounds is the range of columns of a lane, first > last when the lane
162211 // does not exist. A layout that does not wrap has the whole row as lane 0.
163212 func (w *Workspace) laneBounds(lane int) (first, last int) {
164- k := len(w.Columns)
165213 if w.policy().wraps {
166- k = max(w.MaxColumns, 1)
214+ return w.bandBounds(lane)
167215 }
168- first = lane * k
169- if lane < 0 || first >= len(w.Columns) {
216+ if lane != 0 || len(w.Columns) == 0 {
170217 return 0, -1
171218 }
172- return first, min(first+k, len(w.Columns)) - 1
219+ return 0, len(w.Columns) - 1
173220 }
174221
175222 // leavesBand reports whether column i, where a left/right move from the
176223 // focused column goes, is past the workspace or in another cascade band:
177224 // the move then goes to the neighbor monitor first.
178225 func (w *Workspace) leavesBand(i int) bool {
179- return i < 0 || i >= len(w.Columns) || !w.sameBand(i, w.Focus)
226+ return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus)
180227 }
181228
182229 // moveBand swaps the focused cascade column with the column in the band dir
183230 // away where focus-window-up/down goes; focus follows it. Past the first
184-// or last band nothing moves.
231+// or last band nothing moves. Columns keep their maximization: when the two
232+// take a different number of slots, a swap would repack the bands around
233+// them, so the focused column passes the whole adjacent band instead.
185234 func (w *Workspace) moveBand(dir int) {
186235 i := w.laneNeighbor(dir)
187236 if i < 0 {
188237 return
189238 }
190- w.unmaximize()
191- w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]
192- w.Focus = i
239+ if w.bandSlots(w.Focus) == w.bandSlots(i) {
240+ w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]
241+ w.Focus = i
242+ w.scroll()
243+ return
244+ }
245+ first, last := w.bandBounds(w.band(i))
246+ col := w.Columns[w.Focus]
247+ w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
248+ at := first // dir < 0: before the band above.
249+ if dir > 0 {
250+ at = last // After the band below, one index lower once removed.
251+ }
252+ w.Columns = slices.Insert(w.Columns, at, col)
253+ w.Focus = at
193254 w.scroll()
194255 }
195256
@@ -2,8 +2,62 @@ package core
2 2
3 import "slices"3 import "slices"
4 4
5-func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }5+// Cascade bands pack columns in order: a band ends when the next column
6-func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }6+// would take it past MaxColumns slots. A column takes one slot and a
7+// maximized one all of them, so it sits alone in its band. Cells only
8+// share a band's width (laneWidth); they never move a column to a band.
9+
10+// bandSlots is the number of slots column i takes in its band.
11+func (w *Workspace) bandSlots(i int) int {
12+ if w.Columns[i].FullWidth {
13+ return max(w.MaxColumns, 1)
14+ }
15+ return 1
16+}
17+
18+// walkBands calls f with each band and its column range, in order, until f
19+// returns false.
20+func (w *Workspace) walkBands(f func(b, first, last int) bool) {
21+ k := max(w.MaxColumns, 1)
22+ b, first, sum := 0, 0, 0
23+ for i := range w.Columns {
24+ c := w.bandSlots(i)
25+ if i > first && sum+c > k {
26+ if !f(b, first, i-1) {
27+ return
28+ }
29+ b, first, sum = b+1, i, 0
30+ }
31+ sum += c
32+ }
33+ if len(w.Columns) > 0 {
34+ f(b, first, len(w.Columns)-1)
35+ }
36+}
37+
38+// band is the cascade band of column i.
39+func (w *Workspace) band(i int) (band int) {
40+ w.walkBands(func(b, _, last int) bool {
41+ band = b
42+ return last < i
43+ })
44+ return band
45+}
46+
47+// bandBounds is the column range of band b, first > last past the bands.
48+func (w *Workspace) bandBounds(b int) (first, last int) {
49+ first, last = 0, -1
50+ if b < 0 {
51+ return first, last
52+ }
53+ w.walkBands(func(n, f, l int) bool {
54+ if n == b {
55+ first, last = f, l
56+ }
57+ return n < b
58+ })
59+ return first, last
60+}
7 61
8 // cellX is the x of column i in its lane, from the usable area's left edge.62 // cellX is the x of column i in its lane, from the usable area's left edge.
9 func (w *Workspace) cellX(i int) int {63 func (w *Workspace) cellX(i int) int {
@@ -88,16 +142,14 @@ func (w *Workspace) equalShare(k int) int {
88 k = max(k, 1)142 k = max(k, 1)
89 return max((w.Usable.W-w.gap()*(k+1))/k, 0)143 return max((w.Usable.W-w.gap()*(k+1))/k, 0)
90 }144 }
91-func (w *Workspace) cellInBand(b int) int {145+
92- k := max(w.MaxColumns, 1)146+// focusBand focuses the column in the band dir away; a maximized column
93- return min(b*k+w.Focus%k, len(w.Columns)-1)147+// keeps its own band, so it stays maximized.
94-}
95 func (w *Workspace) focusBand(dir int) bool {148 func (w *Workspace) focusBand(dir int) bool {
96 i := w.laneNeighbor(dir)149 i := w.laneNeighbor(dir)
97 if i < 0 {150 if i < 0 {
98 return false151 return false
99 }152 }
100- w.unmaximize()
101 w.Focus = i153 w.Focus = i
102 w.Columns[i].Focus = 0154 w.Columns[i].Focus = 0
103 if dir < 0 {155 if dir < 0 {
@@ -111,16 +163,12 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
111 g := w.gap()163 g := w.gap()
112 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]164 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
113 view := w.View + w.shiftPixels()165 view := w.View + w.shiftPixels()
114- for lane := 0; ; lane++ {166+ w.walkBands(func(lane, first, last int) bool {
115- first, last := w.laneBounds(lane)
116- if first > last {
117- break
118- }
119 S, u, sum := w.laneStats(first, last)167 S, u, sum := w.laneStats(first, last)
120 x := g168 x := g
121 for i := first; i <= last; i++ {169 for i := first; i <= last; i++ {
122 cw := w.laneWidth(i, first, last, S, u, sum)170 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)}171+ r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + lane*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
124 if w.Columns[i].FullWidth && !ignoreFullWidth {172 if w.Columns[i].FullWidth && !ignoreFullWidth {
125 r.X = w.Usable.X + g173 r.X = w.Usable.X + g
126 r.W = max(w.Usable.W-2*g, 0)174 r.W = max(w.Usable.W-2*g, 0)
@@ -128,7 +176,8 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
128 rects[i] = r176 rects[i] = r
129 x += cw + g177 x += cw + g
130 }178 }
131- }179+ return true
180+ })
132 return rects181 return rects
133 }182 }
134 183
@@ -146,7 +195,7 @@ func (w *Workspace) cascadeFocus(view int) int {
146 return 0195 return 0
147 }196 }
148 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))197 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
149- return w.cellInBand(b)198+ return w.laneNeighbor(b - w.band(w.Focus))
150 }199 }
151 200
152 // laneOf is the lane of column i in the overview: its band, or the only201 // laneOf is the lane of column i in the overview: its band, or the only
@@ -161,35 +210,47 @@ func (w *Workspace) laneOf(i int) int {
161 // laneBounds is the range of columns of a lane, first > last when the lane210 // laneBounds is the range of columns of a lane, first > last when the lane
162 // does not exist. A layout that does not wrap has the whole row as lane 0.211 // does not exist. A layout that does not wrap has the whole row as lane 0.
163 func (w *Workspace) laneBounds(lane int) (first, last int) {212 func (w *Workspace) laneBounds(lane int) (first, last int) {
164- k := len(w.Columns)
165 if w.policy().wraps {213 if w.policy().wraps {
166- k = max(w.MaxColumns, 1)214+ return w.bandBounds(lane)
167 }215 }
168- first = lane * k216+ if lane != 0 || len(w.Columns) == 0 {
169- if lane < 0 || first >= len(w.Columns) {
170 return 0, -1217 return 0, -1
171 }218 }
172- return first, min(first+k, len(w.Columns)) - 1219+ return 0, len(w.Columns) - 1
173 }220 }
174 221
175 // leavesBand reports whether column i, where a left/right move from the222 // leavesBand reports whether column i, where a left/right move from the
176 // focused column goes, is past the workspace or in another cascade band:223 // focused column goes, is past the workspace or in another cascade band:
177 // the move then goes to the neighbor monitor first.224 // the move then goes to the neighbor monitor first.
178 func (w *Workspace) leavesBand(i int) bool {225 func (w *Workspace) leavesBand(i int) bool {
179- return i < 0 || i >= len(w.Columns) || !w.sameBand(i, w.Focus)226+ return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus)
180 }227 }
181 228
182 // moveBand swaps the focused cascade column with the column in the band dir229 // moveBand swaps the focused cascade column with the column in the band dir
183 // away where focus-window-up/down goes; focus follows it. Past the first230 // away where focus-window-up/down goes; focus follows it. Past the first
184-// or last band nothing moves.231+// or last band nothing moves. Columns keep their maximization: when the two
232+// take a different number of slots, a swap would repack the bands around
233+// them, so the focused column passes the whole adjacent band instead.
185 func (w *Workspace) moveBand(dir int) {234 func (w *Workspace) moveBand(dir int) {
186 i := w.laneNeighbor(dir)235 i := w.laneNeighbor(dir)
187 if i < 0 {236 if i < 0 {
188 return237 return
189 }238 }
190- w.unmaximize()239+ if w.bandSlots(w.Focus) == w.bandSlots(i) {
191- w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]240+ w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus]
192- w.Focus = i241+ w.Focus = i
242+ w.scroll()
243+ return
244+ }
245+ first, last := w.bandBounds(w.band(i))
246+ col := w.Columns[w.Focus]
247+ w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
248+ at := first // dir < 0: before the band above.
249+ if dir > 0 {
250+ at = last // After the band below, one index lower once removed.
251+ }
252+ w.Columns = slices.Insert(w.Columns, at, col)
253+ w.Focus = at
193 w.scroll()254 w.scroll()
194 }255 }
195 256
M internal/core/cascade_moves_test.go +49 -7
@@ -188,16 +188,58 @@ func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
188188 wantFocus(t, c, "M", 4)
189189 }
190190
191-// A maximized column leaving its band gives up maximization: the band it
192-// joins shows all its columns.
193-func TestCascadeMoveWindowEndsMaximization(t *testing.T) {
191+// A maximized column keeps its band to itself when it moves: it swaps
192+// with the column of the band above and stays maximized.
193+func TestCascadeMoveWindowKeepsMaximization(t *testing.T) {
194194 c, w := cascadeMovesCore(t, 5, 3, false)
195195 w.FocusID(5)
196- w.ToggleFullWidth()
196+ w.ToggleFullWidth() // Bands {1 2 3} {4} {5}.
197197 c.applyAction(ActionMoveWindowUp)
198- wantColumns(t, w, 1, 5, 3, 4, 2)
199- if w.Columns[1].FullWidth || len(w.maximized) != 0 {
200- t.Fatalf("still maximized: full width %t, MRU %v", w.Columns[1].FullWidth, w.maximized)
198+ wantColumns(t, w, 1, 2, 3, 5, 4)
199+ wantFocus(t, c, "M", 5)
200+ if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 {
201+ t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4))
202+ }
203+}
204+
205+// A column moving to or from a maximized column's band passes the whole
206+// adjacent band. Bands follow column order, so a maximized column leaving
207+// lets the bands around it pack together: {1} and {3 4} join.
208+// Repeating the move never leaves the column stuck.
209+func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) {
210+ for _, tc := range []struct {
211+ name string
212+ max, focus WindowID
213+ action Action
214+ want []WindowID
215+ band int
216+ }{
217+ // Bands {1} {2*} {3 4}.
218+ {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1},
219+ {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0},
220+ // Bands {1 2 3} {4*} {5}.
221+ {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2},
222+ {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1},
223+ } {
224+ t.Run(tc.name, func(t *testing.T) {
225+ n := 5
226+ if tc.max == 2 {
227+ n = 4
228+ }
229+ c, w := cascadeMovesCore(t, n, 3, false)
230+ w.FocusID(tc.max)
231+ w.ToggleFullWidth()
232+ w.FocusID(tc.focus)
233+ c.applyAction(tc.action)
234+ wantColumns(t, w, tc.want...)
235+ wantFocus(t, c, "M", tc.focus)
236+ if got := w.band(w.Focus); got != tc.band {
237+ t.Fatalf("band %d, want %d", got, tc.band)
238+ }
239+ if !w.Columns[w.columnOf(tc.max)].FullWidth {
240+ t.Fatal("maximization lost")
241+ }
242+ })
201243 }
202244 }
203245
@@ -188,16 +188,58 @@ func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) {
188 wantFocus(t, c, "M", 4)188 wantFocus(t, c, "M", 4)
189 }189 }
190 190
191-// A maximized column leaving its band gives up maximization: the band it191+// A maximized column keeps its band to itself when it moves: it swaps
192-// joins shows all its columns.192+// with the column of the band above and stays maximized.
193-func TestCascadeMoveWindowEndsMaximization(t *testing.T) {193+func TestCascadeMoveWindowKeepsMaximization(t *testing.T) {
194 c, w := cascadeMovesCore(t, 5, 3, false)194 c, w := cascadeMovesCore(t, 5, 3, false)
195 w.FocusID(5)195 w.FocusID(5)
196- w.ToggleFullWidth()196+ w.ToggleFullWidth() // Bands {1 2 3} {4} {5}.
197 c.applyAction(ActionMoveWindowUp)197 c.applyAction(ActionMoveWindowUp)
198- wantColumns(t, w, 1, 5, 3, 4, 2)198+ wantColumns(t, w, 1, 2, 3, 5, 4)
199- if w.Columns[1].FullWidth || len(w.maximized) != 0 {199+ wantFocus(t, c, "M", 5)
200- t.Fatalf("still maximized: full width %t, MRU %v", w.Columns[1].FullWidth, w.maximized)200+ if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 {
201+ t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4))
202+ }
203+}
204+
205+// A column moving to or from a maximized column's band passes the whole
206+// adjacent band. Bands follow column order, so a maximized column leaving
207+// lets the bands around it pack together: {1} and {3 4} join.
208+// Repeating the move never leaves the column stuck.
209+func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) {
210+ for _, tc := range []struct {
211+ name string
212+ max, focus WindowID
213+ action Action
214+ want []WindowID
215+ band int
216+ }{
217+ // Bands {1} {2*} {3 4}.
218+ {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1},
219+ {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0},
220+ // Bands {1 2 3} {4*} {5}.
221+ {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2},
222+ {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1},
223+ } {
224+ t.Run(tc.name, func(t *testing.T) {
225+ n := 5
226+ if tc.max == 2 {
227+ n = 4
228+ }
229+ c, w := cascadeMovesCore(t, n, 3, false)
230+ w.FocusID(tc.max)
231+ w.ToggleFullWidth()
232+ w.FocusID(tc.focus)
233+ c.applyAction(tc.action)
234+ wantColumns(t, w, tc.want...)
235+ wantFocus(t, c, "M", tc.focus)
236+ if got := w.band(w.Focus); got != tc.band {
237+ t.Fatalf("band %d, want %d", got, tc.band)
238+ }
239+ if !w.Columns[w.columnOf(tc.max)].FullWidth {
240+ t.Fatal("maximization lost")
241+ }
242+ })
201 }243 }
202 }244 }
203 245
M internal/core/cascade_test.go +42 -17
@@ -142,20 +142,48 @@ func TestCascadeCompactionAndNavigation(t *testing.T) {
142142 }
143143 }
144144
145-func TestCascadeCovers(t *testing.T) {
146- w := cascadeWorkspace(4)
145+// A maximized column takes a band of its own: the columns after it pack
146+// into the next bands, and unmaximizing packs them back.
147+func TestCascadeMaximizedColumnOwnsBand(t *testing.T) {
148+ w := cascadeWorkspace(4) // Bands {1 2 3} {4}.
147149 w.FocusID(2)
148150 w.ToggleFullWidth()
149- ps := w.Layout()
150- for _, p := range ps {
151- if p.ID == 2 && p.Rect.W != 900 {
151+ bands := func() []int {
152+ var b []int
153+ for i := range w.Columns {
154+ b = append(b, w.band(i))
155+ }
156+ return b
157+ }
158+ if got := bands(); !slices.Equal(got, []int{0, 1, 2, 2}) {
159+ t.Fatalf("bands %v, want [0 1 2 2]", got)
160+ }
161+ if w.View != 600 {
162+ t.Fatalf("view %d, want the maximized band", w.View)
163+ }
164+ for _, p := range w.Layout() {
165+ if p.ID == 2 && (p.Rect.W != 900 || p.Rect.Y != 0) {
152166 t.Fatalf("full width=%v", p.Rect)
153167 }
154- if (p.ID == 1 || p.ID == 3) && !p.Hidden {
155- t.Fatalf("sibling %d visible", p.ID)
168+ if p.ID != 2 && p.Rect.Y == 0 && !p.Hidden {
169+ t.Fatalf("column %d shares the maximized band: %v", p.ID, p.Rect)
156170 }
157171 }
172+ // Focus moves between bands without unmaximizing.
173+ w.FocusID(1)
174+ if !w.Columns[1].FullWidth || w.View != 0 {
175+ t.Fatalf("focus left the band: maximized %t view %d", w.Columns[1].FullWidth, w.View)
176+ }
177+ w.FocusID(2)
158178 w.ToggleFullWidth()
179+ if got := bands(); !slices.Equal(got, []int{0, 0, 0, 1}) {
180+ t.Fatalf("bands after unmaximize %v", got)
181+ }
182+}
183+
184+func TestCascadeCovers(t *testing.T) {
185+ w := cascadeWorkspace(4)
186+ w.FocusID(2)
159187 w.ToggleFullscreen()
160188 for _, p := range w.Layout() {
161189 if p.ID == 2 && p.Rect != w.Output {
@@ -172,7 +200,8 @@ func TestCascadeCovers(t *testing.T) {
172200 }
173201 }
174202
175-func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
203+// Band changes keep a maximized column maximized: it has its own band.
204+func TestCascadeKeepsMaximizationOnBandChanges(t *testing.T) {
176205 for _, move := range []struct {
177206 name string
178207 run func(*Workspace)
@@ -186,24 +215,20 @@ func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
186215 w.FocusID(1)
187216 w.ToggleFullWidth()
188217 move.run(w)
189- for _, col := range w.Columns {
190- if col.FullWidth {
191- t.Fatal("stale maximization")
192- }
218+ if i := w.columnOf(1); !w.Columns[i].FullWidth || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth && c.Windows[0] != 1 }) {
219+ t.Fatalf("maximization lost or moved: %+v", w.Columns)
193220 }
194221 })
195222 }
196223 }
197224
198-func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
225+func TestCascadeStashRestoreKeepsReloadClearsMaximization(t *testing.T) {
199226 w := cascadeWorkspace(3)
200227 w.FocusID(1)
201228 w.ToggleFullWidth()
202229 w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
203- for _, c := range w.Columns {
204- if c.FullWidth {
205- t.Fatal("restore retained maximization")
206- }
230+ if !w.Columns[0].FullWidth {
231+ t.Fatal("restore into another band dropped the maximization")
207232 }
208233 m := NewMonitor()
209234 m.SetOutput(900, 600)
@@ -142,20 +142,48 @@ func TestCascadeCompactionAndNavigation(t *testing.T) {
142 }142 }
143 }143 }
144 144
145-func TestCascadeCovers(t *testing.T) {145+// A maximized column takes a band of its own: the columns after it pack
146- w := cascadeWorkspace(4)146+// into the next bands, and unmaximizing packs them back.
147+func TestCascadeMaximizedColumnOwnsBand(t *testing.T) {
148+ w := cascadeWorkspace(4) // Bands {1 2 3} {4}.
147 w.FocusID(2)149 w.FocusID(2)
148 w.ToggleFullWidth()150 w.ToggleFullWidth()
149- ps := w.Layout()151+ bands := func() []int {
150- for _, p := range ps {152+ var b []int
151- if p.ID == 2 && p.Rect.W != 900 {153+ for i := range w.Columns {
154+ b = append(b, w.band(i))
155+ }
156+ return b
157+ }
158+ if got := bands(); !slices.Equal(got, []int{0, 1, 2, 2}) {
159+ t.Fatalf("bands %v, want [0 1 2 2]", got)
160+ }
161+ if w.View != 600 {
162+ t.Fatalf("view %d, want the maximized band", w.View)
163+ }
164+ for _, p := range w.Layout() {
165+ if p.ID == 2 && (p.Rect.W != 900 || p.Rect.Y != 0) {
152 t.Fatalf("full width=%v", p.Rect)166 t.Fatalf("full width=%v", p.Rect)
153 }167 }
154- if (p.ID == 1 || p.ID == 3) && !p.Hidden {168+ if p.ID != 2 && p.Rect.Y == 0 && !p.Hidden {
155- t.Fatalf("sibling %d visible", p.ID)169+ t.Fatalf("column %d shares the maximized band: %v", p.ID, p.Rect)
156 }170 }
157 }171 }
172+ // Focus moves between bands without unmaximizing.
173+ w.FocusID(1)
174+ if !w.Columns[1].FullWidth || w.View != 0 {
175+ t.Fatalf("focus left the band: maximized %t view %d", w.Columns[1].FullWidth, w.View)
176+ }
177+ w.FocusID(2)
158 w.ToggleFullWidth()178 w.ToggleFullWidth()
179+ if got := bands(); !slices.Equal(got, []int{0, 0, 0, 1}) {
180+ t.Fatalf("bands after unmaximize %v", got)
181+ }
182+}
183+
184+func TestCascadeCovers(t *testing.T) {
185+ w := cascadeWorkspace(4)
186+ w.FocusID(2)
159 w.ToggleFullscreen()187 w.ToggleFullscreen()
160 for _, p := range w.Layout() {188 for _, p := range w.Layout() {
161 if p.ID == 2 && p.Rect != w.Output {189 if p.ID == 2 && p.Rect != w.Output {
@@ -172,7 +200,8 @@ func TestCascadeCovers(t *testing.T) {
172 }200 }
173 }201 }
174 202
175-func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {203+// Band changes keep a maximized column maximized: it has its own band.
204+func TestCascadeKeepsMaximizationOnBandChanges(t *testing.T) {
176 for _, move := range []struct {205 for _, move := range []struct {
177 name string206 name string
178 run func(*Workspace)207 run func(*Workspace)
@@ -186,24 +215,20 @@ func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
186 w.FocusID(1)215 w.FocusID(1)
187 w.ToggleFullWidth()216 w.ToggleFullWidth()
188 move.run(w)217 move.run(w)
189- for _, col := range w.Columns {218+ if i := w.columnOf(1); !w.Columns[i].FullWidth || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth && c.Windows[0] != 1 }) {
190- if col.FullWidth {219+ t.Fatalf("maximization lost or moved: %+v", w.Columns)
191- t.Fatal("stale maximization")
192- }
193 }220 }
194 })221 })
195 }222 }
196 }223 }
197 224
198-func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {225+func TestCascadeStashRestoreKeepsReloadClearsMaximization(t *testing.T) {
199 w := cascadeWorkspace(3)226 w := cascadeWorkspace(3)
200 w.FocusID(1)227 w.FocusID(1)
201 w.ToggleFullWidth()228 w.ToggleFullWidth()
202 w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})229 w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
203- for _, c := range w.Columns {230+ if !w.Columns[0].FullWidth {
204- if c.FullWidth {231+ t.Fatal("restore into another band dropped the maximization")
205- t.Fatal("restore retained maximization")
206- }
207 }232 }
208 m := NewMonitor()233 m := NewMonitor()
209 m.SetOutput(900, 600)234 m.SetOutput(900, 600)
M internal/core/gesture.go +1 -1
@@ -311,7 +311,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
311311 if !e.Cancelled {
312312 view := int(math.Round(target))
313313 focus := w.snapFocus(view, target >= shown)
314- if focus != w.Focus && w.policy().equalCells {
314+ if focus != w.Focus && w.policy().maximizeHides() {
315315 w.unmaximize()
316316 }
317317 w.Focus = focus
@@ -311,7 +311,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
311 if !e.Cancelled {311 if !e.Cancelled {
312 view := int(math.Round(target))312 view := int(math.Round(target))
313 focus := w.snapFocus(view, target >= shown)313 focus := w.snapFocus(view, target >= shown)
314- if focus != w.Focus && w.policy().equalCells {314+ if focus != w.Focus && w.policy().maximizeHides() {
315 w.unmaximize()315 w.unmaximize()
316 }316 }
317 w.Focus = focus317 w.Focus = focus
M internal/core/layout.go +20 -15
@@ -536,7 +536,7 @@ func (w *Workspace) addColumn(col Column) {
536536 } else if len(w.Columns) > 0 {
537537 at = w.Focus + 1
538538 }
539- if w.policy().equalCells && len(w.Columns) > 0 {
539+ if w.policy().maximizeHides() && len(w.Columns) > 0 {
540540 w.unmaximize()
541541 }
542542 w.Columns = append(w.Columns, Column{})
@@ -688,7 +688,7 @@ func (w *Workspace) FocusID(id WindowID) bool {
688688 w.dropStashOver()
689689 w.floatFocus, w.stashFocus = false, false
690690 w.raiseColumns()
691- if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
691+ if w.policy().maximizeHides() && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
692692 w.unmaximize()
693693 }
694694 w.Focus = i
@@ -736,7 +736,7 @@ func (w *Workspace) focusColumn(dir int) {
736736 }
737737 if i := w.columnToward(dir); i >= 0 {
738738 w.raiseColumns()
739- if w.policy().equalCells {
739+ if w.policy().maximizeHides() {
740740 w.unmaximize()
741741 }
742742 w.Focus = i
@@ -999,7 +999,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
999999 // insertColumn adds a column at index at (clamped) and focuses it.
10001000 func (w *Workspace) insertColumn(at int, col Column) {
10011001 at = min(max(at, 0), len(w.Columns))
1002- if w.policy().equalCells && len(w.Columns) > 0 {
1002+ if w.policy().maximizeHides() && len(w.Columns) > 0 {
10031003 w.unmaximize()
10041004 }
10051005 w.Columns = slices.Insert(w.Columns, at, col)
@@ -1182,23 +1182,28 @@ func (w *Workspace) maximize(i int) {
11821182 }
11831183
11841184 // hiddenByMaximized reports whether the maximized focused column hides
1185-// column i on screen: in equal-cell layouts it hides the other columns of
1186-// its band (the whole row in fixed overflow).
1185+// column i on screen: in fixed overflow it hides the rest of the row. A
1186+// maximized cascade column has its band to itself, so it hides nothing.
11871187 func (w *Workspace) hiddenByMaximized(i int) bool {
1188- return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)
1188+ return w.policy().maximizeHides() && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus
11891189 }
11901190
1191-// unmaximize clears the focused column in scroll mode, or all maximized
1192-// columns in fixed mode (including after Escape changed focus).
1191+// unmaximize clears the focused column in scroll and cascade, or all
1192+// maximized columns in fixed overflow (including after Escape changed focus).
11931193 func (w *Workspace) unmaximize() {
1194- for i := range w.Columns {
1195- if w.policy().equalCells || i == w.Focus {
1196- w.Columns[i].FullWidth = false
1197- }
1194+ if w.policy().maximizeHides() {
1195+ w.clearMaximized()
1196+ } else if w.Focus < len(w.Columns) {
1197+ w.Columns[w.Focus].FullWidth = false
11981198 }
1199- if w.policy().equalCells {
1200- w.maximized = nil
1199+}
1200+
1201+// clearMaximized unmaximizes every column and forgets the history.
1202+func (w *Workspace) clearMaximized() {
1203+ for i := range w.Columns {
1204+ w.Columns[i].FullWidth = false
12011205 }
1206+ w.maximized = nil
12021207 }
12031208
12041209 // transferMaximization changes the maximized column without forgetting MRU.
@@ -536,7 +536,7 @@ func (w *Workspace) addColumn(col Column) {
536 } else if len(w.Columns) > 0 {536 } else if len(w.Columns) > 0 {
537 at = w.Focus + 1537 at = w.Focus + 1
538 }538 }
539- if w.policy().equalCells && len(w.Columns) > 0 {539+ if w.policy().maximizeHides() && len(w.Columns) > 0 {
540 w.unmaximize()540 w.unmaximize()
541 }541 }
542 w.Columns = append(w.Columns, Column{})542 w.Columns = append(w.Columns, Column{})
@@ -688,7 +688,7 @@ func (w *Workspace) FocusID(id WindowID) bool {
688 w.dropStashOver()688 w.dropStashOver()
689 w.floatFocus, w.stashFocus = false, false689 w.floatFocus, w.stashFocus = false, false
690 w.raiseColumns()690 w.raiseColumns()
691- if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {691+ if w.policy().maximizeHides() && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth {
692 w.unmaximize()692 w.unmaximize()
693 }693 }
694 w.Focus = i694 w.Focus = i
@@ -736,7 +736,7 @@ func (w *Workspace) focusColumn(dir int) {
736 }736 }
737 if i := w.columnToward(dir); i >= 0 {737 if i := w.columnToward(dir); i >= 0 {
738 w.raiseColumns()738 w.raiseColumns()
739- if w.policy().equalCells {739+ if w.policy().maximizeHides() {
740 w.unmaximize()740 w.unmaximize()
741 }741 }
742 w.Focus = i742 w.Focus = i
@@ -999,7 +999,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
999 // insertColumn adds a column at index at (clamped) and focuses it.999 // insertColumn adds a column at index at (clamped) and focuses it.
1000 func (w *Workspace) insertColumn(at int, col Column) {1000 func (w *Workspace) insertColumn(at int, col Column) {
1001 at = min(max(at, 0), len(w.Columns))1001 at = min(max(at, 0), len(w.Columns))
1002- if w.policy().equalCells && len(w.Columns) > 0 {1002+ if w.policy().maximizeHides() && len(w.Columns) > 0 {
1003 w.unmaximize()1003 w.unmaximize()
1004 }1004 }
1005 w.Columns = slices.Insert(w.Columns, at, col)1005 w.Columns = slices.Insert(w.Columns, at, col)
@@ -1182,23 +1182,28 @@ func (w *Workspace) maximize(i int) {
1182 }1182 }
1183 1183
1184 // hiddenByMaximized reports whether the maximized focused column hides1184 // hiddenByMaximized reports whether the maximized focused column hides
1185-// column i on screen: in equal-cell layouts it hides the other columns of1185+// column i on screen: in fixed overflow it hides the rest of the row. A
1186-// its band (the whole row in fixed overflow).1186+// maximized cascade column has its band to itself, so it hides nothing.
1187 func (w *Workspace) hiddenByMaximized(i int) bool {1187 func (w *Workspace) hiddenByMaximized(i int) bool {
1188- return w.policy().equalCells && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus && w.sameBand(i, w.Focus)1188+ return w.policy().maximizeHides() && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && i != w.Focus
1189 }1189 }
1190 1190
1191-// unmaximize clears the focused column in scroll mode, or all maximized1191+// unmaximize clears the focused column in scroll and cascade, or all
1192-// columns in fixed mode (including after Escape changed focus).1192+// maximized columns in fixed overflow (including after Escape changed focus).
1193 func (w *Workspace) unmaximize() {1193 func (w *Workspace) unmaximize() {
1194- for i := range w.Columns {1194+ if w.policy().maximizeHides() {
1195- if w.policy().equalCells || i == w.Focus {1195+ w.clearMaximized()
1196- w.Columns[i].FullWidth = false1196+ } else if w.Focus < len(w.Columns) {
1197- }1197+ w.Columns[w.Focus].FullWidth = false
1198 }1198 }
1199- if w.policy().equalCells {1199+}
1200- w.maximized = nil1200+
1201+// clearMaximized unmaximizes every column and forgets the history.
1202+func (w *Workspace) clearMaximized() {
1203+ for i := range w.Columns {
1204+ w.Columns[i].FullWidth = false
1201 }1205 }
1206+ w.maximized = nil
1202 }1207 }
1203 1208
1204 // transferMaximization changes the maximized column without forgetting MRU.1209 // transferMaximization changes the maximized column without forgetting MRU.
M internal/core/layout_policy.go +6 -0
@@ -29,6 +29,12 @@ type layoutPolicy struct {
2929 equalCells, wraps bool
3030 }
3131
32+// maximizeHides reports whether a maximized column hides the others, so
33+// it is a transient state that focus changes undo: fixed overflow. In
34+// scroll overflow and cascade it is a lasting state of its column (a
35+// cascade band packs a maximized column alone).
36+func (p layoutPolicy) maximizeHides() bool { return p.equalCells && !p.wraps }
37+
3238 // workspaceStep is the workspace step a directional focus past the content
3339 // edge takes along axis: dir when axis is the workspace axis, else 0.
3440 func (p layoutPolicy) workspaceStep(axis layoutAxis, dir int) int {
@@ -29,6 +29,12 @@ type layoutPolicy struct {
29 equalCells, wraps bool29 equalCells, wraps bool
30 }30 }
31 31
32+// maximizeHides reports whether a maximized column hides the others, so
33+// it is a transient state that focus changes undo: fixed overflow. In
34+// scroll overflow and cascade it is a lasting state of its column (a
35+// cascade band packs a maximized column alone).
36+func (p layoutPolicy) maximizeHides() bool { return p.equalCells && !p.wraps }
37+
32 // workspaceStep is the workspace step a directional focus past the content38 // workspaceStep is the workspace step a directional focus past the content
33 // edge takes along axis: dir when axis is the workspace axis, else 0.39 // edge takes along axis: dir when axis is the workspace axis, else 0.
34 func (p layoutPolicy) workspaceStep(axis layoutAxis, dir int) int {40 func (p layoutPolicy) workspaceStep(axis layoutAxis, dir int) int {
M internal/core/monitor.go +1 -1
@@ -558,7 +558,7 @@ func (m *Monitor) applyNamed() {
558558 w.Overflow = s.Overflow
559559 }
560560 if before != w.Overflow && w.policy().equalCells {
561- w.unmaximize()
561+ w.clearMaximized()
562562 }
563563 // The view offset belongs to the old content axis: SetMaxColumns
564564 // scrolls back to the focus along the new one.
@@ -558,7 +558,7 @@ func (m *Monitor) applyNamed() {
558 w.Overflow = s.Overflow558 w.Overflow = s.Overflow
559 }559 }
560 if before != w.Overflow && w.policy().equalCells {560 if before != w.Overflow && w.policy().equalCells {
561- w.unmaximize()561+ w.clearMaximized()
562 }562 }
563 // The view offset belongs to the old content axis: SetMaxColumns563 // The view offset belongs to the old content axis: SetMaxColumns
564 // scrolls back to the focus along the new one.564 // scrolls back to the focus along the new one.
M internal/core/overview.go +11 -87
@@ -302,7 +302,7 @@ func restoreRow(w *Workspace, s rowSnapshot) {
302302 // Fixed overflow maximizes at most one column: only the one holding
303303 // the anchor window, wherever active binds moved it. Scroll overflow
304304 // keeps each column's own width.
305- if w.policy().equalCells {
305+ if w.policy().maximizeHides() {
306306 w.unmaximize()
307307 for i := range w.Columns {
308308 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
@@ -460,11 +460,8 @@ func (m *Monitor) overviewAcross(dx int) {
460460 m.clearCard()
461461 m.ov.selected = 0
462462 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
463- // The front column: a maximized one keeps its cards behind.
464- i := w.Focus
465- if !w.hasBandCards() {
466- i, _ = w.laneBounds(w.laneOf(w.Focus))
467- }
463+ // The first column of the focused lane.
464+ i, _ := w.laneBounds(w.laneOf(w.Focus))
468465 m.selectLaneColumn(w, i)
469466 }
470467 return
@@ -863,8 +860,7 @@ func (w *Workspace) overviewZoom() float64 {
863860 }
864861
865862 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
866-// cascade workspace spans one screen whatever its band count, plus the room
867-// of the cards a maximized column hides.
863+// cascade workspace spans one screen whatever its band count.
868864 func (w *Workspace) zoomSpan() (float64, int) {
869865 // Only the span is read: the tiles go in the zoom scratch, dead on return.
870866 var span int
@@ -979,37 +975,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
979975 rects = w.colBuf
980976 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
981977 }
982- // off is the room, left of the band, for the cards a maximized
983- // cascade column hides (the same room is kept on its right).
984- off := 0
985- if w.hasBandCards() {
986- off = 2 * w.bandCardStep()
987- // The columns the maximized one hides are cards behind it,
988- // farthest first so the nearest paint over them.
989- first, last := w.laneBounds(w.laneOf(w.Focus))
990- for d := last - first; d > 0; d-- {
991- if i := w.Focus - d; i >= first {
992- tiles = w.appendBandCard(tiles, i)
993- }
994- if i := w.Focus + d; i <= last {
995- tiles = w.appendBandCard(tiles, i)
996- }
997- }
998- }
999978 for i, c := range w.Columns {
1000979 r := Rect{Y: g, H: h}
1001980 if w.policy().wraps {
1002- if w.hiddenByMaximized(i) {
1003- continue // A card, above.
1004- }
1005981 // The band is the lane: it is encoded in Y, one screen apart.
1006- // A maximized column fills its band as on screen; the cards
1007- // it hides peek out on either side, past the band.
1008- r.X = off + w.cellX(i)
982+ // A maximized column fills its band, alone, as on screen.
983+ r.X = w.cellX(i)
1009984 r.Y = g + w.band(i)*w.Usable.H
1010985 r.W = w.cellWidth(i)
1011986 if c.FullWidth {
1012- r.X, r.W = off+g, max(w.Usable.W-2*g, 0)
987+ r.X, r.W = g, max(w.Usable.W-2*g, 0)
1013988 }
1014989 } else if w.Overflow == OverflowFixed {
1015990 r = rects[i]
@@ -1042,51 +1017,11 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
10421017 }
10431018 }
10441019 if w.policy().wraps && len(w.Columns) > 0 {
1045- span = w.Usable.W + 2*off
1020+ span = w.Usable.W
10461021 }
10471022 return tiles, span, sel
10481023 }
10491024
1050-// bandCardStep is the unscaled step between the cards of a cascade band
1051-// whose maximized column hides the others: the stack peek step.
1052-func (w *Workspace) bandCardStep() int {
1053- return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep)))
1054-}
1055-
1056-// hasBandCards reports whether the focused column of a cascade is
1057-// maximized and hides other columns of its band: the overview shows them
1058-// as cards behind it, peeking out up to two card steps on each side.
1059-func (w *Workspace) hasBandCards() bool {
1060- if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {
1061- return false
1062- }
1063- first, last := w.laneBounds(w.laneOf(w.Focus))
1064- return first < last
1065-}
1066-
1067-// appendBandCard appends hidden column i of a cascade band as a dimmed card
1068-// at its cell size behind the maximized column, on its side: the nearest
1069-// column one step out from the maximized one, the next ones two steps out,
1070-// like the cards of a stack. The maximized column keeps its full size two
1071-// steps right of the row's start (see previewTilesInto).
1072-func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1073- g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1074- r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
1075- k := min(max(i-w.Focus, w.Focus-i), 2)
1076- if i < w.Focus {
1077- r.X = g + (2-k)*s
1078- } else {
1079- r.X = w.Usable.W - g + (2+k)*s - r.W
1080- }
1081- w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1082- for j, t := range w.rowBuf {
1083- p := Placement{ID: c.Windows[j], Rect: t}
1084- p.peeking(1)
1085- tiles = append(tiles, p)
1086- }
1087- return tiles
1088-}
1089-
10901025 // previewRow scales the tiles of w into a row placed in slot, centred
10911026 // in the slot's width, or scrolled so the focused column shows when the
10921027 // row is wider, right of the stash pile. Dimmed rows (neighbor
@@ -1365,14 +1300,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13651300 d := sign(m.ov.scrollX)
13661301 if cascade && finger {
13671302 before = m.overviewSelection()
1368- // One step per gesture: a card behind a maximized
1369- // column first, like the arrows, else one workspace,
1370- // even over a stash card.
1371- if m.bandCardsAt(m.Current()) {
1372- m.OverviewMove(d, 0)
1373- } else {
1374- m.overviewStep(d)
1375- }
1303+ // One step per gesture: one workspace, even over a
1304+ // stash card.
1305+ m.overviewStep(d)
13761306 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
13771307 return m.overviewSelection() != before
13781308 }
@@ -1392,12 +1322,6 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
13921322 }
13931323 }
13941324
1395-// bandCardsAt reports whether the selection of w is a maximized cascade
1396-// column hiding cards of its band: not a stash card nor a covering float.
1397-func (m *Monitor) bandCardsAt(w *Workspace) bool {
1398- return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns
1399-}
1400-
14011325 // overviewSelection is what an overview step can move: the workspace, its
14021326 // stack front, stash card, focused column and selected window.
14031327 type overviewSelection struct {
@@ -302,7 +302,7 @@ func restoreRow(w *Workspace, s rowSnapshot) {
302 // Fixed overflow maximizes at most one column: only the one holding302 // Fixed overflow maximizes at most one column: only the one holding
303 // the anchor window, wherever active binds moved it. Scroll overflow303 // the anchor window, wherever active binds moved it. Scroll overflow
304 // keeps each column's own width.304 // keeps each column's own width.
305- if w.policy().equalCells {305+ if w.policy().maximizeHides() {
306 w.unmaximize()306 w.unmaximize()
307 for i := range w.Columns {307 for i := range w.Columns {
308 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {308 if s.fullWidth != 0 && slices.Contains(w.Columns[i].Windows, s.fullWidth) {
@@ -460,11 +460,8 @@ func (m *Monitor) overviewAcross(dx int) {
460 m.clearCard()460 m.clearCard()
461 m.ov.selected = 0461 m.ov.selected = 0
462 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {462 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
463- // The front column: a maximized one keeps its cards behind.463+ // The first column of the focused lane.
464- i := w.Focus464+ i, _ := w.laneBounds(w.laneOf(w.Focus))
465- if !w.hasBandCards() {
466- i, _ = w.laneBounds(w.laneOf(w.Focus))
467- }
468 m.selectLaneColumn(w, i)465 m.selectLaneColumn(w, i)
469 }466 }
470 return467 return
@@ -863,8 +860,7 @@ func (w *Workspace) overviewZoom() float64 {
863 }860 }
864 861
865 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A862 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
866-// cascade workspace spans one screen whatever its band count, plus the room863+// cascade workspace spans one screen whatever its band count.
867-// of the cards a maximized column hides.
868 func (w *Workspace) zoomSpan() (float64, int) {864 func (w *Workspace) zoomSpan() (float64, int) {
869 // Only the span is read: the tiles go in the zoom scratch, dead on return.865 // Only the span is read: the tiles go in the zoom scratch, dead on return.
870 var span int866 var span int
@@ -979,37 +975,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
979 rects = w.colBuf975 rects = w.colBuf
980 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.976 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
981 }977 }
982- // off is the room, left of the band, for the cards a maximized
983- // cascade column hides (the same room is kept on its right).
984- off := 0
985- if w.hasBandCards() {
986- off = 2 * w.bandCardStep()
987- // The columns the maximized one hides are cards behind it,
988- // farthest first so the nearest paint over them.
989- first, last := w.laneBounds(w.laneOf(w.Focus))
990- for d := last - first; d > 0; d-- {
991- if i := w.Focus - d; i >= first {
992- tiles = w.appendBandCard(tiles, i)
993- }
994- if i := w.Focus + d; i <= last {
995- tiles = w.appendBandCard(tiles, i)
996- }
997- }
998- }
999 for i, c := range w.Columns {978 for i, c := range w.Columns {
1000 r := Rect{Y: g, H: h}979 r := Rect{Y: g, H: h}
1001 if w.policy().wraps {980 if w.policy().wraps {
1002- if w.hiddenByMaximized(i) {
1003- continue // A card, above.
1004- }
1005 // The band is the lane: it is encoded in Y, one screen apart.981 // The band is the lane: it is encoded in Y, one screen apart.
1006- // A maximized column fills its band as on screen; the cards982+ // A maximized column fills its band, alone, as on screen.
1007- // it hides peek out on either side, past the band.983+ r.X = w.cellX(i)
1008- r.X = off + w.cellX(i)
1009 r.Y = g + w.band(i)*w.Usable.H984 r.Y = g + w.band(i)*w.Usable.H
1010 r.W = w.cellWidth(i)985 r.W = w.cellWidth(i)
1011 if c.FullWidth {986 if c.FullWidth {
1012- r.X, r.W = off+g, max(w.Usable.W-2*g, 0)987+ r.X, r.W = g, max(w.Usable.W-2*g, 0)
1013 }988 }
1014 } else if w.Overflow == OverflowFixed {989 } else if w.Overflow == OverflowFixed {
1015 r = rects[i]990 r = rects[i]
@@ -1042,51 +1017,11 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
1042 }1017 }
1043 }1018 }
1044 if w.policy().wraps && len(w.Columns) > 0 {1019 if w.policy().wraps && len(w.Columns) > 0 {
1045- span = w.Usable.W + 2*off1020+ span = w.Usable.W
1046 }1021 }
1047 return tiles, span, sel1022 return tiles, span, sel
1048 }1023 }
1049 1024
1050-// bandCardStep is the unscaled step between the cards of a cascade band
1051-// whose maximized column hides the others: the stack peek step.
1052-func (w *Workspace) bandCardStep() int {
1053- return max(1, int(math.Round(float64(w.Usable.H)*overviewPeekStep)))
1054-}
1055-
1056-// hasBandCards reports whether the focused column of a cascade is
1057-// maximized and hides other columns of its band: the overview shows them
1058-// as cards behind it, peeking out up to two card steps on each side.
1059-func (w *Workspace) hasBandCards() bool {
1060- if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {
1061- return false
1062- }
1063- first, last := w.laneBounds(w.laneOf(w.Focus))
1064- return first < last
1065-}
1066-
1067-// appendBandCard appends hidden column i of a cascade band as a dimmed card
1068-// at its cell size behind the maximized column, on its side: the nearest
1069-// column one step out from the maximized one, the next ones two steps out,
1070-// like the cards of a stack. The maximized column keeps its full size two
1071-// steps right of the row's start (see previewTilesInto).
1072-func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1073- g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1074- r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
1075- k := min(max(i-w.Focus, w.Focus-i), 2)
1076- if i < w.Focus {
1077- r.X = g + (2-k)*s
1078- } else {
1079- r.X = w.Usable.W - g + (2+k)*s - r.W
1080- }
1081- w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1082- for j, t := range w.rowBuf {
1083- p := Placement{ID: c.Windows[j], Rect: t}
1084- p.peeking(1)
1085- tiles = append(tiles, p)
1086- }
1087- return tiles
1088-}
1089-
1090 // previewRow scales the tiles of w into a row placed in slot, centred1025 // previewRow scales the tiles of w into a row placed in slot, centred
1091 // in the slot's width, or scrolled so the focused column shows when the1026 // in the slot's width, or scrolled so the focused column shows when the
1092 // row is wider, right of the stash pile. Dimmed rows (neighbor1027 // row is wider, right of the stash pile. Dimmed rows (neighbor
@@ -1365,14 +1300,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1365 d := sign(m.ov.scrollX)1300 d := sign(m.ov.scrollX)
1366 if cascade && finger {1301 if cascade && finger {
1367 before = m.overviewSelection()1302 before = m.overviewSelection()
1368- // One step per gesture: a card behind a maximized1303+ // One step per gesture: one workspace, even over a
1369- // column first, like the arrows, else one workspace,1304+ // stash card.
1370- // even over a stash card.1305+ m.overviewStep(d)
1371- if m.bandCardsAt(m.Current()) {
1372- m.OverviewMove(d, 0)
1373- } else {
1374- m.overviewStep(d)
1375- }
1376 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1306 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1377 return m.overviewSelection() != before1307 return m.overviewSelection() != before
1378 }1308 }
@@ -1392,12 +1322,6 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1392 }1322 }
1393 }1323 }
1394 1324
1395-// bandCardsAt reports whether the selection of w is a maximized cascade
1396-// column hiding cards of its band: not a stash card nor a covering float.
1397-func (m *Monitor) bandCardsAt(w *Workspace) bool {
1398- return m.cardAt(w) < 0 && w.hasBandCards() && m.stackFront(w).kind == stackColumns
1399-}
1400-
1401 // overviewSelection is what an overview step can move: the workspace, its1325 // overviewSelection is what an overview step can move: the workspace, its
1402 // stack front, stash card, focused column and selected window.1326 // stack front, stash card, focused column and selected window.
1403 type overviewSelection struct {1327 type overviewSelection struct {
M internal/core/overview_cascade_test.go +1 -291
@@ -75,182 +75,6 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
7575 }
7676 }
7777
78-// A maximized column keeps its full size in the preview (the workspace's
79-// ratio); the columns it hides are dimmed cards at their cell size behind
80-// it, peeking out on their side, the nearest one step, the farther ones two.
81-// The row widens by that room; other bands keep their cells.
82-func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
83- // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
84- for _, tc := range []struct {
85- n, max int
86- cards []int // per column: 0 not a card, -k/+k k steps left/right
87- order []int // paint order of band 0's columns
88- }{
89- {n: 1, max: 0, cards: []int{0}, order: []int{0}},
90- {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
91- {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92- {n: 4, max: 3, cards: []int{0, 0, 0, 0}},
93- // Partial bands: the card has its band's half width.
94- {n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}},
95- {n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}},
96- } {
97- w := cascadeWorkspace(tc.n)
98- w.Focus = tc.max
99- w.maximize(tc.max)
100- tiles, span, sel := w.previewTiles()
101- g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
102- off := 0
103- if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
104- off = 2 * s
105- }
106- if span != w.Usable.W+2*off {
107- t.Fatalf("n=%d max=%d span %d, want %d", tc.n, tc.max, span, w.Usable.W+2*off)
108- }
109- var order []int
110- for _, p := range tiles {
111- i := int(p.ID) - 1
112- if i/3 == 0 && tc.order != nil {
113- order = append(order, i)
114- }
115- band := i / 3
116- want := Rect{X: off + w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
117- switch k := tc.cards[i]; {
118- case i == tc.max:
119- // Full size, as on screen: the workspace's ratio.
120- want.X, want.W = off+g, w.Usable.W-2*g
121- case k < 0:
122- want.X = g + (2+k)*s
123- case k > 0:
124- want.X = w.Usable.W - g + (2+k)*s - want.W
125- }
126- if p.Rect.X < 0 || p.Rect.X+p.Rect.W > span {
127- t.Fatalf("n=%d max=%d column %d %+v outside span %d", tc.n, tc.max, i, p.Rect, span)
128- }
129- if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
130- t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
131- }
132- // The 900-wide band split among its columns, no gaps.
133- if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand {
134- t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand)
135- }
136- if i == tc.max && sel != want {
137- t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
138- }
139- }
140- if tc.order != nil && !slices.Equal(order, tc.order) {
141- t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
142- }
143- }
144-}
145-
146-// Moving onto a card behind a maximized column hands it the maximization;
147-// Return keeps it there, Escape gives it back. Moving to another band
148-// unmaximizes as before.
149-func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
150- for _, d := range cascadeDrivers {
151- t.Run(d.name, func(t *testing.T) {
152- m := cascadeOverview()
153- w := m.Current()
154- w.FocusID(9) // band 2 holds 7, 8, 9
155- w.ToggleFullWidth()
156- m.ToggleOverview()
157- for _, id := range []WindowID{7, 8} {
158- if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
159- t.Fatalf("card %d: %+v", id, p)
160- }
161- }
162- d.move(m, -1, 0)
163- if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
164- t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
165- }
166- if p := previewOf(t, m.Layout(), 9); !p.Peek {
167- t.Fatalf("former maximized column not a card: %+v", p)
168- }
169- m.CancelOverview()
170- if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
171- t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
172- }
173-
174- m.ToggleOverview()
175- d.move(m, -1, 0)
176- d.move(m, -1, 0)
177- m.ToggleOverview() // return
178- if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
179- t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
180- }
181-
182- m.ToggleOverview()
183- d.move(m, 0, -1)
184- if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
185- t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
186- }
187- })
188- }
189-}
190-
191-// A click on a window of a card's column records that window in the
192-// maximize history, not the column's former focus.
193-func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
194- m := cascadeOverview()
195- w := m.Current()
196- // Column 8 stacks 8 over 50, 8 focused.
197- w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50)
198- w.FocusID(7)
199- w.ToggleFullWidth()
200- m.ToggleOverview()
201- m.OverviewPick(50)
202- if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 {
203- t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m))
204- }
205-}
206-
207-// A FullWidth column outside the focused band fills its band, shifted like
208-// every tile by the room of the focused column's cards.
209-func TestOverviewCascadeUnfocusedMaximizedFillsBand(t *testing.T) {
210- w := cascadeWorkspace(6)
211- w.SetGaps(8)
212- w.Focus = 4
213- w.maximize(4)
214- w.Columns[0].FullWidth = true
215- tiles, _, _ := w.previewTiles()
216- // The row keeps room for the focused column's cards on its left.
217- g, off := w.gap(), 2*w.bandCardStep()
218- p := previewOf(t, tiles, 1)
219- if p.Hidden || p.Rect.X != off+g || p.Rect.W != w.Usable.W-2*g {
220- t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, off+g, w.Usable.W-2*g)
221- }
222-}
223-
224-// A click on a card behind a maximized cascade column hands it the
225-// maximization.
226-func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
227- m := cascadeOverview()
228- w := m.Current()
229- w.FocusID(7)
230- w.ToggleFullWidth()
231- m.ToggleOverview()
232- // Each card's strip right of the maximized column hits it: 8 next to
233- // it, 9 at the band's edge.
234- ps := m.Layout()
235- front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect
236- for _, c := range []struct {
237- x0, x1 int
238- want WindowID
239- }{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} {
240- x := (c.x0 + c.x1) / 2
241- if c.x0 >= c.x1 {
242- t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far)
243- }
244- if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want {
245- t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want)
246- }
247- }
248- m.OverviewPick(9)
249- if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
250- t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
251- }
252-}
253-
25478 // The selected band is the centre lane: undimmed, centred, selected tile
25579 // focused.
25680 func TestOverviewCascadeCentreLane(t *testing.T) {
@@ -643,122 +467,8 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
643467 }
644468 }
645469
646-// Three fingers sideways walk the cards behind a maximized column one per
647-// gesture, like the arrows, then change workspace past the band's edge.
648-func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) {
649- c, m := cascadeCore(t)
650- w := m.Current()
651- w.ToggleFullWidth() // Column 2 hides 3, the other column of its band.
652- m.ToggleOverview()
653- swipe := func(dx float64) {
654- t.Helper()
655- c.swipeBegin(ports.SwipeBegin{Fingers: 3})
656- for range 4 {
657- c.swipeUpdate(ports.SwipeUpdate{DX: dx})
658- }
659- c.swipeEnd(ports.SwipeEnd{})
660- }
661- swipe(40)
662- if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth {
663- t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
664- }
665- swipe(-40)
666- if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
667- t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
668- }
669- swipe(40)
670- swipe(40)
671- if m.Current() != m.Workspaces[2] {
672- t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current()))
673- }
674-
675- // A covering float in front: the swipe changes workspace, as without
676- // a maximized column.
677- m.CancelOverview()
678- w.AddFloating(90, 300, 200)
679- m.ToggleOverview()
680- if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth {
681- t.Fatalf("setup: front %+v", f)
682- }
683- swipe(-40)
684- if m.Current() != m.Workspaces[0] {
685- t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current()))
686- }
687-}
688-
689-// Left from the first column of a band whose maximized column hides it:
690-// the arrows and three fingers walk the cards, then enter the stash, or
691-// change workspace without one.
692-func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) {
693- for _, stash := range []bool{false, true} {
694- c, m := cascadeCore(t)
695- w := m.Current()
696- if stash {
697- w.AddWindow(40)
698- w.FocusID(40)
699- w.ToggleWindowStash()
700- }
701- w.FocusID(3) // Band {2, 3}: 3 hides 2.
702- w.ToggleFullWidth()
703- m.ToggleOverview()
704- swipe := func() {
705- c.swipeBegin(ports.SwipeBegin{Fingers: 3})
706- for range 4 {
707- c.swipeUpdate(ports.SwipeUpdate{DX: -40})
708- }
709- c.swipeEnd(ports.SwipeEnd{})
710- }
711- swipe()
712- if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
713- t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget())
714- }
715- swipe()
716- if stash {
717- if m.Current() != w || m.cardAt(w) < 0 {
718- t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w))
719- }
720- } else if m.Current() != m.Workspaces[0] {
721- t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current()))
722- }
723- }
724-}
725-
726-// With four or more columns in a band, the cards three or more columns
727-// away sit under the two-step card: arrows still reach every one, in
728-// order, each taking the maximization.
729-func TestOverviewCascadeFarCardsByArrows(t *testing.T) {
730- m := newMonitor("", "")
731- m.SetOutput(1200, 600)
732- m.SetMaxColumns(5)
733- w := m.Current()
734- w.Overflow = OverflowCascade
735- for id := WindowID(1); id <= 5; id++ {
736- m.AddWindow(id)
737- }
738- w.FocusID(1)
739- w.ToggleFullWidth()
740- m.ToggleOverview()
741- ps := m.Layout()
742- edge := previewOf(t, ps, 3).Rect
743- for _, id := range []WindowID{4, 5} {
744- if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X {
745- t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X)
746- }
747- }
748- for _, want := range []WindowID{2, 3, 4, 5} {
749- m.OverviewMove(1, 0)
750- if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth {
751- t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want)
752- }
753- }
754- m.CancelOverview()
755- if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth {
756- t.Fatalf("escape: focus %d, want maximized 1", focusedID(m))
757- }
758-}
759-
760470 // Right from a stash card lands on the maximized column, which keeps its
761-// maximization, not on the first card of its band.
471+// maximization: it is alone in its band.
762472 func TestOverviewCascadeStashToMaximized(t *testing.T) {
763473 m := cascadeOverview()
764474 withStash(m, 1)
@@ -75,182 +75,6 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
75 }75 }
76 }76 }
77 77
78-// A maximized column keeps its full size in the preview (the workspace's
79-// ratio); the columns it hides are dimmed cards at their cell size behind
80-// it, peeking out on their side, the nearest one step, the farther ones two.
81-// The row widens by that room; other bands keep their cells.
82-func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
83- // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
84- for _, tc := range []struct {
85- n, max int
86- cards []int // per column: 0 not a card, -k/+k k steps left/right
87- order []int // paint order of band 0's columns
88- }{
89- {n: 1, max: 0, cards: []int{0}, order: []int{0}},
90- {n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
91- {n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92- {n: 4, max: 3, cards: []int{0, 0, 0, 0}},
93- // Partial bands: the card has its band's half width.
94- {n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}},
95- {n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}},
96- } {
97- w := cascadeWorkspace(tc.n)
98- w.Focus = tc.max
99- w.maximize(tc.max)
100- tiles, span, sel := w.previewTiles()
101- g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
102- off := 0
103- if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
104- off = 2 * s
105- }
106- if span != w.Usable.W+2*off {
107- t.Fatalf("n=%d max=%d span %d, want %d", tc.n, tc.max, span, w.Usable.W+2*off)
108- }
109- var order []int
110- for _, p := range tiles {
111- i := int(p.ID) - 1
112- if i/3 == 0 && tc.order != nil {
113- order = append(order, i)
114- }
115- band := i / 3
116- want := Rect{X: off + w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
117- switch k := tc.cards[i]; {
118- case i == tc.max:
119- // Full size, as on screen: the workspace's ratio.
120- want.X, want.W = off+g, w.Usable.W-2*g
121- case k < 0:
122- want.X = g + (2+k)*s
123- case k > 0:
124- want.X = w.Usable.W - g + (2+k)*s - want.W
125- }
126- if p.Rect.X < 0 || p.Rect.X+p.Rect.W > span {
127- t.Fatalf("n=%d max=%d column %d %+v outside span %d", tc.n, tc.max, i, p.Rect, span)
128- }
129- if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
130- t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
131- }
132- // The 900-wide band split among its columns, no gaps.
133- if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand {
134- t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand)
135- }
136- if i == tc.max && sel != want {
137- t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
138- }
139- }
140- if tc.order != nil && !slices.Equal(order, tc.order) {
141- t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
142- }
143- }
144-}
145-
146-// Moving onto a card behind a maximized column hands it the maximization;
147-// Return keeps it there, Escape gives it back. Moving to another band
148-// unmaximizes as before.
149-func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
150- for _, d := range cascadeDrivers {
151- t.Run(d.name, func(t *testing.T) {
152- m := cascadeOverview()
153- w := m.Current()
154- w.FocusID(9) // band 2 holds 7, 8, 9
155- w.ToggleFullWidth()
156- m.ToggleOverview()
157- for _, id := range []WindowID{7, 8} {
158- if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
159- t.Fatalf("card %d: %+v", id, p)
160- }
161- }
162- d.move(m, -1, 0)
163- if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
164- t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
165- }
166- if p := previewOf(t, m.Layout(), 9); !p.Peek {
167- t.Fatalf("former maximized column not a card: %+v", p)
168- }
169- m.CancelOverview()
170- if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
171- t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
172- }
173-
174- m.ToggleOverview()
175- d.move(m, -1, 0)
176- d.move(m, -1, 0)
177- m.ToggleOverview() // return
178- if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
179- t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
180- }
181-
182- m.ToggleOverview()
183- d.move(m, 0, -1)
184- if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
185- t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
186- }
187- })
188- }
189-}
190-
191-// A click on a window of a card's column records that window in the
192-// maximize history, not the column's former focus.
193-func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
194- m := cascadeOverview()
195- w := m.Current()
196- // Column 8 stacks 8 over 50, 8 focused.
197- w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50)
198- w.FocusID(7)
199- w.ToggleFullWidth()
200- m.ToggleOverview()
201- m.OverviewPick(50)
202- if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 {
203- t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m))
204- }
205-}
206-
207-// A FullWidth column outside the focused band fills its band, shifted like
208-// every tile by the room of the focused column's cards.
209-func TestOverviewCascadeUnfocusedMaximizedFillsBand(t *testing.T) {
210- w := cascadeWorkspace(6)
211- w.SetGaps(8)
212- w.Focus = 4
213- w.maximize(4)
214- w.Columns[0].FullWidth = true
215- tiles, _, _ := w.previewTiles()
216- // The row keeps room for the focused column's cards on its left.
217- g, off := w.gap(), 2*w.bandCardStep()
218- p := previewOf(t, tiles, 1)
219- if p.Hidden || p.Rect.X != off+g || p.Rect.W != w.Usable.W-2*g {
220- t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, off+g, w.Usable.W-2*g)
221- }
222-}
223-
224-// A click on a card behind a maximized cascade column hands it the
225-// maximization.
226-func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
227- m := cascadeOverview()
228- w := m.Current()
229- w.FocusID(7)
230- w.ToggleFullWidth()
231- m.ToggleOverview()
232- // Each card's strip right of the maximized column hits it: 8 next to
233- // it, 9 at the band's edge.
234- ps := m.Layout()
235- front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect
236- for _, c := range []struct {
237- x0, x1 int
238- want WindowID
239- }{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} {
240- x := (c.x0 + c.x1) / 2
241- if c.x0 >= c.x1 {
242- t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far)
243- }
244- if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want {
245- t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want)
246- }
247- }
248- m.OverviewPick(9)
249- if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
250- t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
251- }
252-}
253-
254 // The selected band is the centre lane: undimmed, centred, selected tile78 // The selected band is the centre lane: undimmed, centred, selected tile
255 // focused.79 // focused.
256 func TestOverviewCascadeCentreLane(t *testing.T) {80 func TestOverviewCascadeCentreLane(t *testing.T) {
@@ -643,122 +467,8 @@ func TestOverviewCascadeThreeFinger(t *testing.T) {
643 }467 }
644 }468 }
645 469
646-// Three fingers sideways walk the cards behind a maximized column one per
647-// gesture, like the arrows, then change workspace past the band's edge.
648-func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) {
649- c, m := cascadeCore(t)
650- w := m.Current()
651- w.ToggleFullWidth() // Column 2 hides 3, the other column of its band.
652- m.ToggleOverview()
653- swipe := func(dx float64) {
654- t.Helper()
655- c.swipeBegin(ports.SwipeBegin{Fingers: 3})
656- for range 4 {
657- c.swipeUpdate(ports.SwipeUpdate{DX: dx})
658- }
659- c.swipeEnd(ports.SwipeEnd{})
660- }
661- swipe(40)
662- if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth {
663- t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
664- }
665- swipe(-40)
666- if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
667- t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
668- }
669- swipe(40)
670- swipe(40)
671- if m.Current() != m.Workspaces[2] {
672- t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current()))
673- }
674-
675- // A covering float in front: the swipe changes workspace, as without
676- // a maximized column.
677- m.CancelOverview()
678- w.AddFloating(90, 300, 200)
679- m.ToggleOverview()
680- if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth {
681- t.Fatalf("setup: front %+v", f)
682- }
683- swipe(-40)
684- if m.Current() != m.Workspaces[0] {
685- t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current()))
686- }
687-}
688-
689-// Left from the first column of a band whose maximized column hides it:
690-// the arrows and three fingers walk the cards, then enter the stash, or
691-// change workspace without one.
692-func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) {
693- for _, stash := range []bool{false, true} {
694- c, m := cascadeCore(t)
695- w := m.Current()
696- if stash {
697- w.AddWindow(40)
698- w.FocusID(40)
699- w.ToggleWindowStash()
700- }
701- w.FocusID(3) // Band {2, 3}: 3 hides 2.
702- w.ToggleFullWidth()
703- m.ToggleOverview()
704- swipe := func() {
705- c.swipeBegin(ports.SwipeBegin{Fingers: 3})
706- for range 4 {
707- c.swipeUpdate(ports.SwipeUpdate{DX: -40})
708- }
709- c.swipeEnd(ports.SwipeEnd{})
710- }
711- swipe()
712- if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
713- t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget())
714- }
715- swipe()
716- if stash {
717- if m.Current() != w || m.cardAt(w) < 0 {
718- t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w))
719- }
720- } else if m.Current() != m.Workspaces[0] {
721- t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current()))
722- }
723- }
724-}
725-
726-// With four or more columns in a band, the cards three or more columns
727-// away sit under the two-step card: arrows still reach every one, in
728-// order, each taking the maximization.
729-func TestOverviewCascadeFarCardsByArrows(t *testing.T) {
730- m := newMonitor("", "")
731- m.SetOutput(1200, 600)
732- m.SetMaxColumns(5)
733- w := m.Current()
734- w.Overflow = OverflowCascade
735- for id := WindowID(1); id <= 5; id++ {
736- m.AddWindow(id)
737- }
738- w.FocusID(1)
739- w.ToggleFullWidth()
740- m.ToggleOverview()
741- ps := m.Layout()
742- edge := previewOf(t, ps, 3).Rect
743- for _, id := range []WindowID{4, 5} {
744- if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X {
745- t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X)
746- }
747- }
748- for _, want := range []WindowID{2, 3, 4, 5} {
749- m.OverviewMove(1, 0)
750- if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth {
751- t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want)
752- }
753- }
754- m.CancelOverview()
755- if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth {
756- t.Fatalf("escape: focus %d, want maximized 1", focusedID(m))
757- }
758-}
759-
760 // Right from a stash card lands on the maximized column, which keeps its470 // Right from a stash card lands on the maximized column, which keeps its
761-// maximization, not on the first card of its band.471+// maximization: it is alone in its band.
762 func TestOverviewCascadeStashToMaximized(t *testing.T) {472 func TestOverviewCascadeStashToMaximized(t *testing.T) {
763 m := cascadeOverview()473 m := cascadeOverview()
764 withStash(m, 1)474 withStash(m, 1)
M internal/core/overview_stack.go +1 -5
@@ -214,11 +214,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
214214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
215215 func (w *Workspace) selectOverviewColumn(i int) {
216216 w.floatFocus, w.stashFocus = false, false
217- if w.policy().wraps && w.hiddenByMaximized(i) {
218- // A card behind a maximized cascade column takes the
219- // maximization; Escape gives it back (restoreRow).
220- w.transferMaximization(i)
221- } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
217+ if w.policy().maximizeHides() && w.Focus != i && w.Focus < len(w.Columns) {
222218 w.unmaximize()
223219 }
224220 w.Focus = i
@@ -214,11 +214,7 @@ func (m *Monitor) hiddenColumn(w *Workspace) int {
214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.214 // selectOverviewColumn retains the existing ordinary-row live focus semantics.
215 func (w *Workspace) selectOverviewColumn(i int) {215 func (w *Workspace) selectOverviewColumn(i int) {
216 w.floatFocus, w.stashFocus = false, false216 w.floatFocus, w.stashFocus = false, false
217- if w.policy().wraps && w.hiddenByMaximized(i) {217+ if w.policy().maximizeHides() && w.Focus != i && w.Focus < len(w.Columns) {
218- // A card behind a maximized cascade column takes the
219- // maximization; Escape gives it back (restoreRow).
220- w.transferMaximization(i)
221- } else if w.policy().equalCells && w.Focus != i && w.Focus < len(w.Columns) {
222 w.unmaximize()218 w.unmaximize()
223 }219 }
224 w.Focus = i220 w.Focus = i
M internal/core/rules.go +1 -1
@@ -190,7 +190,7 @@ func (w *Workspace) addColumnQuiet(col Column) {
190190 return
191191 }
192192 if w.policy().equalCells {
193- if at > 0 {
193+ if at > 0 && w.policy().maximizeHides() {
194194 w.unmaximize()
195195 }
196196 } else if at > 0 {
@@ -190,7 +190,7 @@ func (w *Workspace) addColumnQuiet(col Column) {
190 return190 return
191 }191 }
192 if w.policy().equalCells {192 if w.policy().equalCells {
193- if at > 0 {193+ if at > 0 && w.policy().maximizeHides() {
194 w.unmaximize()194 w.unmaximize()
195 }195 }
196 } else if at > 0 {196 } else if at > 0 {
M internal/core/stash.go +1 -1
@@ -132,7 +132,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
132132 }
133133 c.Windows = slices.Insert(c.Windows, row, id)
134134 c.Focus = row
135- if w.policy().equalCells && w.Focus != i {
135+ if w.policy().maximizeHides() && w.Focus != i {
136136 w.unmaximize()
137137 }
138138 w.Focus = i
@@ -132,7 +132,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
132 }132 }
133 c.Windows = slices.Insert(c.Windows, row, id)133 c.Windows = slices.Insert(c.Windows, row, id)
134 c.Focus = row134 c.Focus = row
135- if w.policy().equalCells && w.Focus != i {135+ if w.policy().maximizeHides() && w.Focus != i {
136 w.unmaximize()136 w.unmaximize()
137 }137 }
138 w.Focus = i138 w.Focus = i
M internal/core/switcher.go +1 -1
@@ -165,7 +165,7 @@ func (m *Monitor) commitSwitcher() bool {
165165 if i == w.Focus && !w.floatFocus && !w.stashFocused() {
166166 return true
167167 }
168- if i != w.Focus && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
168+ if i != w.Focus && !w.policy().wraps && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
169169 w.transferMaximization(i)
170170 }
171171 // Activate leaves another window's fullscreen and handles dialogs.
@@ -165,7 +165,7 @@ func (m *Monitor) commitSwitcher() bool {
165 if i == w.Focus && !w.floatFocus && !w.stashFocused() {165 if i == w.Focus && !w.floatFocus && !w.stashFocused() {
166 return true166 return true
167 }167 }
168- if i != w.Focus && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {168+ if i != w.Focus && !w.policy().wraps && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth {
169 w.transferMaximization(i)169 w.transferMaximization(i)
170 }170 }
171 // Activate leaves another window's fullscreen and handles dialogs.171 // Activate leaves another window's fullscreen and handles dialogs.
M internal/core/switcher_test.go +7 -0
@@ -363,6 +363,13 @@ func TestSwitchCommitTransfersMaximization(t *testing.T) {
363363 }
364364 openSwitcher(m, 1)
365365 m.commitSwitcher()
366+ if overflow == OverflowCascade {
367+ // The maximized column has its own band: it stays.
368+ if id, _ := w.Focused(); w.Columns[w.columnOf(id)].FullWidth || !w.Columns[a].FullWidth {
369+ t.Fatalf("cascade: maximization moved: %+v", w.Columns)
370+ }
371+ return
372+ }
366373 if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {
367374 t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)
368375 }
@@ -363,6 +363,13 @@ func TestSwitchCommitTransfersMaximization(t *testing.T) {
363 }363 }
364 openSwitcher(m, 1)364 openSwitcher(m, 1)
365 m.commitSwitcher()365 m.commitSwitcher()
366+ if overflow == OverflowCascade {
367+ // The maximized column has its own band: it stays.
368+ if id, _ := w.Focused(); w.Columns[w.columnOf(id)].FullWidth || !w.Columns[a].FullWidth {
369+ t.Fatalf("cascade: maximization moved: %+v", w.Columns)
370+ }
371+ return
372+ }
366 if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {373 if w.Focus != b || !w.Columns[b].FullWidth || w.Columns[a].FullWidth {
367 t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)374 t.Fatalf("focus %d, FullWidth source %v target %v", w.Focus, w.Columns[a].FullWidth, w.Columns[b].FullWidth)
368 }375 }

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close