feat(core): stack fixed-mode overview columns and order covering floats (#66)
* fix(core): show a covering fullscreen float in the overview A fixed-size game maps as a native float; the overview hid every float, so a workspace covered by a fullscreen game showed an empty row. The covering float is now that row's only preview, clickable and selectable; its dialogs and the columns under it stay hidden. Also fixes a panic when a window maps under a covering float with no column (scroll overflow). * fix(core): configure a covering float fullscreen in its overview preview A float made fullscreen while hidden kept its non-fullscreen configure in the overview preview. The preview placement now carries Fullscreen, and configures sizes such a window for the output, never the preview. Documents that the overview shows a fullscreen float. * fix(core): open the overview over a covering fullscreen window Cmd+o did nothing on a workspace covered by a fullscreen window (a game on its own workspace). The overview now opens there too: the game's row is selected, composed while the overview is open, and Escape returns to it. * fix(core): show bars and pin selection in the overview over fullscreen Opened over a covering fullscreen window, the overview kept bars and notifications hidden, and in fixed overflow a tiled fullscreen window's hidden columns stayed selectable while Return kept the fullscreen window in front. The overview now shows the layers, and any pinned fullscreen window (tiled or floating) is its row's only preview, as on screen. * docs(config): describe the overview over fixed and scroll fullscreen Documents that only pinned fullscreen windows (floating, or fixed overflow) are their row's only preview, and tests picking another column over a scroll-overflow fullscreen column. * feat(core): order covering floats behind user-focused columns * fix(core): keep float focus alone and raise columns on fullscreen return Directional focus on a covering float with no column or stash under it kept nothing focused; the float now keeps the focus. A tile returning focused from its own fullscreen workspace is raised over a covering float mapped in its origin meanwhile. * feat(core): show covering float stack cards in overview * fix(core): bring columns front from the stash and drop the float veil in the overview Right past the last stash card now brings the column group to the front of a covering-float stack. Stack previews are no longer marked floating, so the overview draws no output-wide float veil or float border. * fix(core): keep stack previews floating without the float veil Stack previews of covering floats stay floating so a float first seen in the overview is configured as a float, sized by the client. The float veil and the tile/float line boundary skip overview previews instead. * fix(core): match fixed overflow overview previews to screen geometry * fix(core): raise demoted covering float with focus-window-up * test(core): cover focus-window-up from the stash and a pinned fullscreen A shown stash keeps the focus on up with a demoted covering float, and a pinned fullscreen window still goes to the workspace above. * feat(core): show hidden fixed columns as overview stack cards * fix(core): restore overview maximization and track hidden selection * fix(core): restore one maximized column on overview cancel Escape anchored the maximization on every window of the opening column, so a column split while the overview was open could leave two maximized columns. It now anchors on the window focused at opening, where Escape returns the focus, and maximizes only its column. * fix(core): keep scroll overflow maximized columns on overview cancel Only fixed overflow maximizes at most one column; Escape now restores the anchor there and leaves scroll overflow columns as they are. * refactor(core): unify overview stack, state and card placement * feat(core): navigate overview cards as a vertical stack * fix(core): configure real sizes of windows resized during the overview * fix(core): align the stash pile with stacked rows * fix(core): show hidden columns behind a maximized column as one card * fix(core): route focus binds through overview navigation and keep rows centred beside the stash * fix(core): lay out hidden columns without the maximized column's slot * fix(overview): align window actions with preview selection * fix(overview): follow highlighted column after layout mutations * fix(core): preserve float focus across lifecycle transitions * fix(overview): commit the clicked window in stacked column cards * test(overview): cover Vim focus binds through keyboard events * docs(test): describe Vim overview navigation coverage
- commit
- 5117e75e8cb294b5d13a2b184bb95b6023840b13
- author
- bnema · · 2026-09-30 02:51 UTC
- committer
- GitHub · · 2026-09-30 02:51 UTC
- parent
- 09abb12
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
27 files · +2791 -241
M docs/config.md +13 -11
| @@ -22,7 +22,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 22 | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | |
| 23 | 23 | | `background` | `#111111` | Solid background color | | |
| 24 | 24 | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | |
| 25 | - | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 | | |
| 25 | + | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window above the tiles or the stash is visible; opacity 0 to 1 | | |
| 26 | 26 | | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height | | |
| 27 | 27 | | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them | | |
| 28 | 28 | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | |
| @@ -114,7 +114,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 114 | 114 | | `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 | | |
| 115 | 115 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | |
| 116 | 116 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| 117 | - | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first | | |
| 117 | + | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns | | |
| 118 | 118 | | `move-column-left/right` | Move the focused column | | |
| 119 | 119 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | |
| 120 | 120 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | |
| @@ -132,24 +132,26 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle | |||
| 132 | 132 | ||
| 133 | 133 | While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts). | |
| 134 | 134 | ||
| 135 | - | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | |
| 135 | + | Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive. | |
| 136 | 136 | ||
| 137 | 137 | ### Overview | |
| 138 | 138 | ||
| 139 | - | `toggle-overview` shows every column of the current workspace in one row, shrunk by the same factor so each window keeps its shape, as small as needed to fit (down to a quarter, then the row scrolls). The workspaces above and below show dimmed. Windows are not resized: the previews are their last frames. Native floating windows are hidden. The overview opens over a fullscreen window too. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; with `scroll` overflow the other columns stay selectable. | |
| 139 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. 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, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. | |
| 140 | 140 | ||
| 141 | 141 | | Key | Action | | |
| 142 | 142 | | --- | --- | | |
| 143 | - | | `h` / `l`, `left` / `right` | Select the column on the left / right; left of the first column, the stash pile | | |
| 144 | - | | `k` / `j`, `up` / `down` | Select the workspace above / below | | |
| 145 | - | | `return` | Close the overview on the selected window | | |
| 146 | - | | `escape` | Close it and return to the window it opened on | | |
| 143 | + | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | |
| 144 | + | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | | |
| 145 | + | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | | |
| 146 | + | | `escape` | Restore the original focus, maximization and float order | | |
| 147 | 147 | ||
| 148 | - | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the first column. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 148 | + | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. | |
| 149 | 149 | ||
| 150 | - | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through the workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | |
| 150 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 151 | 151 | ||
| 152 | - | A click on a preview picks it. Other binds still work. Native floating windows (dialogs) are not shown. | |
| 152 | + | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | |
| 153 | + | ||
| 154 | + | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | |
| 153 | 155 | ||
| 154 | 156 | ### Default binds | |
| 155 | 157 | ||
| @@ -22,7 +22,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | 22 | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | ||
| 23 | | `background` | `#111111` | Solid background color | | 23 | | `background` | `#111111` | Solid background color | | ||
| 24 | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | 24 | | `border.width` | `2` | Lines between visible neighboring tiles and around non-fullscreen floats, in logical pixels; `0` hides them | | ||
| 25 | - | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window (a dialog) or the stash is visible; opacity 0 to 1 | | 25 | + | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when a native floating window above the tiles or the stash is visible; opacity 0 to 1 | |
| 26 | | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height | | 26 | | `stash.width` | `80` | Width of the selected stashed window, over the tiles, in percent of the usable width; 10 to 90 (`80%` works too). The rest is split between both sides, where the neighbors show. Its height is 80% of the usable height | | ||
| 27 | | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them | | 27 | | `stash.gap` | `2` | Space between the selected stashed window and its neighbors, in percent of the usable width; 0 to 10. The neighbors show from there to the screen edge: with the defaults, 8% of each. A gap as wide as the side margin hides them | | ||
| 28 | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | 28 | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | ||
| @@ -114,7 +114,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 114 | | `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 | | 114 | | `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 | | ||
| 115 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | 115 | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | ||
| 116 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | 116 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | ||
| 117 | - | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first | | 117 | + | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first. Up at the top brings back a covering float put behind the columns | |
| 118 | | `move-column-left/right` | Move the focused column | | 118 | | `move-column-left/right` | Move the focused column | | ||
| 119 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | 119 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | ||
| 120 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | 120 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | ||
| @@ -132,24 +132,26 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle | |||||
| 132 | 132 | ||||
| 133 | While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts). | 133 | While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts). | ||
| 134 | 134 | ||||
| 135 | - | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | 135 | + | Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive. |
| 136 | 136 | ||||
| 137 | ### Overview | 137 | ### Overview | ||
| 138 | 138 | ||||
| 139 | - | `toggle-overview` shows every column of the current workspace in one row, shrunk by the same factor so each window keeps its shape, as small as needed to fit (down to a quarter, then the row scrolls). The workspaces above and below show dimmed. Windows are not resized: the previews are their last frames. Native floating windows are hidden. The overview opens over a fullscreen window too. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; with `scroll` overflow the other columns stay selectable. | 139 | + | `toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. 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, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card. |
| 140 | 140 | ||||
| 141 | | Key | Action | | 141 | | Key | Action | | ||
| 142 | | --- | --- | | 142 | | --- | --- | | ||
| 143 | - | | `h` / `l`, `left` / `right` | Select the column on the left / right; left of the first column, the stash pile | | 143 | + | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | |
| 144 | - | | `k` / `j`, `up` / `down` | Select the workspace above / below | | 144 | + | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | |
| 145 | - | | `return` | Close the overview on the selected window | | 145 | + | | `return` | Show the selected front card; picking a hidden column moves the maximization to it | |
| 146 | - | | `escape` | Close it and return to the window it opened on | | 146 | + | | `escape` | Restore the original focus, maximization and float order | |
| 147 | 147 | ||||
| 148 | - | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the first column. `return` or a click on a card closes the overview with the stash shown on that window. | 148 | + | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. |
| 149 | 149 | ||||
| 150 | - | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through the workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. | 150 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. |
| 151 | 151 | ||||
| 152 | - | A click on a preview picks it. Other binds still work. Native floating windows (dialogs) are not shown. | 152 | + | A four-finger swipe up opens the overview and a swipe down closes it on the selection, whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll`. A three-finger swipe moves it one step when the fingers lift. |
| 153 | + | ||||
| 154 | + | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Window mutation binds (moving, resizing, maximizing, fullscreen and stash toggles) and workspace moves are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | |||
| 153 | 155 | ||||
| 154 | ### Default binds | 156 | ### Default binds | ||
| 155 | 157 | ||||
M internal/adapters/vulkan/scene.go +4 -3
| @@ -85,15 +85,16 @@ func (w *sceneWalk) layers(afterWindows bool) { | |||
| 85 | 85 | } | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | // windows draws the tiled then floating windows with their borders. | |
| 89 | - | // Tile lines (Window 0) go over the tiles, a float's border with it. | |
| 88 | + | // windows draws ordered placements with borders. Tile lines go after | |
| 89 | + | // the column group, before the first float above it; an overview preview | |
| 90 | + | // of a float is not one. | |
| 90 | 91 | func (w *sceneWalk) windows() { | |
| 91 | 92 | tileLines := false | |
| 92 | 93 | for _, win := range w.s.Windows { | |
| 93 | 94 | if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 { | |
| 94 | 95 | continue | |
| 95 | 96 | } | |
| 96 | - | if win.Floating && !tileLines { | |
| 97 | + | if !win.Below && win.Floating && win.Preview == 0 && !tileLines { | |
| 97 | 98 | w.separators(0) | |
| 98 | 99 | tileLines = true | |
| 99 | 100 | if w.s.Dim > 0 { | |
| @@ -85,15 +85,16 @@ func (w *sceneWalk) layers(afterWindows bool) { | |||||
| 85 | } | 85 | } | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | // windows draws the tiled then floating windows with their borders. | 88 | + | // windows draws ordered placements with borders. Tile lines go after |
| 89 | - | // Tile lines (Window 0) go over the tiles, a float's border with it. | 89 | + | // the column group, before the first float above it; an overview preview |
| 90 | + | // of a float is not one. | |||
| 90 | func (w *sceneWalk) windows() { | 91 | func (w *sceneWalk) windows() { | ||
| 91 | tileLines := false | 92 | tileLines := false | ||
| 92 | for _, win := range w.s.Windows { | 93 | for _, win := range w.s.Windows { | ||
| 93 | if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 { | 94 | if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 { | ||
| 94 | continue | 95 | continue | ||
| 95 | } | 96 | } | ||
| 96 | - | if win.Floating && !tileLines { | 97 | + | if !win.Below && win.Floating && win.Preview == 0 && !tileLines { |
| 97 | w.separators(0) | 98 | w.separators(0) | ||
| 98 | tileLines = true | 99 | tileLines = true | ||
| 99 | if w.s.Dim > 0 { | 100 | if w.s.Dim > 0 { | ||
A internal/adapters/vulkan/scene_float_order_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSceneBelowFloatBoundary(t *testing.T) { | |
| 11 | + | r := &Renderer{width: 80, height: 60} | |
| 12 | + | s := ports.Scene{Border: ports.Border{Active: "#ffffff"}, Windows: []ports.SceneWindow{ | |
| 13 | + | {ID: 1, Floating: true, Below: true, Rect: ports.Rect{W: 80, H: 60}}, | |
| 14 | + | {ID: 2, Rect: ports.Rect{W: 40, H: 60}}, | |
| 15 | + | }, Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}}} | |
| 16 | + | draws := func() []draw { return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) } | |
| 17 | + | ds := draws() | |
| 18 | + | if len(ds) != 3 || ds[0].pc.rect[2] != 80 || ds[1].pc.rect[2] != 40 || ds[2].pc.rect[3] != 1 { | |
| 19 | + | t.Fatalf("below float / tile / lines: %+v", ds) | |
| 20 | + | } | |
| 21 | + | s.Dim = 0.4 | |
| 22 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}}) | |
| 23 | + | ds = draws() | |
| 24 | + | if len(ds) != 5 || ds[3].pc.color != [4]float32{0, 0, 0, 0.4} || ds[4].pc.rect[0] != 50 { | |
| 25 | + | t.Fatalf("veil boundary: %+v", ds) | |
| 26 | + | } | |
| 27 | + | // An overview preview of a float is not a float above the tiles: the | |
| 28 | + | // tile lines follow the last window. | |
| 29 | + | s.Dim = 0 | |
| 30 | + | s.Windows = []ports.SceneWindow{ | |
| 31 | + | {ID: 1, Floating: true, Preview: 0.5, Rect: ports.Rect{W: 40, H: 30}}, | |
| 32 | + | {ID: 2, Preview: 0.5, Rect: ports.Rect{X: 40, W: 40, H: 30}}, | |
| 33 | + | } | |
| 34 | + | ds = draws() | |
| 35 | + | if len(ds) != 3 || ds[2].pc.rect[3] != 1 { | |
| 36 | + | t.Fatalf("preview boundary: %+v", ds) | |
| 37 | + | } | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestSceneBelowFloatBoundary(t *testing.T) { | |||
| 11 | + | r := &Renderer{width: 80, height: 60} | |||
| 12 | + | s := ports.Scene{Border: ports.Border{Active: "#ffffff"}, Windows: []ports.SceneWindow{ | |||
| 13 | + | {ID: 1, Floating: true, Below: true, Rect: ports.Rect{W: 80, H: 60}}, | |||
| 14 | + | {ID: 2, Rect: ports.Rect{W: 40, H: 60}}, | |||
| 15 | + | }, Separators: []ports.Separator{{Rect: ports.Rect{W: 80, H: 1}, Active: true}}} | |||
| 16 | + | draws := func() []draw { return r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) } | |||
| 17 | + | ds := draws() | |||
| 18 | + | if len(ds) != 3 || ds[0].pc.rect[2] != 80 || ds[1].pc.rect[2] != 40 || ds[2].pc.rect[3] != 1 { | |||
| 19 | + | t.Fatalf("below float / tile / lines: %+v", ds) | |||
| 20 | + | } | |||
| 21 | + | s.Dim = 0.4 | |||
| 22 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Floating: true, Rect: ports.Rect{X: 50, W: 20, H: 20}}) | |||
| 23 | + | ds = draws() | |||
| 24 | + | if len(ds) != 5 || ds[3].pc.color != [4]float32{0, 0, 0, 0.4} || ds[4].pc.rect[0] != 50 { | |||
| 25 | + | t.Fatalf("veil boundary: %+v", ds) | |||
| 26 | + | } | |||
| 27 | + | // An overview preview of a float is not a float above the tiles: the | |||
| 28 | + | // tile lines follow the last window. | |||
| 29 | + | s.Dim = 0 | |||
| 30 | + | s.Windows = []ports.SceneWindow{ | |||
| 31 | + | {ID: 1, Floating: true, Preview: 0.5, Rect: ports.Rect{W: 40, H: 30}}, | |||
| 32 | + | {ID: 2, Preview: 0.5, Rect: ports.Rect{X: 40, W: 40, H: 30}}, | |||
| 33 | + | } | |||
| 34 | + | ds = draws() | |||
| 35 | + | if len(ds) != 3 || ds[2].pc.rect[3] != 1 { | |||
| 36 | + | t.Fatalf("preview boundary: %+v", ds) | |||
| 37 | + | } | |||
| 38 | + | } | |||
M internal/core/action.go +19 -6
| @@ -109,6 +109,14 @@ type Effect struct { | |||
| 109 | 109 | // screen; column focus past the edge column moves to the neighbor screen; | |
| 110 | 110 | // everything else applies to the focused screen's monitor. | |
| 111 | 111 | func (c *Core) applyAction(a Action) Effect { | |
| 112 | + | if m := c.cur().mon; m.ov.open { | |
| 113 | + | if m.overviewFocus(a) || overviewBlocks(a) { | |
| 114 | + | return Effect{} | |
| 115 | + | } | |
| 116 | + | if a == ActionCloseWindow { | |
| 117 | + | return m.Apply(a) | |
| 118 | + | } | |
| 119 | + | } | |
| 112 | 120 | // A bind acts on the window on screen: under a covering fullscreen | |
| 113 | 121 | // window, that is the one Focused reports. | |
| 114 | 122 | c.cur().mon.Current().focusCover() | |
| @@ -135,11 +143,11 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 135 | 143 | } else { | |
| 136 | 144 | dir = 1 | |
| 137 | 145 | } | |
| 138 | - | // The first move from a float stays on this monitor (FocusColumn): | |
| 146 | + | // The first move from a float stays here unless nothing is under it: | |
| 139 | 147 | // a native float leaves for the stash or the columns, and the stash | |
| 140 | 148 | // keeps the focus at its ends. A covering fullscreen window pins | |
| 141 | 149 | // the focus: the move goes to the neighbor monitor. | |
| 142 | - | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned()) | |
| 150 | + | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned() || w.floatFocus && !w.canLeaveFloat()) | |
| 143 | 151 | if i := c.neighbor(dir); edge && i >= 0 { | |
| 144 | 152 | c.focusScreen = i | |
| 145 | 153 | return Effect{} | |
| @@ -201,9 +209,13 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 201 | 209 | ||
| 202 | 210 | // Apply runs a bind action on the monitor. | |
| 203 | 211 | func (m *Monitor) Apply(a Action) Effect { | |
| 204 | - | // In the overview, close-window closes the selected stash card. | |
| 205 | - | if id := m.card(); id != 0 && a == ActionCloseWindow { | |
| 206 | - | return Effect{Close: id} | |
| 212 | + | if m.ov.open { | |
| 213 | + | if a == ActionCloseWindow { | |
| 214 | + | return Effect{Close: m.overviewTarget()} | |
| 215 | + | } | |
| 216 | + | if overviewBlocks(a) { | |
| 217 | + | return Effect{} | |
| 218 | + | } | |
| 207 | 219 | } | |
| 208 | 220 | if n, op, ok := WorkspaceArg(a); ok { | |
| 209 | 221 | if op == FocusWorkspace { | |
| @@ -219,7 +231,8 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 219 | 231 | } | |
| 220 | 232 | switch a { | |
| 221 | 233 | case ActionFocusWindowUp, ActionFocusWindowDown: | |
| 222 | - | // Past the top or bottom window of the column, move to the next workspace. | |
| 234 | + | // Past the column and any on-screen neighbor or demoted float, | |
| 235 | + | // move to the next workspace. | |
| 223 | 236 | dir := 1 | |
| 224 | 237 | if a == ActionFocusWindowUp { | |
| 225 | 238 | dir = -1 | |
| @@ -109,6 +109,14 @@ type Effect struct { | |||||
| 109 | // screen; column focus past the edge column moves to the neighbor screen; | 109 | // screen; column focus past the edge column moves to the neighbor screen; | ||
| 110 | // everything else applies to the focused screen's monitor. | 110 | // everything else applies to the focused screen's monitor. | ||
| 111 | func (c *Core) applyAction(a Action) Effect { | 111 | func (c *Core) applyAction(a Action) Effect { | ||
| 112 | + | if m := c.cur().mon; m.ov.open { | |||
| 113 | + | if m.overviewFocus(a) || overviewBlocks(a) { | |||
| 114 | + | return Effect{} | |||
| 115 | + | } | |||
| 116 | + | if a == ActionCloseWindow { | |||
| 117 | + | return m.Apply(a) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 112 | // A bind acts on the window on screen: under a covering fullscreen | 120 | // A bind acts on the window on screen: under a covering fullscreen | ||
| 113 | // window, that is the one Focused reports. | 121 | // window, that is the one Focused reports. | ||
| 114 | c.cur().mon.Current().focusCover() | 122 | c.cur().mon.Current().focusCover() | ||
| @@ -135,11 +143,11 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 135 | } else { | 143 | } else { | ||
| 136 | dir = 1 | 144 | dir = 1 | ||
| 137 | } | 145 | } | ||
| 138 | - | // The first move from a float stays on this monitor (FocusColumn): | 146 | + | // The first move from a float stays here unless nothing is under it: |
| 139 | // a native float leaves for the stash or the columns, and the stash | 147 | // a native float leaves for the stash or the columns, and the stash | ||
| 140 | // keeps the focus at its ends. A covering fullscreen window pins | 148 | // keeps the focus at its ends. A covering fullscreen window pins | ||
| 141 | // the focus: the move goes to the neighbor monitor. | 149 | // the focus: the move goes to the neighbor monitor. | ||
| 142 | - | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned()) | 150 | + | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned() || w.floatFocus && !w.canLeaveFloat()) |
| 143 | if i := c.neighbor(dir); edge && i >= 0 { | 151 | if i := c.neighbor(dir); edge && i >= 0 { | ||
| 144 | c.focusScreen = i | 152 | c.focusScreen = i | ||
| 145 | return Effect{} | 153 | return Effect{} | ||
| @@ -201,9 +209,13 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 201 | 209 | ||||
| 202 | // Apply runs a bind action on the monitor. | 210 | // Apply runs a bind action on the monitor. | ||
| 203 | func (m *Monitor) Apply(a Action) Effect { | 211 | func (m *Monitor) Apply(a Action) Effect { | ||
| 204 | - | // In the overview, close-window closes the selected stash card. | 212 | + | if m.ov.open { |
| 205 | - | if id := m.card(); id != 0 && a == ActionCloseWindow { | 213 | + | if a == ActionCloseWindow { |
| 206 | - | return Effect{Close: id} | 214 | + | return Effect{Close: m.overviewTarget()} |
| 215 | + | } | |||
| 216 | + | if overviewBlocks(a) { | |||
| 217 | + | return Effect{} | |||
| 218 | + | } | |||
| 207 | } | 219 | } | ||
| 208 | if n, op, ok := WorkspaceArg(a); ok { | 220 | if n, op, ok := WorkspaceArg(a); ok { | ||
| 209 | if op == FocusWorkspace { | 221 | if op == FocusWorkspace { | ||
| @@ -219,7 +231,8 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 219 | } | 231 | } | ||
| 220 | switch a { | 232 | switch a { | ||
| 221 | case ActionFocusWindowUp, ActionFocusWindowDown: | 233 | case ActionFocusWindowUp, ActionFocusWindowDown: | ||
| 222 | - | // Past the top or bottom window of the column, move to the next workspace. | 234 | + | // Past the column and any on-screen neighbor or demoted float, |
| 235 | + | // move to the next workspace. | |||
| 223 | dir := 1 | 236 | dir := 1 | ||
| 224 | if a == ActionFocusWindowUp { | 237 | if a == ActionFocusWindowUp { | ||
| 225 | dir = -1 | 238 | dir = -1 | ||
M internal/core/configures.go +10 -1
| @@ -11,6 +11,8 @@ type configureTarget struct { | |||
| 11 | 11 | // floating window (native floats pick their own size). | |
| 12 | 12 | client Rect | |
| 13 | 13 | imposed bool | |
| 14 | + | // realTiled sizes a visible tiled preview from its real client rect. | |
| 15 | + | realTiled bool | |
| 14 | 16 | } | |
| 15 | 17 | ||
| 16 | 18 | // configures owns the last configure sent to each window and decides the | |
| @@ -44,13 +46,20 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow | |||
| 44 | 46 | // A preview keeps its client's size: only its focus changes. | |
| 45 | 47 | v = old | |
| 46 | 48 | if !ok { | |
| 47 | - | v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview)} | |
| 49 | + | v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview), Floating: p.Floating} | |
| 50 | + | if p.Floating { | |
| 51 | + | // A native float picks its own size. | |
| 52 | + | v.Width, v.Height = 0, 0 | |
| 53 | + | } | |
| 48 | 54 | } | |
| 49 | 55 | if p.Fullscreen && !v.Fullscreen { | |
| 50 | 56 | // A float made fullscreen off screen: its preview row shows | |
| 51 | 57 | // it fullscreen, sized for the output, never the preview. | |
| 52 | 58 | v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false | |
| 53 | 59 | } | |
| 60 | + | if t.realTiled { | |
| 61 | + | v.Width, v.Height = t.client.W, t.client.H | |
| 62 | + | } | |
| 54 | 63 | v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output | |
| 55 | 64 | default: | |
| 56 | 65 | v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)} | |
| @@ -11,6 +11,8 @@ type configureTarget struct { | |||||
| 11 | // floating window (native floats pick their own size). | 11 | // floating window (native floats pick their own size). | ||
| 12 | client Rect | 12 | client Rect | ||
| 13 | imposed bool | 13 | imposed bool | ||
| 14 | + | // realTiled sizes a visible tiled preview from its real client rect. | |||
| 15 | + | realTiled bool | |||
| 14 | } | 16 | } | ||
| 15 | 17 | ||||
| 16 | // configures owns the last configure sent to each window and decides the | 18 | // configures owns the last configure sent to each window and decides the | ||
| @@ -44,13 +46,20 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow | |||||
| 44 | // A preview keeps its client's size: only its focus changes. | 46 | // A preview keeps its client's size: only its focus changes. | ||
| 45 | v = old | 47 | v = old | ||
| 46 | if !ok { | 48 | if !ok { | ||
| 47 | - | v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview)} | 49 | + | v = ports.ConfigureWindow{ID: p.ID, Width: int(float64(p.Rect.W) / p.Preview), Height: int(float64(p.Rect.H) / p.Preview), Floating: p.Floating} |
| 50 | + | if p.Floating { | |||
| 51 | + | // A native float picks its own size. | |||
| 52 | + | v.Width, v.Height = 0, 0 | |||
| 53 | + | } | |||
| 48 | } | 54 | } | ||
| 49 | if p.Fullscreen && !v.Fullscreen { | 55 | if p.Fullscreen && !v.Fullscreen { | ||
| 50 | // A float made fullscreen off screen: its preview row shows | 56 | // A float made fullscreen off screen: its preview row shows | ||
| 51 | // it fullscreen, sized for the output, never the preview. | 57 | // it fullscreen, sized for the output, never the preview. | ||
| 52 | v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false | 58 | v.Width, v.Height, v.Fullscreen, v.Floating = t.area.W, t.area.H, true, false | ||
| 53 | } | 59 | } | ||
| 60 | + | if t.realTiled { | |||
| 61 | + | v.Width, v.Height = t.client.W, t.client.H | |||
| 62 | + | } | |||
| 54 | v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output | 63 | v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output | ||
| 55 | default: | 64 | default: | ||
| 56 | v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)} | 65 | v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)} | ||
M internal/core/configures_test.go +3 -0
| @@ -30,6 +30,9 @@ func TestConfiguresNext(t *testing.T) { | |||
| 30 | 30 | {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true}, | |
| 31 | 31 | {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true}, | |
| 32 | 32 | {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true}, | |
| 33 | + | {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true}, | |
| 34 | + | {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}}, | |
| 35 | + | {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}}, | |
| 33 | 36 | } | |
| 34 | 37 | for _, tt := range tests { | |
| 35 | 38 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -30,6 +30,9 @@ func TestConfiguresNext(t *testing.T) { | |||||
| 30 | {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true}, | 30 | {name: "fullscreen preview keeps size and state", first: &Placement{ID: 1, Rect: area, Floating: true, Fullscreen: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Fullscreen: true, Output: "A", Visible: true}, send: true}, | ||
| 31 | {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true}, | 31 | {name: "hidden float made fullscreen previews fullscreen", first: &Placement{ID: 1, Hidden: true, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 100, H: 80}, Preview: 0.1, Fullscreen: true}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 1000, Height: 800, Fullscreen: true, Output: "A", Visible: true}, send: true}, | ||
| 32 | {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true}, | 32 | {name: "preview keeps size", first: &tiled, p: Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.5}, t: configureTarget{output: "A", area: area}, want: ports.ConfigureWindow{ID: 1, Width: 496, Height: 796, Output: "A", Visible: true}, send: true}, | ||
| 33 | + | {name: "new float preview stays floating", p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}, send: true}, | |||
| 34 | + | {name: "covering float preview keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{W: 600, H: 480}, Preview: 0.6, Floating: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}}, | |||
| 35 | + | {name: "covering float peek keeps native size", first: &Placement{ID: 1, Rect: area, Floating: true}, p: Placement{ID: 1, Rect: Rect{X: 20, Y: 20, W: 600, H: 480}, Preview: 0.6, Floating: true, Peek: true}, t: target, want: ports.ConfigureWindow{ID: 1, Floating: true, Activated: true, Output: "A", Visible: true}}, | |||
| 33 | } | 36 | } | ||
| 34 | for _, tt := range tests { | 37 | for _, tt := range tests { | ||
| 35 | t.Run(tt.name, func(t *testing.T) { | 38 | t.Run(tt.name, func(t *testing.T) { | ||
M internal/core/core.go +28 -22
| @@ -338,12 +338,13 @@ func onScreen(p Placement, o Rect) bool { | |||
| 338 | 338 | return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H}) | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | - | // floatDim is the veil opacity of a layout: dim while a float is drawn | |
| 342 | - | // over the tiles, none under a fullscreen float (nothing is seen below). | |
| 341 | + | // floatDim is the veil opacity of a layout: dim only when a float is | |
| 342 | + | // drawn above the tiles, never for a demoted covering float alone nor for | |
| 343 | + | // an overview preview. | |
| 343 | 344 | func floatDim(layout []Placement, o Rect, dim float64) float64 { | |
| 344 | 345 | shown := false | |
| 345 | 346 | for _, p := range layout { | |
| 346 | - | if p.Floating && onScreen(p, o) { | |
| 347 | + | if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) { | |
| 347 | 348 | if p.Fullscreen { | |
| 348 | 349 | return 0 | |
| 349 | 350 | } | |
| @@ -450,17 +451,26 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 450 | 451 | o := sc.mon.Output() | |
| 451 | 452 | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} | |
| 452 | 453 | layout := sc.mon.Layout() | |
| 454 | + | var real map[WindowID]Placement | |
| 455 | + | if sc.mon.ov.open { | |
| 456 | + | real = make(map[WindowID]Placement) | |
| 457 | + | for _, w := range sc.mon.all() { | |
| 458 | + | for _, p := range w.Layout() { | |
| 459 | + | real[p.ID] = p | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | } | |
| 453 | 463 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | |
| 454 | 464 | // Only the focused output lights the focused window's lines. | |
| 455 | 465 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | |
| 456 | - | if sc.mon.overview { | |
| 466 | + | if sc.mon.ov.open { | |
| 457 | 467 | // Previews have no lines: the selected one is framed. | |
| 458 | 468 | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | |
| 459 | 469 | } | |
| 460 | 470 | for _, p := range layout { | |
| 461 | 471 | // Only the focused output has an activated window. | |
| 462 | 472 | focused := p.Focused && i == c.focusScreen | |
| 463 | - | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset, Preview: p.Preview} | |
| 473 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | |
| 464 | 474 | if p.Peek { | |
| 465 | 475 | sw.Dim = c.cfg.Stash.Dim | |
| 466 | 476 | } | |
| @@ -469,6 +479,11 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 469 | 479 | if !p.Hidden && p.Preview == 0 { | |
| 470 | 480 | // Only a sized configure needs the client size. | |
| 471 | 481 | t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID) | |
| 482 | + | } else if p.Preview > 0 && !p.Hidden { | |
| 483 | + | if rp, ok := real[p.ID]; ok && !rp.Hidden { | |
| 484 | + | t.realTiled = !rp.Floating | |
| 485 | + | t.client = c.clientRect(rp) | |
| 486 | + | } | |
| 472 | 487 | } | |
| 473 | 488 | if v, send := c.configures.next(p, t); send { | |
| 474 | 489 | if err := c.command(ctx, v); err != nil { | |
| @@ -684,8 +699,8 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool { | |||
| 684 | 699 | ||
| 685 | 700 | // hit returns the surface under the global logical point and the point in | |
| 686 | 701 | // its surface coordinates: popups, then overlay and top layers, then | |
| 687 | - | // windows (fullscreen wins; otherwise the last visible placement is | |
| 688 | - | // topmost), then bottom and background layers. | |
| 702 | + | // windows (last visible placement is topmost), then bottom and | |
| 703 | + | // background layers. | |
| 689 | 704 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |
| 690 | 705 | o, ok := c.layout().At(x, y) | |
| 691 | 706 | if !ok { | |
| @@ -704,14 +719,13 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 704 | 719 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | |
| 705 | 720 | return id, px, py | |
| 706 | 721 | } | |
| 707 | - | if sc.mon.overview { | |
| 722 | + | if sc.mon.ov.open { | |
| 708 | 723 | // Previews and the layers under them take no input: a click | |
| 709 | 724 | // picks a preview (overviewClick). | |
| 710 | 725 | return 0, 0, 0 | |
| 711 | 726 | } | |
| 712 | 727 | var id WindowID | |
| 713 | 728 | var sx, sy float64 | |
| 714 | - | full := false | |
| 715 | 729 | for _, p := range sc.mon.Layout() { | |
| 716 | 730 | r := c.clientRect(p) | |
| 717 | 731 | // A peek is clickable wherever it shows, border included: it may | |
| @@ -724,18 +738,10 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 724 | 738 | if p.Peek { | |
| 725 | 739 | lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1)) | |
| 726 | 740 | } | |
| 727 | - | // Floating windows come last in the layout and are on top, | |
| 728 | - | // even of a fullscreen window; nothing else is. | |
| 729 | - | if full && !p.Floating { | |
| 730 | - | continue | |
| 731 | - | } | |
| 732 | - | if !c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) { | |
| 733 | - | continue | |
| 734 | - | } | |
| 735 | - | if id == 0 || p.Fullscreen || p.Floating { | |
| 741 | + | if c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) { | |
| 742 | + | // Layout is bottom to top, including fullscreen and floats. | |
| 736 | 743 | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) | |
| 737 | 744 | } | |
| 738 | - | full = full || p.Fullscreen | |
| 739 | 745 | } | |
| 740 | 746 | } | |
| 741 | 747 | if id == 0 { | |
| @@ -1024,7 +1030,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1024 | 1030 | continue | |
| 1025 | 1031 | case ports.PointerAxis: | |
| 1026 | 1032 | // In the overview, scrolling moves the selection. | |
| 1027 | - | if c.cur().mon.overview && !c.overviewKeyboardTaken() { | |
| 1033 | + | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | |
| 1028 | 1034 | if c.cur().mon.overviewScroll(v) { | |
| 1029 | 1035 | if c.workspaceVisible(ctx, true) != nil { | |
| 1030 | 1036 | return nil | |
| @@ -1066,7 +1072,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1066 | 1072 | // The overview takes its keys before any window; others still | |
| 1067 | 1073 | // run binds, and are not forwarded. | |
| 1068 | 1074 | // A launcher or a menu holding the keyboard gets them first. | |
| 1069 | - | if mon := c.cur().mon; mon.overview && !c.overviewKeyboardTaken() { | |
| 1075 | + | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | |
| 1070 | 1076 | if key.Pressed && mon.overviewKey(key) { | |
| 1071 | 1077 | c.pressed[heldKey(key)] = true | |
| 1072 | 1078 | if c.workspaceVisible(ctx, true) != nil { | |
| @@ -1172,7 +1178,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1172 | 1178 | } | |
| 1173 | 1179 | c.pressed[held] = false | |
| 1174 | 1180 | } | |
| 1175 | - | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) { | |
| 1181 | + | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) { | |
| 1176 | 1182 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |
| 1177 | 1183 | return nil | |
| 1178 | 1184 | } | |
| @@ -338,12 +338,13 @@ func onScreen(p Placement, o Rect) bool { | |||||
| 338 | return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H}) | 338 | return !p.Hidden && p.Rect.Overlaps(Rect{W: o.W, H: o.H}) | ||
| 339 | } | 339 | } | ||
| 340 | 340 | ||||
| 341 | - | // floatDim is the veil opacity of a layout: dim while a float is drawn | 341 | + | // floatDim is the veil opacity of a layout: dim only when a float is |
| 342 | - | // over the tiles, none under a fullscreen float (nothing is seen below). | 342 | + | // drawn above the tiles, never for a demoted covering float alone nor for |
| 343 | + | // an overview preview. | |||
| 343 | func floatDim(layout []Placement, o Rect, dim float64) float64 { | 344 | func floatDim(layout []Placement, o Rect, dim float64) float64 { | ||
| 344 | shown := false | 345 | shown := false | ||
| 345 | for _, p := range layout { | 346 | for _, p := range layout { | ||
| 346 | - | if p.Floating && onScreen(p, o) { | 347 | + | if p.Floating && !p.Below && p.Preview == 0 && onScreen(p, o) { |
| 347 | if p.Fullscreen { | 348 | if p.Fullscreen { | ||
| 348 | return 0 | 349 | return 0 | ||
| 349 | } | 350 | } | ||
| @@ -450,17 +451,26 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 450 | o := sc.mon.Output() | 451 | o := sc.mon.Output() | ||
| 451 | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} | 452 | scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)} | ||
| 452 | layout := sc.mon.Layout() | 453 | layout := sc.mon.Layout() | ||
| 454 | + | var real map[WindowID]Placement | |||
| 455 | + | if sc.mon.ov.open { | |||
| 456 | + | real = make(map[WindowID]Placement) | |||
| 457 | + | for _, w := range sc.mon.all() { | |||
| 458 | + | for _, p := range w.Layout() { | |||
| 459 | + | real[p.ID] = p | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | } | |||
| 453 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | 463 | scene.Dim = floatDim(layout, o, c.cfg.Floating.Dim) | ||
| 454 | // Only the focused output lights the focused window's lines. | 464 | // Only the focused output lights the focused window's lines. | ||
| 455 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | 465 | scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), Rect{W: o.W, H: o.H}, i == c.focusScreen) | ||
| 456 | - | if sc.mon.overview { | 466 | + | if sc.mon.ov.open { |
| 457 | // Previews have no lines: the selected one is framed. | 467 | // Previews have no lines: the selected one is framed. | ||
| 458 | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | 468 | scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2)) | ||
| 459 | } | 469 | } | ||
| 460 | for _, p := range layout { | 470 | for _, p := range layout { | ||
| 461 | // Only the focused output has an activated window. | 471 | // Only the focused output has an activated window. | ||
| 462 | focused := p.Focused && i == c.focusScreen | 472 | focused := p.Focused && i == c.focusScreen | ||
| 463 | - | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset, Preview: p.Preview} | 473 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} |
| 464 | if p.Peek { | 474 | if p.Peek { | ||
| 465 | sw.Dim = c.cfg.Stash.Dim | 475 | sw.Dim = c.cfg.Stash.Dim | ||
| 466 | } | 476 | } | ||
| @@ -469,6 +479,11 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 469 | if !p.Hidden && p.Preview == 0 { | 479 | if !p.Hidden && p.Preview == 0 { | ||
| 470 | // Only a sized configure needs the client size. | 480 | // Only a sized configure needs the client size. | ||
| 471 | t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID) | 481 | t.client, t.imposed = c.clientRect(p), sc.mon.Current().imposedFloat(p.ID) | ||
| 482 | + | } else if p.Preview > 0 && !p.Hidden { | |||
| 483 | + | if rp, ok := real[p.ID]; ok && !rp.Hidden { | |||
| 484 | + | t.realTiled = !rp.Floating | |||
| 485 | + | t.client = c.clientRect(rp) | |||
| 486 | + | } | |||
| 472 | } | 487 | } | ||
| 473 | if v, send := c.configures.next(p, t); send { | 488 | if v, send := c.configures.next(p, t); send { | ||
| 474 | if err := c.command(ctx, v); err != nil { | 489 | if err := c.command(ctx, v); err != nil { | ||
| @@ -684,8 +699,8 @@ func (c *Core) acceptsInput(id WindowID, x, y float64) bool { | |||||
| 684 | 699 | ||||
| 685 | // hit returns the surface under the global logical point and the point in | 700 | // hit returns the surface under the global logical point and the point in | ||
| 686 | // its surface coordinates: popups, then overlay and top layers, then | 701 | // its surface coordinates: popups, then overlay and top layers, then | ||
| 687 | - | // windows (fullscreen wins; otherwise the last visible placement is | 702 | + | // windows (last visible placement is topmost), then bottom and |
| 688 | - | // topmost), then bottom and background layers. | 703 | + | // background layers. |
| 689 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | 704 | func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | ||
| 690 | o, ok := c.layout().At(x, y) | 705 | o, ok := c.layout().At(x, y) | ||
| 691 | if !ok { | 706 | if !ok { | ||
| @@ -704,14 +719,13 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 704 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | 719 | if id, px, py := c.popupAt(sc, lx, ly, false); id != 0 { | ||
| 705 | return id, px, py | 720 | return id, px, py | ||
| 706 | } | 721 | } | ||
| 707 | - | if sc.mon.overview { | 722 | + | if sc.mon.ov.open { |
| 708 | // Previews and the layers under them take no input: a click | 723 | // Previews and the layers under them take no input: a click | ||
| 709 | // picks a preview (overviewClick). | 724 | // picks a preview (overviewClick). | ||
| 710 | return 0, 0, 0 | 725 | return 0, 0, 0 | ||
| 711 | } | 726 | } | ||
| 712 | var id WindowID | 727 | var id WindowID | ||
| 713 | var sx, sy float64 | 728 | var sx, sy float64 | ||
| 714 | - | full := false | |||
| 715 | for _, p := range sc.mon.Layout() { | 729 | for _, p := range sc.mon.Layout() { | ||
| 716 | r := c.clientRect(p) | 730 | r := c.clientRect(p) | ||
| 717 | // A peek is clickable wherever it shows, border included: it may | 731 | // A peek is clickable wherever it shows, border included: it may | ||
| @@ -724,18 +738,10 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 724 | if p.Peek { | 738 | if p.Peek { | ||
| 725 | lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1)) | 739 | lx, ly = min(max(lx, float64(r.X)), float64(r.X+r.W-1)), min(max(ly, float64(r.Y)), float64(r.Y+r.H-1)) | ||
| 726 | } | 740 | } | ||
| 727 | - | // Floating windows come last in the layout and are on top, | 741 | + | if c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) { |
| 728 | - | // even of a fullscreen window; nothing else is. | 742 | + | // Layout is bottom to top, including fullscreen and floats. |
| 729 | - | if full && !p.Floating { | |||
| 730 | - | continue | |||
| 731 | - | } | |||
| 732 | - | if !c.acceptsInput(p.ID, lx-float64(r.X), ly-float64(r.Y)) { | |||
| 733 | - | continue | |||
| 734 | - | } | |||
| 735 | - | if id == 0 || p.Fullscreen || p.Floating { | |||
| 736 | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) | 743 | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) | ||
| 737 | } | 744 | } | ||
| 738 | - | full = full || p.Fullscreen | |||
| 739 | } | 745 | } | ||
| 740 | } | 746 | } | ||
| 741 | if id == 0 { | 747 | if id == 0 { | ||
| @@ -1024,7 +1030,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1024 | continue | 1030 | continue | ||
| 1025 | case ports.PointerAxis: | 1031 | case ports.PointerAxis: | ||
| 1026 | // In the overview, scrolling moves the selection. | 1032 | // In the overview, scrolling moves the selection. | ||
| 1027 | - | if c.cur().mon.overview && !c.overviewKeyboardTaken() { | 1033 | + | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { |
| 1028 | if c.cur().mon.overviewScroll(v) { | 1034 | if c.cur().mon.overviewScroll(v) { | ||
| 1029 | if c.workspaceVisible(ctx, true) != nil { | 1035 | if c.workspaceVisible(ctx, true) != nil { | ||
| 1030 | return nil | 1036 | return nil | ||
| @@ -1066,7 +1072,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1066 | // The overview takes its keys before any window; others still | 1072 | // The overview takes its keys before any window; others still | ||
| 1067 | // run binds, and are not forwarded. | 1073 | // run binds, and are not forwarded. | ||
| 1068 | // A launcher or a menu holding the keyboard gets them first. | 1074 | // A launcher or a menu holding the keyboard gets them first. | ||
| 1069 | - | if mon := c.cur().mon; mon.overview && !c.overviewKeyboardTaken() { | 1075 | + | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { |
| 1070 | if key.Pressed && mon.overviewKey(key) { | 1076 | if key.Pressed && mon.overviewKey(key) { | ||
| 1071 | c.pressed[heldKey(key)] = true | 1077 | c.pressed[heldKey(key)] = true | ||
| 1072 | if c.workspaceVisible(ctx, true) != nil { | 1078 | if c.workspaceVisible(ctx, true) != nil { | ||
| @@ -1172,7 +1178,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1172 | } | 1178 | } | ||
| 1173 | c.pressed[held] = false | 1179 | c.pressed[held] = false | ||
| 1174 | } | 1180 | } | ||
| 1175 | - | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.overview || c.overviewKeyboardTaken()) { | 1181 | + | if id := c.keyboardFocus(); id != 0 && (!c.cur().mon.ov.open || c.overviewKeyboardTaken()) { |
| 1176 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | 1182 | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | ||
| 1177 | return nil | 1183 | return nil | ||
| 1178 | } | 1184 | } | ||
M internal/core/expel.go +3 -3
| @@ -38,7 +38,7 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool { | |||
| 38 | 38 | // Consume at the edge: move-column already crosses monitors. | |
| 39 | 39 | case len(col.Windows) == 1: | |
| 40 | 40 | // The column goes away, and a slot with it. | |
| 41 | - | w.stack(id, w.Columns[next].Windows[0]) | |
| 41 | + | w.stackWindow(id, w.Columns[next].Windows[0]) | |
| 42 | 42 | case !w.full(): | |
| 43 | 43 | c := Column{Windows: []WindowID{id}, Width: col.Width} | |
| 44 | 44 | if col.Focus == 0 && col.Slot != 0 { | |
| @@ -53,14 +53,14 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool { | |||
| 53 | 53 | case edge: | |
| 54 | 54 | return false | |
| 55 | 55 | default: | |
| 56 | - | w.stack(id, w.Columns[next].Windows[0]) | |
| 56 | + | w.stackWindow(id, w.Columns[next].Windows[0]) | |
| 57 | 57 | } | |
| 58 | 58 | return true | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | // stack moves tiled window id to the bottom of the column holding target | |
| 62 | 62 | // and focuses it. Leaving a slot column's first row releases the slot. | |
| 63 | - | func (w *Workspace) stack(id, target WindowID) { | |
| 63 | + | func (w *Workspace) stackWindow(id, target WindowID) { | |
| 64 | 64 | w.dropSlotOf(id) | |
| 65 | 65 | w.RemoveWindow(id) | |
| 66 | 66 | i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) }) | |
| @@ -38,7 +38,7 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool { | |||||
| 38 | // Consume at the edge: move-column already crosses monitors. | 38 | // Consume at the edge: move-column already crosses monitors. | ||
| 39 | case len(col.Windows) == 1: | 39 | case len(col.Windows) == 1: | ||
| 40 | // The column goes away, and a slot with it. | 40 | // The column goes away, and a slot with it. | ||
| 41 | - | w.stack(id, w.Columns[next].Windows[0]) | 41 | + | w.stackWindow(id, w.Columns[next].Windows[0]) |
| 42 | case !w.full(): | 42 | case !w.full(): | ||
| 43 | c := Column{Windows: []WindowID{id}, Width: col.Width} | 43 | c := Column{Windows: []WindowID{id}, Width: col.Width} | ||
| 44 | if col.Focus == 0 && col.Slot != 0 { | 44 | if col.Focus == 0 && col.Slot != 0 { | ||
| @@ -53,14 +53,14 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool { | |||||
| 53 | case edge: | 53 | case edge: | ||
| 54 | return false | 54 | return false | ||
| 55 | default: | 55 | default: | ||
| 56 | - | w.stack(id, w.Columns[next].Windows[0]) | 56 | + | w.stackWindow(id, w.Columns[next].Windows[0]) |
| 57 | } | 57 | } | ||
| 58 | return true | 58 | return true | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | // stack moves tiled window id to the bottom of the column holding target | 61 | // stack moves tiled window id to the bottom of the column holding target | ||
| 62 | // and focuses it. Leaving a slot column's first row releases the slot. | 62 | // and focuses it. Leaving a slot column's first row releases the slot. | ||
| 63 | - | func (w *Workspace) stack(id, target WindowID) { | 63 | + | func (w *Workspace) stackWindow(id, target WindowID) { |
| 64 | w.dropSlotOf(id) | 64 | w.dropSlotOf(id) | ||
| 65 | w.RemoveWindow(id) | 65 | w.RemoveWindow(id) | ||
| 66 | i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) }) | 66 | i := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, target) }) | ||
A internal/core/float_core_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCoreCoveringFloatSceneAndHit(t *testing.T) { | |
| 11 | + | cfg := ports.Config{} | |
| 12 | + | cfg.Keyboard.CmdKey = "super" | |
| 13 | + | cfg.Layout.MaxColumns = 2 | |
| 14 | + | cfg.Border.Width = 2 | |
| 15 | + | cfg.Floating.Dim = 0.3 | |
| 16 | + | scenes := make(chan []ports.Scene, 1) | |
| 17 | + | commands := make(chan ports.ClientCommand, 128) | |
| 18 | + | c, err := New(cfg, Channels{Scenes: scenes, Commands: commands}) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | c.cur().info = ports.OutputInfo{Name: "OUT", Width: 100, Height: 80} | |
| 23 | + | m := c.cur().mon | |
| 24 | + | m.SetOutput(100, 80) | |
| 25 | + | w := m.Current() | |
| 26 | + | w.AddWindow(1) | |
| 27 | + | w.AddFloating(2, 100, 80) | |
| 28 | + | ctx := context.Background() | |
| 29 | + | check := func(dim float64, ids ...WindowID) { | |
| 30 | + | t.Helper() | |
| 31 | + | if err := c.publish(ctx); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | s := (<-scenes)[0] | |
| 35 | + | if s.Dim != dim { | |
| 36 | + | t.Fatalf("dim %v, want %v", s.Dim, dim) | |
| 37 | + | } | |
| 38 | + | if len(s.Windows) != len(ids) { | |
| 39 | + | t.Fatalf("scene %+v", s.Windows) | |
| 40 | + | } | |
| 41 | + | for i, id := range ids { | |
| 42 | + | if s.Windows[i].ID != id { | |
| 43 | + | t.Fatalf("scene %+v, want %v", s.Windows, ids) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | check(0.3, 1, 2) | |
| 48 | + | w.FocusID(1) | |
| 49 | + | check(0, 2, 1) | |
| 50 | + | if !w.Layout()[0].Below { | |
| 51 | + | t.Fatal("float not below") | |
| 52 | + | } | |
| 53 | + | if id, _, _ := c.hit(50, 40); id != 1 { | |
| 54 | + | t.Fatalf("hit %d, want tile", id) | |
| 55 | + | } | |
| 56 | + | w.AddFloating(3, 12, 10) | |
| 57 | + | check(0.3, 2, 1, 3) | |
| 58 | + | if id, _, _ := c.hit(50, 40); id != 3 { | |
| 59 | + | t.Fatalf("hit %d, want dialog", id) | |
| 60 | + | } | |
| 61 | + | w.FocusID(2) | |
| 62 | + | check(0.3, 1, 2, 3) | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestCoreCoveringFloatSceneAndHit(t *testing.T) { | |||
| 11 | + | cfg := ports.Config{} | |||
| 12 | + | cfg.Keyboard.CmdKey = "super" | |||
| 13 | + | cfg.Layout.MaxColumns = 2 | |||
| 14 | + | cfg.Border.Width = 2 | |||
| 15 | + | cfg.Floating.Dim = 0.3 | |||
| 16 | + | scenes := make(chan []ports.Scene, 1) | |||
| 17 | + | commands := make(chan ports.ClientCommand, 128) | |||
| 18 | + | c, err := New(cfg, Channels{Scenes: scenes, Commands: commands}) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | c.cur().info = ports.OutputInfo{Name: "OUT", Width: 100, Height: 80} | |||
| 23 | + | m := c.cur().mon | |||
| 24 | + | m.SetOutput(100, 80) | |||
| 25 | + | w := m.Current() | |||
| 26 | + | w.AddWindow(1) | |||
| 27 | + | w.AddFloating(2, 100, 80) | |||
| 28 | + | ctx := context.Background() | |||
| 29 | + | check := func(dim float64, ids ...WindowID) { | |||
| 30 | + | t.Helper() | |||
| 31 | + | if err := c.publish(ctx); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | s := (<-scenes)[0] | |||
| 35 | + | if s.Dim != dim { | |||
| 36 | + | t.Fatalf("dim %v, want %v", s.Dim, dim) | |||
| 37 | + | } | |||
| 38 | + | if len(s.Windows) != len(ids) { | |||
| 39 | + | t.Fatalf("scene %+v", s.Windows) | |||
| 40 | + | } | |||
| 41 | + | for i, id := range ids { | |||
| 42 | + | if s.Windows[i].ID != id { | |||
| 43 | + | t.Fatalf("scene %+v, want %v", s.Windows, ids) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | check(0.3, 1, 2) | |||
| 48 | + | w.FocusID(1) | |||
| 49 | + | check(0, 2, 1) | |||
| 50 | + | if !w.Layout()[0].Below { | |||
| 51 | + | t.Fatal("float not below") | |||
| 52 | + | } | |||
| 53 | + | if id, _, _ := c.hit(50, 40); id != 1 { | |||
| 54 | + | t.Fatalf("hit %d, want tile", id) | |||
| 55 | + | } | |||
| 56 | + | w.AddFloating(3, 12, 10) | |||
| 57 | + | check(0.3, 2, 1, 3) | |||
| 58 | + | if id, _, _ := c.hit(50, 40); id != 3 { | |||
| 59 | + | t.Fatalf("hit %d, want dialog", id) | |||
| 60 | + | } | |||
| 61 | + | w.FocusID(2) | |||
| 62 | + | check(0.3, 1, 2, 3) | |||
| 63 | + | } | |||
A internal/core/float_order_test.go +290 -0
| @@ -0,0 +1,290 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func order(w *Workspace) []WindowID { | |
| 9 | + | ids := []WindowID{} | |
| 10 | + | for _, p := range w.Layout() { | |
| 11 | + | if !p.Hidden { | |
| 12 | + | ids = append(ids, p.ID) | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | return ids | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func wantOrder(t *testing.T, w *Workspace, want ...WindowID) { | |
| 19 | + | t.Helper() | |
| 20 | + | if got := order(w); !slices.Equal(got, want) { | |
| 21 | + | t.Fatalf("order %v, want %v", got, want) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCoveringFloatOrder(t *testing.T) { | |
| 26 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 27 | + | t.Run(string(overflow), func(t *testing.T) { | |
| 28 | + | w := workspace() | |
| 29 | + | w.Overflow = overflow | |
| 30 | + | w.border = 2 | |
| 31 | + | w.AddWindow(1) | |
| 32 | + | w.AddWindow(2) | |
| 33 | + | w.AddFloating(3, 98, 79) // placed rect fills the 100x81 usable area | |
| 34 | + | wantOrder(t, w, 1, 2, 3) | |
| 35 | + | w.FocusID(2) | |
| 36 | + | wantOrder(t, w, 3, 1, 2) | |
| 37 | + | if id, _ := w.Focused(); id != 2 { | |
| 38 | + | t.Fatalf("focus %d", id) | |
| 39 | + | } | |
| 40 | + | w.RemoveWindow(2) | |
| 41 | + | wantOrder(t, w, 3, 1) | |
| 42 | + | if id, _ := w.Focused(); id != 1 { | |
| 43 | + | t.Fatalf("after close focus %d", id) | |
| 44 | + | } | |
| 45 | + | w.AddFloating(4, 20, 20) // new dialogs map on top | |
| 46 | + | wantOrder(t, w, 3, 1, 4) | |
| 47 | + | w.FocusID(3) | |
| 48 | + | wantOrder(t, w, 1, 3, 4) // user-selected float rises only within its allowed group | |
| 49 | + | w.FocusID(1) | |
| 50 | + | wantOrder(t, w, 3, 1, 4) | |
| 51 | + | w.ResizeFloating(3, 99, 80) | |
| 52 | + | wantOrder(t, w, 3, 1, 4) | |
| 53 | + | w.SetOutput(102, 83) | |
| 54 | + | w.SetUsable(Rect{W: 102, H: 83}) | |
| 55 | + | wantOrder(t, w, 3, 1, 4) | |
| 56 | + | w.ResizeFloating(3, 20, 20) | |
| 57 | + | wantOrder(t, w, 1, 4, 3) | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestCoveringFloatGeometryAndGroup(t *testing.T) { | |
| 63 | + | w := workspace() | |
| 64 | + | w.border = 2 | |
| 65 | + | w.AddWindow(1) | |
| 66 | + | w.AddFloating(2, 94, 75) // two border widths plus two pixels on each axis | |
| 67 | + | if !w.coversFloat(w.Floats[0]) { | |
| 68 | + | t.Fatal("rounding tolerance") | |
| 69 | + | } | |
| 70 | + | w.FocusID(1) | |
| 71 | + | wantOrder(t, w, 2, 1) | |
| 72 | + | w.SetUsable(Rect{X: 5, Y: 4, W: 90, H: 73}) | |
| 73 | + | wantOrder(t, w, 2, 1) | |
| 74 | + | w.SetUsable(Rect{W: 100, H: 81}) | |
| 75 | + | wantOrder(t, w, 2, 1) | |
| 76 | + | w.border = 0 | |
| 77 | + | w.reconcileFloats() | |
| 78 | + | wantOrder(t, w, 1, 2) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestDemotedFloatFullscreenRoundTrip(t *testing.T) { | |
| 82 | + | m := monitor() | |
| 83 | + | m.SetBorder(2) | |
| 84 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 85 | + | m = monitor() | |
| 86 | + | m.SetBorder(2) | |
| 87 | + | m.Current().Overflow = overflow | |
| 88 | + | m.AddWindow(1) | |
| 89 | + | m.AddFloating(2, 100, 80) | |
| 90 | + | w := m.Current() | |
| 91 | + | w.FocusID(1) | |
| 92 | + | wantOrder(t, w, 2, 1) | |
| 93 | + | m.SetFullscreen(2, true) | |
| 94 | + | if overflow == OverflowFixed { | |
| 95 | + | fs := m.Workspaces[1] | |
| 96 | + | if fs.origin != w || fs.back.float == nil || !fs.back.float.below { | |
| 97 | + | t.Fatalf("fullscreen origin %+v", fs.back) | |
| 98 | + | } | |
| 99 | + | m.SetFullscreen(2, false) | |
| 100 | + | } else { | |
| 101 | + | if p := placement(w, 2); !p.Fullscreen { | |
| 102 | + | t.Fatalf("fullscreen %+v", p) | |
| 103 | + | } | |
| 104 | + | m.SetFullscreen(2, false) | |
| 105 | + | } | |
| 106 | + | wantOrder(t, w, 2, 1) | |
| 107 | + | if id, _ := w.Focused(); id != 1 { | |
| 108 | + | t.Fatalf("focus %d", id) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestDemotedFloatMoveAndAdopt(t *testing.T) { | |
| 114 | + | m := monitor() | |
| 115 | + | m.SetBorder(2) | |
| 116 | + | m.AddWindow(1) | |
| 117 | + | m.AddFloating(2, 100, 80) | |
| 118 | + | w := m.Current() | |
| 119 | + | w.FocusID(1) | |
| 120 | + | other := monitor() | |
| 121 | + | other.SetOutput(102, 82) | |
| 122 | + | other.SetBorder(2) | |
| 123 | + | m.take(w) | |
| 124 | + | other.adopt(w, false, 0) | |
| 125 | + | if !w.Floats[0].below { | |
| 126 | + | t.Fatal("adopt raised float") | |
| 127 | + | } | |
| 128 | + | wantOrder(t, w, 2, 1) | |
| 129 | + | // A user moving the selected float to a workspace raises it there. | |
| 130 | + | other.show(w) | |
| 131 | + | w.FocusID(2) | |
| 132 | + | other.MoveToWorkspace(1, false) | |
| 133 | + | if w.has(2) || !other.Workspaces[1].has(2) { | |
| 134 | + | t.Fatal("move lost float") | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Up from the top of a column raises the last demoted covering float; | |
| 139 | + | // down leaves it for the columns again without changing workspaces. | |
| 140 | + | func TestFocusWindowReturnsToDemotedCoveringFloat(t *testing.T) { | |
| 141 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 142 | + | t.Run(string(overflow), func(t *testing.T) { | |
| 143 | + | m := monitor() | |
| 144 | + | m.SetOverflow(overflow) | |
| 145 | + | m.SetBorder(2) | |
| 146 | + | m.AddWindow(1) // kitty | |
| 147 | + | m.AddFloating(2, 100, 80) // covering game | |
| 148 | + | m.AddFloating(3, 100, 80) // last demoted float is on top | |
| 149 | + | w := m.Current() | |
| 150 | + | w.FocusID(1) | |
| 151 | + | wantOrder(t, w, 2, 3, 1) | |
| 152 | + | m.Apply(ActionFocusWindowUp) | |
| 153 | + | if id, _ := w.Focused(); id != 3 || m.Active != 0 || w.Floats[len(w.Floats)-1].below { | |
| 154 | + | t.Fatalf("up focused %d on workspace %d, floats %+v", id, m.Active, w.Floats) | |
| 155 | + | } | |
| 156 | + | wantOrder(t, w, 2, 1, 3) | |
| 157 | + | m.Apply(ActionFocusWindowDown) | |
| 158 | + | if id, _ := w.Focused(); id != 1 || m.Active != 0 { | |
| 159 | + | t.Fatalf("down focused %d on workspace %d", id, m.Active) | |
| 160 | + | } | |
| 161 | + | wantOrder(t, w, 2, 3, 1) | |
| 162 | + | }) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestFocusWindowDemotedFloatAfterColumnAndScreenNeighbors(t *testing.T) { | |
| 167 | + | m := monitor() | |
| 168 | + | m.SetOverflow(OverflowFixed) | |
| 169 | + | m.SetBorder(2) | |
| 170 | + | m.AddWindow(1) | |
| 171 | + | m.AddWindow(2) | |
| 172 | + | w := m.Current() | |
| 173 | + | w.ConsumeOrExpel(-1) // two windows in the same column | |
| 174 | + | w.AddFloating(9, 100, 80) | |
| 175 | + | w.FocusID(1) // top of the column | |
| 176 | + | w.FocusID(2) // bottom of the column | |
| 177 | + | if !w.FocusWindow(-1) { | |
| 178 | + | t.Fatal("up inside column failed") | |
| 179 | + | } | |
| 180 | + | if id, _ := w.Focused(); id != 1 || !w.Floats[0].below { | |
| 181 | + | t.Fatalf("up inside column focused %d, float %+v", id, w.Floats[0]) | |
| 182 | + | } | |
| 183 | + | if !w.FocusWindow(-1) { | |
| 184 | + | t.Fatal("up to float failed") | |
| 185 | + | } | |
| 186 | + | if id, _ := w.Focused(); id != 9 { | |
| 187 | + | t.Fatalf("up to float focused %d", id) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | m = monitor() | |
| 191 | + | m.SetOverflow(OverflowFixed) | |
| 192 | + | m.SetMaxColumns(2) | |
| 193 | + | for id := WindowID(1); id <= 4; id++ { | |
| 194 | + | m.AddWindow(id) | |
| 195 | + | } | |
| 196 | + | w = m.Current() | |
| 197 | + | w.AddFloating(9, 100, 80) | |
| 198 | + | w.FocusID(4) | |
| 199 | + | if !w.FocusWindow(-1) { | |
| 200 | + | t.Fatal("up to on-screen column failed") | |
| 201 | + | } | |
| 202 | + | if id, _ := w.Focused(); id != 2 || !w.Floats[0].below { | |
| 203 | + | t.Fatalf("up focused %d, float %+v", id, w.Floats[0]) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) { | |
| 208 | + | m := monitor() | |
| 209 | + | m.AddWindow(1) | |
| 210 | + | m.Focus(1) | |
| 211 | + | m.AddWindow(2) | |
| 212 | + | m.Apply(ActionFocusWindowUp) | |
| 213 | + | if m.Active != 0 { | |
| 214 | + | t.Fatalf("up on workspace %d", m.Active) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Up from a shown stash stays in the stash, even with a demoted covering | |
| 219 | + | // float; from a pinned fullscreen window it goes to the workspace above, | |
| 220 | + | // as without covering floats. | |
| 221 | + | func TestFocusWindowUpStashAndPinned(t *testing.T) { | |
| 222 | + | m := monitor() | |
| 223 | + | m.SetBorder(2) | |
| 224 | + | m.AddWindow(1) | |
| 225 | + | m.AddWindow(2) | |
| 226 | + | w := m.Current() | |
| 227 | + | w.AddFloating(9, 100, 80) | |
| 228 | + | w.FocusID(2) | |
| 229 | + | w.ToggleWindowStash() // 2 to the shown stash, focused | |
| 230 | + | if !w.Floats[0].below { | |
| 231 | + | t.Fatalf("float not below: %+v", w.Floats[0]) | |
| 232 | + | } | |
| 233 | + | m.Apply(ActionFocusWindowUp) | |
| 234 | + | if id, _ := w.Focused(); id != 2 || !w.Floats[0].below || m.Active != 0 { | |
| 235 | + | t.Fatalf("stash up focused %d on %d, float %+v", id, m.Active, w.Floats[0]) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | m = monitor() | |
| 239 | + | m.SetOverflow(OverflowFixed) | |
| 240 | + | m.AddWindow(1) | |
| 241 | + | m.AddFloating(2, 50, 40) | |
| 242 | + | m.SetFullscreen(2, true) // own workspace below | |
| 243 | + | if m.Active != 1 || !m.Current().pinned() { | |
| 244 | + | t.Fatalf("setup: active %d", m.Active) | |
| 245 | + | } | |
| 246 | + | m.Apply(ActionFocusWindowUp) | |
| 247 | + | if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 { | |
| 248 | + | t.Fatalf("pinned up focused %d on %d", id, m.Active) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Directional focus on a covering float with nothing under it keeps the | |
| 253 | + | // float focused. | |
| 254 | + | func TestCoveringFloatAloneKeepsFocus(t *testing.T) { | |
| 255 | + | w := workspace() | |
| 256 | + | w.border = 2 | |
| 257 | + | w.AddFloating(3, 98, 79) | |
| 258 | + | w.FocusColumn(-1) | |
| 259 | + | w.FocusWindow(1) | |
| 260 | + | if id, ok := w.Focused(); !ok || id != 3 { | |
| 261 | + | t.Fatalf("focus %d %v", id, ok) | |
| 262 | + | } | |
| 263 | + | wantOrder(t, w, 3) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // A tile returning from its own fullscreen workspace with the focus comes | |
| 267 | + | // back in front of a covering float mapped in its origin meanwhile. | |
| 268 | + | func TestFullscreenReturnRaisesColumns(t *testing.T) { | |
| 269 | + | for _, stacked := range []bool{false, true} { | |
| 270 | + | m := monitor() | |
| 271 | + | m.SetBorder(2) | |
| 272 | + | m.SetOverflow(OverflowFixed) | |
| 273 | + | m.AddWindow(1) | |
| 274 | + | m.AddWindow(2) | |
| 275 | + | if stacked { | |
| 276 | + | m.Current().ConsumeOrExpel(-1) | |
| 277 | + | } | |
| 278 | + | w := m.Current() | |
| 279 | + | w.FocusID(2) | |
| 280 | + | m.ToggleFullscreen() // 2 to its own workspace | |
| 281 | + | w.AddFloating(3, 100, 80) | |
| 282 | + | m.ToggleFullscreen() // back, focused | |
| 283 | + | if id, _ := w.Focused(); id != 2 { | |
| 284 | + | t.Fatalf("stacked %v: focus %d", stacked, id) | |
| 285 | + | } | |
| 286 | + | if got := order(w); got[0] != 3 { | |
| 287 | + | t.Fatalf("stacked %v: order %v, covering float not below", stacked, got) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } | |
| @@ -0,0 +1,290 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func order(w *Workspace) []WindowID { | |||
| 9 | + | ids := []WindowID{} | |||
| 10 | + | for _, p := range w.Layout() { | |||
| 11 | + | if !p.Hidden { | |||
| 12 | + | ids = append(ids, p.ID) | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | return ids | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func wantOrder(t *testing.T, w *Workspace, want ...WindowID) { | |||
| 19 | + | t.Helper() | |||
| 20 | + | if got := order(w); !slices.Equal(got, want) { | |||
| 21 | + | t.Fatalf("order %v, want %v", got, want) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestCoveringFloatOrder(t *testing.T) { | |||
| 26 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 27 | + | t.Run(string(overflow), func(t *testing.T) { | |||
| 28 | + | w := workspace() | |||
| 29 | + | w.Overflow = overflow | |||
| 30 | + | w.border = 2 | |||
| 31 | + | w.AddWindow(1) | |||
| 32 | + | w.AddWindow(2) | |||
| 33 | + | w.AddFloating(3, 98, 79) // placed rect fills the 100x81 usable area | |||
| 34 | + | wantOrder(t, w, 1, 2, 3) | |||
| 35 | + | w.FocusID(2) | |||
| 36 | + | wantOrder(t, w, 3, 1, 2) | |||
| 37 | + | if id, _ := w.Focused(); id != 2 { | |||
| 38 | + | t.Fatalf("focus %d", id) | |||
| 39 | + | } | |||
| 40 | + | w.RemoveWindow(2) | |||
| 41 | + | wantOrder(t, w, 3, 1) | |||
| 42 | + | if id, _ := w.Focused(); id != 1 { | |||
| 43 | + | t.Fatalf("after close focus %d", id) | |||
| 44 | + | } | |||
| 45 | + | w.AddFloating(4, 20, 20) // new dialogs map on top | |||
| 46 | + | wantOrder(t, w, 3, 1, 4) | |||
| 47 | + | w.FocusID(3) | |||
| 48 | + | wantOrder(t, w, 1, 3, 4) // user-selected float rises only within its allowed group | |||
| 49 | + | w.FocusID(1) | |||
| 50 | + | wantOrder(t, w, 3, 1, 4) | |||
| 51 | + | w.ResizeFloating(3, 99, 80) | |||
| 52 | + | wantOrder(t, w, 3, 1, 4) | |||
| 53 | + | w.SetOutput(102, 83) | |||
| 54 | + | w.SetUsable(Rect{W: 102, H: 83}) | |||
| 55 | + | wantOrder(t, w, 3, 1, 4) | |||
| 56 | + | w.ResizeFloating(3, 20, 20) | |||
| 57 | + | wantOrder(t, w, 1, 4, 3) | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestCoveringFloatGeometryAndGroup(t *testing.T) { | |||
| 63 | + | w := workspace() | |||
| 64 | + | w.border = 2 | |||
| 65 | + | w.AddWindow(1) | |||
| 66 | + | w.AddFloating(2, 94, 75) // two border widths plus two pixels on each axis | |||
| 67 | + | if !w.coversFloat(w.Floats[0]) { | |||
| 68 | + | t.Fatal("rounding tolerance") | |||
| 69 | + | } | |||
| 70 | + | w.FocusID(1) | |||
| 71 | + | wantOrder(t, w, 2, 1) | |||
| 72 | + | w.SetUsable(Rect{X: 5, Y: 4, W: 90, H: 73}) | |||
| 73 | + | wantOrder(t, w, 2, 1) | |||
| 74 | + | w.SetUsable(Rect{W: 100, H: 81}) | |||
| 75 | + | wantOrder(t, w, 2, 1) | |||
| 76 | + | w.border = 0 | |||
| 77 | + | w.reconcileFloats() | |||
| 78 | + | wantOrder(t, w, 1, 2) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestDemotedFloatFullscreenRoundTrip(t *testing.T) { | |||
| 82 | + | m := monitor() | |||
| 83 | + | m.SetBorder(2) | |||
| 84 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 85 | + | m = monitor() | |||
| 86 | + | m.SetBorder(2) | |||
| 87 | + | m.Current().Overflow = overflow | |||
| 88 | + | m.AddWindow(1) | |||
| 89 | + | m.AddFloating(2, 100, 80) | |||
| 90 | + | w := m.Current() | |||
| 91 | + | w.FocusID(1) | |||
| 92 | + | wantOrder(t, w, 2, 1) | |||
| 93 | + | m.SetFullscreen(2, true) | |||
| 94 | + | if overflow == OverflowFixed { | |||
| 95 | + | fs := m.Workspaces[1] | |||
| 96 | + | if fs.origin != w || fs.back.float == nil || !fs.back.float.below { | |||
| 97 | + | t.Fatalf("fullscreen origin %+v", fs.back) | |||
| 98 | + | } | |||
| 99 | + | m.SetFullscreen(2, false) | |||
| 100 | + | } else { | |||
| 101 | + | if p := placement(w, 2); !p.Fullscreen { | |||
| 102 | + | t.Fatalf("fullscreen %+v", p) | |||
| 103 | + | } | |||
| 104 | + | m.SetFullscreen(2, false) | |||
| 105 | + | } | |||
| 106 | + | wantOrder(t, w, 2, 1) | |||
| 107 | + | if id, _ := w.Focused(); id != 1 { | |||
| 108 | + | t.Fatalf("focus %d", id) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestDemotedFloatMoveAndAdopt(t *testing.T) { | |||
| 114 | + | m := monitor() | |||
| 115 | + | m.SetBorder(2) | |||
| 116 | + | m.AddWindow(1) | |||
| 117 | + | m.AddFloating(2, 100, 80) | |||
| 118 | + | w := m.Current() | |||
| 119 | + | w.FocusID(1) | |||
| 120 | + | other := monitor() | |||
| 121 | + | other.SetOutput(102, 82) | |||
| 122 | + | other.SetBorder(2) | |||
| 123 | + | m.take(w) | |||
| 124 | + | other.adopt(w, false, 0) | |||
| 125 | + | if !w.Floats[0].below { | |||
| 126 | + | t.Fatal("adopt raised float") | |||
| 127 | + | } | |||
| 128 | + | wantOrder(t, w, 2, 1) | |||
| 129 | + | // A user moving the selected float to a workspace raises it there. | |||
| 130 | + | other.show(w) | |||
| 131 | + | w.FocusID(2) | |||
| 132 | + | other.MoveToWorkspace(1, false) | |||
| 133 | + | if w.has(2) || !other.Workspaces[1].has(2) { | |||
| 134 | + | t.Fatal("move lost float") | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Up from the top of a column raises the last demoted covering float; | |||
| 139 | + | // down leaves it for the columns again without changing workspaces. | |||
| 140 | + | func TestFocusWindowReturnsToDemotedCoveringFloat(t *testing.T) { | |||
| 141 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 142 | + | t.Run(string(overflow), func(t *testing.T) { | |||
| 143 | + | m := monitor() | |||
| 144 | + | m.SetOverflow(overflow) | |||
| 145 | + | m.SetBorder(2) | |||
| 146 | + | m.AddWindow(1) // kitty | |||
| 147 | + | m.AddFloating(2, 100, 80) // covering game | |||
| 148 | + | m.AddFloating(3, 100, 80) // last demoted float is on top | |||
| 149 | + | w := m.Current() | |||
| 150 | + | w.FocusID(1) | |||
| 151 | + | wantOrder(t, w, 2, 3, 1) | |||
| 152 | + | m.Apply(ActionFocusWindowUp) | |||
| 153 | + | if id, _ := w.Focused(); id != 3 || m.Active != 0 || w.Floats[len(w.Floats)-1].below { | |||
| 154 | + | t.Fatalf("up focused %d on workspace %d, floats %+v", id, m.Active, w.Floats) | |||
| 155 | + | } | |||
| 156 | + | wantOrder(t, w, 2, 1, 3) | |||
| 157 | + | m.Apply(ActionFocusWindowDown) | |||
| 158 | + | if id, _ := w.Focused(); id != 1 || m.Active != 0 { | |||
| 159 | + | t.Fatalf("down focused %d on workspace %d", id, m.Active) | |||
| 160 | + | } | |||
| 161 | + | wantOrder(t, w, 2, 3, 1) | |||
| 162 | + | }) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestFocusWindowDemotedFloatAfterColumnAndScreenNeighbors(t *testing.T) { | |||
| 167 | + | m := monitor() | |||
| 168 | + | m.SetOverflow(OverflowFixed) | |||
| 169 | + | m.SetBorder(2) | |||
| 170 | + | m.AddWindow(1) | |||
| 171 | + | m.AddWindow(2) | |||
| 172 | + | w := m.Current() | |||
| 173 | + | w.ConsumeOrExpel(-1) // two windows in the same column | |||
| 174 | + | w.AddFloating(9, 100, 80) | |||
| 175 | + | w.FocusID(1) // top of the column | |||
| 176 | + | w.FocusID(2) // bottom of the column | |||
| 177 | + | if !w.FocusWindow(-1) { | |||
| 178 | + | t.Fatal("up inside column failed") | |||
| 179 | + | } | |||
| 180 | + | if id, _ := w.Focused(); id != 1 || !w.Floats[0].below { | |||
| 181 | + | t.Fatalf("up inside column focused %d, float %+v", id, w.Floats[0]) | |||
| 182 | + | } | |||
| 183 | + | if !w.FocusWindow(-1) { | |||
| 184 | + | t.Fatal("up to float failed") | |||
| 185 | + | } | |||
| 186 | + | if id, _ := w.Focused(); id != 9 { | |||
| 187 | + | t.Fatalf("up to float focused %d", id) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | m = monitor() | |||
| 191 | + | m.SetOverflow(OverflowFixed) | |||
| 192 | + | m.SetMaxColumns(2) | |||
| 193 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 194 | + | m.AddWindow(id) | |||
| 195 | + | } | |||
| 196 | + | w = m.Current() | |||
| 197 | + | w.AddFloating(9, 100, 80) | |||
| 198 | + | w.FocusID(4) | |||
| 199 | + | if !w.FocusWindow(-1) { | |||
| 200 | + | t.Fatal("up to on-screen column failed") | |||
| 201 | + | } | |||
| 202 | + | if id, _ := w.Focused(); id != 2 || !w.Floats[0].below { | |||
| 203 | + | t.Fatalf("up focused %d, float %+v", id, w.Floats[0]) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestFocusWindowWithoutDemotedFloatChangesWorkspace(t *testing.T) { | |||
| 208 | + | m := monitor() | |||
| 209 | + | m.AddWindow(1) | |||
| 210 | + | m.Focus(1) | |||
| 211 | + | m.AddWindow(2) | |||
| 212 | + | m.Apply(ActionFocusWindowUp) | |||
| 213 | + | if m.Active != 0 { | |||
| 214 | + | t.Fatalf("up on workspace %d", m.Active) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // Up from a shown stash stays in the stash, even with a demoted covering | |||
| 219 | + | // float; from a pinned fullscreen window it goes to the workspace above, | |||
| 220 | + | // as without covering floats. | |||
| 221 | + | func TestFocusWindowUpStashAndPinned(t *testing.T) { | |||
| 222 | + | m := monitor() | |||
| 223 | + | m.SetBorder(2) | |||
| 224 | + | m.AddWindow(1) | |||
| 225 | + | m.AddWindow(2) | |||
| 226 | + | w := m.Current() | |||
| 227 | + | w.AddFloating(9, 100, 80) | |||
| 228 | + | w.FocusID(2) | |||
| 229 | + | w.ToggleWindowStash() // 2 to the shown stash, focused | |||
| 230 | + | if !w.Floats[0].below { | |||
| 231 | + | t.Fatalf("float not below: %+v", w.Floats[0]) | |||
| 232 | + | } | |||
| 233 | + | m.Apply(ActionFocusWindowUp) | |||
| 234 | + | if id, _ := w.Focused(); id != 2 || !w.Floats[0].below || m.Active != 0 { | |||
| 235 | + | t.Fatalf("stash up focused %d on %d, float %+v", id, m.Active, w.Floats[0]) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | m = monitor() | |||
| 239 | + | m.SetOverflow(OverflowFixed) | |||
| 240 | + | m.AddWindow(1) | |||
| 241 | + | m.AddFloating(2, 50, 40) | |||
| 242 | + | m.SetFullscreen(2, true) // own workspace below | |||
| 243 | + | if m.Active != 1 || !m.Current().pinned() { | |||
| 244 | + | t.Fatalf("setup: active %d", m.Active) | |||
| 245 | + | } | |||
| 246 | + | m.Apply(ActionFocusWindowUp) | |||
| 247 | + | if id, _ := m.Current().Focused(); id != 1 || m.Active != 0 { | |||
| 248 | + | t.Fatalf("pinned up focused %d on %d", id, m.Active) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // Directional focus on a covering float with nothing under it keeps the | |||
| 253 | + | // float focused. | |||
| 254 | + | func TestCoveringFloatAloneKeepsFocus(t *testing.T) { | |||
| 255 | + | w := workspace() | |||
| 256 | + | w.border = 2 | |||
| 257 | + | w.AddFloating(3, 98, 79) | |||
| 258 | + | w.FocusColumn(-1) | |||
| 259 | + | w.FocusWindow(1) | |||
| 260 | + | if id, ok := w.Focused(); !ok || id != 3 { | |||
| 261 | + | t.Fatalf("focus %d %v", id, ok) | |||
| 262 | + | } | |||
| 263 | + | wantOrder(t, w, 3) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // A tile returning from its own fullscreen workspace with the focus comes | |||
| 267 | + | // back in front of a covering float mapped in its origin meanwhile. | |||
| 268 | + | func TestFullscreenReturnRaisesColumns(t *testing.T) { | |||
| 269 | + | for _, stacked := range []bool{false, true} { | |||
| 270 | + | m := monitor() | |||
| 271 | + | m.SetBorder(2) | |||
| 272 | + | m.SetOverflow(OverflowFixed) | |||
| 273 | + | m.AddWindow(1) | |||
| 274 | + | m.AddWindow(2) | |||
| 275 | + | if stacked { | |||
| 276 | + | m.Current().ConsumeOrExpel(-1) | |||
| 277 | + | } | |||
| 278 | + | w := m.Current() | |||
| 279 | + | w.FocusID(2) | |||
| 280 | + | m.ToggleFullscreen() // 2 to its own workspace | |||
| 281 | + | w.AddFloating(3, 100, 80) | |||
| 282 | + | m.ToggleFullscreen() // back, focused | |||
| 283 | + | if id, _ := w.Focused(); id != 2 { | |||
| 284 | + | t.Fatalf("stacked %v: focus %d", stacked, id) | |||
| 285 | + | } | |||
| 286 | + | if got := order(w); got[0] != 3 { | |||
| 287 | + | t.Fatalf("stacked %v: order %v, covering float not below", stacked, got) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | } | |||
A internal/core/float_review_regression_test.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestFullscreenFloatReturnPreservesDialogFocus(t *testing.T) { | |
| 6 | + | for _, covering := range []bool{false, true} { | |
| 7 | + | m := monitor() | |
| 8 | + | m.SetOverflow(OverflowFixed) | |
| 9 | + | m.AddWindow(1) | |
| 10 | + | size := 30 | |
| 11 | + | if covering { | |
| 12 | + | size = 100 | |
| 13 | + | } | |
| 14 | + | m.AddFloating(2, size, size) | |
| 15 | + | w := m.Current() | |
| 16 | + | if covering { | |
| 17 | + | w.FocusID(1) | |
| 18 | + | } | |
| 19 | + | m.SetFullscreen(2, true) | |
| 20 | + | m.show(w) | |
| 21 | + | m.AddFloating(3, 20, 20) | |
| 22 | + | m.SetFullscreen(2, false) | |
| 23 | + | if id, _ := w.Focused(); id != 3 || !w.floatFocus || w.Floats[len(w.Floats)-1].ID != 3 { | |
| 24 | + | t.Fatalf("covering %v: focus %d, float focus %v, floats %+v", covering, id, w.floatFocus, w.Floats) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestDemotedFloatFullscreenUserReturn(t *testing.T) { | |
| 30 | + | m := monitor() | |
| 31 | + | m.SetOverflow(OverflowFixed) | |
| 32 | + | m.AddWindow(1) | |
| 33 | + | m.AddFloating(2, 100, 80) | |
| 34 | + | w := m.Current() | |
| 35 | + | w.FocusID(1) | |
| 36 | + | m.SetFullscreen(2, true) | |
| 37 | + | m.Focus(1) | |
| 38 | + | m.Current().FocusID(2) | |
| 39 | + | m.SetFullscreen(2, false) | |
| 40 | + | if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below { | |
| 41 | + | t.Fatalf("returned focus %d floats %+v", id, w.Floats) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestSetNamedDemotedFloatKeepsColumnActions(t *testing.T) { | |
| 46 | + | m := monitor() | |
| 47 | + | m.SetNamed([]NamedWorkspace{{Name: "work"}}) | |
| 48 | + | m.AddWindow(1) | |
| 49 | + | m.AddFloating(2, 100, 80) | |
| 50 | + | m.Current().FocusID(1) | |
| 51 | + | m.SetNamed(nil) | |
| 52 | + | w := m.Current() | |
| 53 | + | if id, _ := w.Focused(); id != 1 || w.onFloat() || w.floatFocus { | |
| 54 | + | t.Fatalf("focus %d float focus %v floats %+v", id, w.floatFocus, w.Floats) | |
| 55 | + | } | |
| 56 | + | w.ToggleFullWidth() | |
| 57 | + | if !w.Columns[w.Focus].FullWidth { | |
| 58 | + | t.Fatal("column action was blocked") | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestFloatOnlyWorkspaceDirectionalExit(t *testing.T) { | |
| 63 | + | for _, dir := range []int{-1, 1} { | |
| 64 | + | m := monitor() | |
| 65 | + | m.AddWindow(1) | |
| 66 | + | m.Focus(1) | |
| 67 | + | m.AddFloating(2, 20, 20) | |
| 68 | + | m.Focus(2) | |
| 69 | + | m.AddWindow(3) | |
| 70 | + | m.Focus(1) | |
| 71 | + | a := ActionFocusWindowUp | |
| 72 | + | if dir > 0 { | |
| 73 | + | a = ActionFocusWindowDown | |
| 74 | + | } | |
| 75 | + | m.Apply(a) | |
| 76 | + | if m.Active != 1+dir { | |
| 77 | + | t.Fatalf("direction %d stayed on workspace %d", dir, m.Active) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestOverviewMaximizedPreviewAfterAnchorRemoval(t *testing.T) { | |
| 83 | + | m := maximizedOverview() | |
| 84 | + | w := m.Current() | |
| 85 | + | w.Columns[1].Windows = append(w.Columns[1].Windows, 5) | |
| 86 | + | // Closing the maximization anchor leaves the surviving column maximized. | |
| 87 | + | w.RemoveWindow(2) | |
| 88 | + | m.ToggleOverview() | |
| 89 | + | p := previewOf(t, m.Layout(), 5) | |
| 90 | + | if p.Hidden || p.Preview <= 0 { | |
| 91 | + | t.Fatalf("preview %+v", p) | |
| 92 | + | } | |
| 93 | + | real := previewOf(t, w.Layout(), 5) | |
| 94 | + | if p.Rect.W != int(float64(real.Rect.W)*p.Preview+0.5) || p.Rect.H != int(float64(real.Rect.H)*p.Preview+0.5) { | |
| 95 | + | t.Fatalf("preview %+v real %+v", p, real) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestOverviewPickStackedColumnWindow(t *testing.T) { | |
| 100 | + | for _, hidden := range []bool{false, true} { | |
| 101 | + | m := maximizedOverview() | |
| 102 | + | w := m.Current() | |
| 103 | + | i := w.Focus | |
| 104 | + | if hidden { | |
| 105 | + | i = 2 | |
| 106 | + | } | |
| 107 | + | w.Columns[i].Windows = append(w.Columns[i].Windows, 6) | |
| 108 | + | m.ToggleOverview() | |
| 109 | + | m.OverviewPick(6) | |
| 110 | + | if id, _ := w.Focused(); id != 6 || m.ov.open { | |
| 111 | + | t.Fatalf("hidden %v: picked focus %d, overview %v", hidden, id, m.ov.open) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestOverviewTargetAfterColumnActivation(t *testing.T) { | |
| 117 | + | for _, maximized := range []bool{false, true} { | |
| 118 | + | m := overviewMonitor() | |
| 119 | + | w := m.Current() | |
| 120 | + | w.AddWindow(5) | |
| 121 | + | w.ConsumeOrExpel(-1) | |
| 122 | + | w.FocusID(3) | |
| 123 | + | if maximized { | |
| 124 | + | m.SetOverflow(OverflowFixed) | |
| 125 | + | w.ToggleFullWidth() | |
| 126 | + | } | |
| 127 | + | m.ToggleOverview() | |
| 128 | + | if maximized && w.stack()[0].kind != stackColumn { | |
| 129 | + | t.Fatalf("expected maximized column card: %+v", w.stack()) | |
| 130 | + | } | |
| 131 | + | w.Activate(5) | |
| 132 | + | var highlighted WindowID | |
| 133 | + | for _, p := range m.Layout() { | |
| 134 | + | if p.Focused && !p.Hidden { | |
| 135 | + | highlighted = p.ID | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted { | |
| 139 | + | t.Fatalf("maximized %v: highlight %d target %d", maximized, highlighted, m.overviewTarget()) | |
| 140 | + | } | |
| 141 | + | m.ToggleOverview() | |
| 142 | + | if id, _ := w.Focused(); id != highlighted { | |
| 143 | + | t.Fatalf("accepted %d want %d", id, highlighted) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| @@ -0,0 +1,146 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestFullscreenFloatReturnPreservesDialogFocus(t *testing.T) { | |||
| 6 | + | for _, covering := range []bool{false, true} { | |||
| 7 | + | m := monitor() | |||
| 8 | + | m.SetOverflow(OverflowFixed) | |||
| 9 | + | m.AddWindow(1) | |||
| 10 | + | size := 30 | |||
| 11 | + | if covering { | |||
| 12 | + | size = 100 | |||
| 13 | + | } | |||
| 14 | + | m.AddFloating(2, size, size) | |||
| 15 | + | w := m.Current() | |||
| 16 | + | if covering { | |||
| 17 | + | w.FocusID(1) | |||
| 18 | + | } | |||
| 19 | + | m.SetFullscreen(2, true) | |||
| 20 | + | m.show(w) | |||
| 21 | + | m.AddFloating(3, 20, 20) | |||
| 22 | + | m.SetFullscreen(2, false) | |||
| 23 | + | if id, _ := w.Focused(); id != 3 || !w.floatFocus || w.Floats[len(w.Floats)-1].ID != 3 { | |||
| 24 | + | t.Fatalf("covering %v: focus %d, float focus %v, floats %+v", covering, id, w.floatFocus, w.Floats) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestDemotedFloatFullscreenUserReturn(t *testing.T) { | |||
| 30 | + | m := monitor() | |||
| 31 | + | m.SetOverflow(OverflowFixed) | |||
| 32 | + | m.AddWindow(1) | |||
| 33 | + | m.AddFloating(2, 100, 80) | |||
| 34 | + | w := m.Current() | |||
| 35 | + | w.FocusID(1) | |||
| 36 | + | m.SetFullscreen(2, true) | |||
| 37 | + | m.Focus(1) | |||
| 38 | + | m.Current().FocusID(2) | |||
| 39 | + | m.SetFullscreen(2, false) | |||
| 40 | + | if id, _ := w.Focused(); id != 2 || !w.floatFocus || w.Floats[0].below { | |||
| 41 | + | t.Fatalf("returned focus %d floats %+v", id, w.Floats) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestSetNamedDemotedFloatKeepsColumnActions(t *testing.T) { | |||
| 46 | + | m := monitor() | |||
| 47 | + | m.SetNamed([]NamedWorkspace{{Name: "work"}}) | |||
| 48 | + | m.AddWindow(1) | |||
| 49 | + | m.AddFloating(2, 100, 80) | |||
| 50 | + | m.Current().FocusID(1) | |||
| 51 | + | m.SetNamed(nil) | |||
| 52 | + | w := m.Current() | |||
| 53 | + | if id, _ := w.Focused(); id != 1 || w.onFloat() || w.floatFocus { | |||
| 54 | + | t.Fatalf("focus %d float focus %v floats %+v", id, w.floatFocus, w.Floats) | |||
| 55 | + | } | |||
| 56 | + | w.ToggleFullWidth() | |||
| 57 | + | if !w.Columns[w.Focus].FullWidth { | |||
| 58 | + | t.Fatal("column action was blocked") | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestFloatOnlyWorkspaceDirectionalExit(t *testing.T) { | |||
| 63 | + | for _, dir := range []int{-1, 1} { | |||
| 64 | + | m := monitor() | |||
| 65 | + | m.AddWindow(1) | |||
| 66 | + | m.Focus(1) | |||
| 67 | + | m.AddFloating(2, 20, 20) | |||
| 68 | + | m.Focus(2) | |||
| 69 | + | m.AddWindow(3) | |||
| 70 | + | m.Focus(1) | |||
| 71 | + | a := ActionFocusWindowUp | |||
| 72 | + | if dir > 0 { | |||
| 73 | + | a = ActionFocusWindowDown | |||
| 74 | + | } | |||
| 75 | + | m.Apply(a) | |||
| 76 | + | if m.Active != 1+dir { | |||
| 77 | + | t.Fatalf("direction %d stayed on workspace %d", dir, m.Active) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestOverviewMaximizedPreviewAfterAnchorRemoval(t *testing.T) { | |||
| 83 | + | m := maximizedOverview() | |||
| 84 | + | w := m.Current() | |||
| 85 | + | w.Columns[1].Windows = append(w.Columns[1].Windows, 5) | |||
| 86 | + | // Closing the maximization anchor leaves the surviving column maximized. | |||
| 87 | + | w.RemoveWindow(2) | |||
| 88 | + | m.ToggleOverview() | |||
| 89 | + | p := previewOf(t, m.Layout(), 5) | |||
| 90 | + | if p.Hidden || p.Preview <= 0 { | |||
| 91 | + | t.Fatalf("preview %+v", p) | |||
| 92 | + | } | |||
| 93 | + | real := previewOf(t, w.Layout(), 5) | |||
| 94 | + | if p.Rect.W != int(float64(real.Rect.W)*p.Preview+0.5) || p.Rect.H != int(float64(real.Rect.H)*p.Preview+0.5) { | |||
| 95 | + | t.Fatalf("preview %+v real %+v", p, real) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestOverviewPickStackedColumnWindow(t *testing.T) { | |||
| 100 | + | for _, hidden := range []bool{false, true} { | |||
| 101 | + | m := maximizedOverview() | |||
| 102 | + | w := m.Current() | |||
| 103 | + | i := w.Focus | |||
| 104 | + | if hidden { | |||
| 105 | + | i = 2 | |||
| 106 | + | } | |||
| 107 | + | w.Columns[i].Windows = append(w.Columns[i].Windows, 6) | |||
| 108 | + | m.ToggleOverview() | |||
| 109 | + | m.OverviewPick(6) | |||
| 110 | + | if id, _ := w.Focused(); id != 6 || m.ov.open { | |||
| 111 | + | t.Fatalf("hidden %v: picked focus %d, overview %v", hidden, id, m.ov.open) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestOverviewTargetAfterColumnActivation(t *testing.T) { | |||
| 117 | + | for _, maximized := range []bool{false, true} { | |||
| 118 | + | m := overviewMonitor() | |||
| 119 | + | w := m.Current() | |||
| 120 | + | w.AddWindow(5) | |||
| 121 | + | w.ConsumeOrExpel(-1) | |||
| 122 | + | w.FocusID(3) | |||
| 123 | + | if maximized { | |||
| 124 | + | m.SetOverflow(OverflowFixed) | |||
| 125 | + | w.ToggleFullWidth() | |||
| 126 | + | } | |||
| 127 | + | m.ToggleOverview() | |||
| 128 | + | if maximized && w.stack()[0].kind != stackColumn { | |||
| 129 | + | t.Fatalf("expected maximized column card: %+v", w.stack()) | |||
| 130 | + | } | |||
| 131 | + | w.Activate(5) | |||
| 132 | + | var highlighted WindowID | |||
| 133 | + | for _, p := range m.Layout() { | |||
| 134 | + | if p.Focused && !p.Hidden { | |||
| 135 | + | highlighted = p.ID | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted { | |||
| 139 | + | t.Fatalf("maximized %v: highlight %d target %d", maximized, highlighted, m.overviewTarget()) | |||
| 140 | + | } | |||
| 141 | + | m.ToggleOverview() | |||
| 142 | + | if id, _ := w.Focused(); id != highlighted { | |||
| 143 | + | t.Fatalf("accepted %d want %d", id, highlighted) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
M internal/core/gesture.go +3 -3
| @@ -159,7 +159,7 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 159 | 159 | case g.fingers == 4: | |
| 160 | 160 | // Four fingers sideways do nothing. | |
| 161 | 161 | g.mode = swipeDropped | |
| 162 | - | case m.overview: | |
| 162 | + | case m.ov.open: | |
| 163 | 163 | // The overview does not slide: the swipe moves its selection. | |
| 164 | 164 | g.mode = swipeDiscrete | |
| 165 | 165 | case g.horizontal && w.slidable(): | |
| @@ -238,7 +238,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 238 | 238 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | |
| 239 | 239 | case swipeOverview: | |
| 240 | 240 | p := g.tracker.projectedEnd() | |
| 241 | - | if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.overview { | |
| 241 | + | if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.ov.open { | |
| 242 | 242 | return false | |
| 243 | 243 | } | |
| 244 | 244 | before := m.Current() | |
| @@ -267,7 +267,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 267 | 267 | } | |
| 268 | 268 | before := c.cur().mon.Current() | |
| 269 | 269 | c.keyboard.takeBack() | |
| 270 | - | if mon := c.cur().mon; mon.overview { | |
| 270 | + | if mon := c.cur().mon; mon.ov.open { | |
| 271 | 271 | mon.overviewSwipe(a) | |
| 272 | 272 | } else { | |
| 273 | 273 | c.applyAction(a) | |
| @@ -159,7 +159,7 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 159 | case g.fingers == 4: | 159 | case g.fingers == 4: | ||
| 160 | // Four fingers sideways do nothing. | 160 | // Four fingers sideways do nothing. | ||
| 161 | g.mode = swipeDropped | 161 | g.mode = swipeDropped | ||
| 162 | - | case m.overview: | 162 | + | case m.ov.open: |
| 163 | // The overview does not slide: the swipe moves its selection. | 163 | // The overview does not slide: the swipe moves its selection. | ||
| 164 | g.mode = swipeDiscrete | 164 | g.mode = swipeDiscrete | ||
| 165 | case g.horizontal && w.slidable(): | 165 | case g.horizontal && w.slidable(): | ||
| @@ -238,7 +238,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 238 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | 238 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | ||
| 239 | case swipeOverview: | 239 | case swipeOverview: | ||
| 240 | p := g.tracker.projectedEnd() | 240 | p := g.tracker.projectedEnd() | ||
| 241 | - | if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.overview { | 241 | + | if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.ov.open { |
| 242 | return false | 242 | return false | ||
| 243 | } | 243 | } | ||
| 244 | before := m.Current() | 244 | before := m.Current() | ||
| @@ -267,7 +267,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 267 | } | 267 | } | ||
| 268 | before := c.cur().mon.Current() | 268 | before := c.cur().mon.Current() | ||
| 269 | c.keyboard.takeBack() | 269 | c.keyboard.takeBack() | ||
| 270 | - | if mon := c.cur().mon; mon.overview { | 270 | + | if mon := c.cur().mon; mon.ov.open { |
| 271 | mon.overviewSwipe(a) | 271 | mon.overviewSwipe(a) | ||
| 272 | } else { | 272 | } else { | ||
| 273 | c.applyAction(a) | 273 | c.applyAction(a) | ||
M internal/core/layers.go +1 -1
| @@ -90,7 +90,7 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||
| 90 | 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | |
| 91 | 91 | // one shown as an overview preview. | |
| 92 | 92 | func hasFullscreen(sc *screen) bool { | |
| 93 | - | return !sc.mon.overview && sc.mon.Current().cover() != 0 | |
| 93 | + | return !sc.mon.ov.open && sc.mon.Current().cover() != 0 | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | 96 | // shown reports whether a layer surface is drawn. A fullscreen window is | |
| @@ -90,7 +90,7 @@ func (c *Core) layerOf(id WindowID) (*screen, ports.SceneLayer, bool) { | |||||
| 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | 90 | // scroll overflow, one scrolled off by the user no longer does; nor does | ||
| 91 | // one shown as an overview preview. | 91 | // one shown as an overview preview. | ||
| 92 | func hasFullscreen(sc *screen) bool { | 92 | func hasFullscreen(sc *screen) bool { | ||
| 93 | - | return !sc.mon.overview && sc.mon.Current().cover() != 0 | 93 | + | return !sc.mon.ov.open && sc.mon.Current().cover() != 0 |
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | // shown reports whether a layer surface is drawn. A fullscreen window is | 96 | // shown reports whether a layer surface is drawn. A fullscreen window is | ||
M internal/core/layout.go +194 -48
| @@ -96,8 +96,9 @@ type Placement struct { | |||
| 96 | 96 | ID WindowID | |
| 97 | 97 | Rect Rect | |
| 98 | 98 | Fullscreen, Focused, Hidden bool | |
| 99 | - | // Floating windows sit over the columns at their own size. | |
| 100 | - | Floating bool | |
| 99 | + | // Floating windows sit at their own size. Below marks a native float | |
| 100 | + | // ordered behind the columns (never a stashed window). | |
| 101 | + | Floating, Below bool | |
| 101 | 102 | // Peek is a stashed window peeking in beside the selected one, or an | |
| 102 | 103 | // overview preview of a neighbor workspace: dimmed. | |
| 103 | 104 | Peek bool | |
| @@ -129,8 +130,11 @@ type Workspace struct { | |||
| 129 | 130 | Name string | |
| 130 | 131 | Overflow Overflow | |
| 131 | 132 | Columns []Column | |
| 132 | - | Focus int | |
| 133 | - | ViewX int | |
| 133 | + | // maximized remembers recently maximized columns by window ID, newest | |
| 134 | + | // first; stack() filters removed windows at read time. | |
| 135 | + | maximized []WindowID | |
| 136 | + | Focus int | |
| 137 | + | ViewX int | |
| 134 | 138 | // shift slides the columns on screen past ViewX, in logical pixels, | |
| 135 | 139 | // while a swipe follows the fingers or its spring (motion) lands. | |
| 136 | 140 | shift float64 | |
| @@ -143,8 +147,8 @@ type Workspace struct { | |||
| 143 | 147 | border int | |
| 144 | 148 | presets []Width | |
| 145 | 149 | fullscreen WindowID | |
| 146 | - | // Floats are native floating windows (dialogs), bottom to top; | |
| 147 | - | // floatFocus is set while the top one has the focus. | |
| 150 | + | // Floats are native floating windows in user selection order; | |
| 151 | + | // below/above and dialog groups determine their layout order. | |
| 148 | 152 | Floats []Float | |
| 149 | 153 | floatFocus bool | |
| 150 | 154 | // Stash holds the windows set aside by toggle-window-stash, left to | |
| @@ -190,6 +194,8 @@ type origPlace struct { | |||
| 190 | 194 | // fullWidth is the column's maximize-column state. | |
| 191 | 195 | fullWidth bool | |
| 192 | 196 | float *Float | |
| 197 | + | // floatAt is the original native float position on fullscreen entry. | |
| 198 | + | floatAt int | |
| 193 | 199 | // stash is set when float came from the stash, at index col. | |
| 194 | 200 | stash bool | |
| 195 | 201 | } | |
| @@ -198,6 +204,8 @@ type origPlace struct { | |||
| 198 | 204 | type Float struct { | |
| 199 | 205 | ID WindowID | |
| 200 | 206 | W, H int | |
| 207 | + | // below places a covering native float behind columns and stash. | |
| 208 | + | below bool | |
| 201 | 209 | // back records the former column of a stashed window; nil for native floats. | |
| 202 | 210 | back *origPlace | |
| 203 | 211 | } | |
| @@ -226,6 +234,56 @@ func (w *Workspace) floatIndex(id WindowID) int { | |||
| 226 | 234 | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | |
| 227 | 235 | } | |
| 228 | 236 | ||
| 237 | + | // coversFloat uses the placed rectangle, not a map-time size. Allow up to | |
| 238 | + | // two border widths plus two logical pixels per axis for scale rounding. | |
| 239 | + | func (w *Workspace) coversFloat(f Float) bool { | |
| 240 | + | if w.fullscreen == f.ID || w.Usable.W <= 0 || w.Usable.H <= 0 { | |
| 241 | + | return false | |
| 242 | + | } | |
| 243 | + | r := w.floatRect(f) | |
| 244 | + | return w.Usable.W-r.W <= 2*w.border+2 && w.Usable.H-r.H <= 2*w.border+2 | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // reconcileFloats promotes floats that no longer cover, without raising | |
| 248 | + | // existing covering floats on automatic geometry changes. It also drops | |
| 249 | + | // float focus when no raised float remains on top. | |
| 250 | + | func (w *Workspace) reconcileFloats() { | |
| 251 | + | for i := range w.Floats { | |
| 252 | + | if w.Floats[i].below && !w.coversFloat(w.Floats[i]) && w.fullscreen != w.Floats[i].ID { | |
| 253 | + | w.Floats[i].below = false | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { | |
| 257 | + | w.floatFocus = false | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // canLeaveFloat reports whether columns or a shown stash can receive focus. | |
| 262 | + | func (w *Workspace) canLeaveFloat() bool { | |
| 263 | + | return len(w.Columns) > 0 || !w.stashHidden && len(w.Stash) > 0 | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // leaveFloat moves focus to the columns or shown stash under the float. | |
| 267 | + | // Without either, it keeps focus and reports false. | |
| 268 | + | func (w *Workspace) leaveFloat() bool { | |
| 269 | + | if !w.canLeaveFloat() { | |
| 270 | + | return false | |
| 271 | + | } | |
| 272 | + | w.floatFocus = false | |
| 273 | + | w.raiseColumns() | |
| 274 | + | return true | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // raiseColumns moves only covering floats above the columns behind them. | |
| 278 | + | // The user-selected column group rises as one; dialogs remain above it. | |
| 279 | + | func (w *Workspace) raiseColumns() { | |
| 280 | + | for i := range w.Floats { | |
| 281 | + | if w.coversFloat(w.Floats[i]) { | |
| 282 | + | w.Floats[i].below = true | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 229 | 287 | // AddFloating shows a native floating window over the columns and the | |
| 230 | 288 | // stash, and focuses it. | |
| 231 | 289 | func (w *Workspace) AddFloating(id WindowID, width, height int) { | |
| @@ -240,6 +298,7 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 240 | 298 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | |
| 241 | 299 | if i := w.floatIndex(id); i >= 0 { | |
| 242 | 300 | w.Floats[i].W, w.Floats[i].H = width, height | |
| 301 | + | w.reconcileFloats() | |
| 243 | 302 | } | |
| 244 | 303 | } | |
| 245 | 304 | ||
| @@ -263,7 +322,7 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 263 | 322 | if full := w.cover(); full != 0 { | |
| 264 | 323 | return full, true | |
| 265 | 324 | } | |
| 266 | - | if w.floatFocus && len(w.Floats) > 0 { | |
| 325 | + | if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below { | |
| 267 | 326 | return w.Floats[len(w.Floats)-1].ID, true | |
| 268 | 327 | } | |
| 269 | 328 | if w.stashFocused() { | |
| @@ -306,7 +365,7 @@ func (w *Workspace) addColumn(col Column) { | |||
| 306 | 365 | at = w.Focus + 1 | |
| 307 | 366 | } | |
| 308 | 367 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | |
| 309 | - | w.Columns[w.Focus].FullWidth = false | |
| 368 | + | w.unmaximize() | |
| 310 | 369 | } | |
| 311 | 370 | w.Columns = append(w.Columns, Column{}) | |
| 312 | 371 | copy(w.Columns[at+1:], w.Columns[at:]) | |
| @@ -366,6 +425,7 @@ func (w *Workspace) unslot(n int) { | |||
| 366 | 425 | } | |
| 367 | 426 | ||
| 368 | 427 | func (w *Workspace) RemoveWindow(id WindowID) { | |
| 428 | + | w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id }) | |
| 369 | 429 | if w.fullscreen == id { | |
| 370 | 430 | w.fullscreen = 0 | |
| 371 | 431 | } | |
| @@ -374,7 +434,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 374 | 434 | } | |
| 375 | 435 | if i := w.floatIndex(id); i >= 0 { | |
| 376 | 436 | w.Floats = slices.Delete(w.Floats, i, i+1) | |
| 377 | - | if len(w.Floats) == 0 { | |
| 437 | + | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { | |
| 378 | 438 | w.floatFocus = false | |
| 379 | 439 | } | |
| 380 | 440 | return | |
| @@ -425,6 +485,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 425 | 485 | func (w *Workspace) FocusID(id WindowID) bool { | |
| 426 | 486 | if i := w.floatIndex(id); i >= 0 { | |
| 427 | 487 | f := w.Floats[i] | |
| 488 | + | f.below = false | |
| 428 | 489 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | |
| 429 | 490 | w.floatFocus = true | |
| 430 | 491 | return true | |
| @@ -440,8 +501,9 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 440 | 501 | for j, v := range w.Columns[i].Windows { | |
| 441 | 502 | if v == id { | |
| 442 | 503 | w.floatFocus, w.stashFocus = false, false | |
| 443 | - | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | |
| 444 | - | w.Columns[w.Focus].FullWidth = false | |
| 504 | + | w.raiseColumns() | |
| 505 | + | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth { | |
| 506 | + | w.unmaximize() | |
| 445 | 507 | } | |
| 446 | 508 | w.Focus = i | |
| 447 | 509 | w.Columns[i].Focus = j | |
| @@ -457,8 +519,9 @@ func (w *Workspace) FocusColumn(dir int) { | |||
| 457 | 519 | return | |
| 458 | 520 | } | |
| 459 | 521 | if w.floatFocus { | |
| 460 | - | // The first move leaves the native float for the stash or columns. | |
| 461 | - | w.floatFocus = false | |
| 522 | + | // The first move leaves the native float for the stash or columns, | |
| 523 | + | // if there is one: otherwise the float keeps the focus. | |
| 524 | + | w.leaveFloat() | |
| 462 | 525 | return | |
| 463 | 526 | } | |
| 464 | 527 | if w.stashFocused() { | |
| @@ -469,8 +532,9 @@ func (w *Workspace) FocusColumn(dir int) { | |||
| 469 | 532 | return | |
| 470 | 533 | } | |
| 471 | 534 | if i := w.columnToward(dir); i >= 0 { | |
| 535 | + | w.raiseColumns() | |
| 472 | 536 | if w.Overflow == OverflowFixed { | |
| 473 | - | w.Columns[w.Focus].FullWidth = false | |
| 537 | + | w.unmaximize() | |
| 474 | 538 | } | |
| 475 | 539 | w.Focus = i | |
| 476 | 540 | w.scroll() | |
| @@ -500,16 +564,15 @@ func (w *Workspace) onScreenFocus() bool { | |||
| 500 | 564 | return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth | |
| 501 | 565 | } | |
| 502 | 566 | ||
| 503 | - | // FocusWindow moves focus inside the column; false means it was already at the edge. | |
| 504 | - | // Fixed overflow also stacks columns (spiral, expanded strips): past the | |
| 505 | - | // column edge, focus goes to the column on screen above or below. | |
| 567 | + | // FocusWindow moves focus inside the column, then to a fixed-overflow column | |
| 568 | + | // above or below on screen. Up at the top raises the topmost demoted covering | |
| 569 | + | // float; false means no window is available in that direction. | |
| 506 | 570 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 507 | 571 | if w.pinned() { | |
| 508 | 572 | return false | |
| 509 | 573 | } | |
| 510 | 574 | if w.floatFocus { | |
| 511 | - | w.floatFocus = false | |
| 512 | - | return true | |
| 575 | + | return w.leaveFloat() | |
| 513 | 576 | } | |
| 514 | 577 | if w.stashFocused() { | |
| 515 | 578 | // The stash is one row: up and down do nothing there. | |
| @@ -521,19 +584,27 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||
| 521 | 584 | c := &w.Columns[w.Focus] | |
| 522 | 585 | if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) { | |
| 523 | 586 | c.Focus += dir | |
| 587 | + | w.raiseColumns() | |
| 524 | 588 | w.scroll() | |
| 525 | 589 | return true | |
| 526 | 590 | } | |
| 527 | - | if !w.onScreenFocus() { | |
| 528 | - | return false | |
| 591 | + | if w.onScreenFocus() { | |
| 592 | + | if i := w.screenNeighbor(0, dir); i >= 0 { | |
| 593 | + | w.Focus = i | |
| 594 | + | w.raiseColumns() | |
| 595 | + | w.Columns[i].Focus = 0 | |
| 596 | + | if dir < 0 { | |
| 597 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |
| 598 | + | } | |
| 599 | + | return true | |
| 600 | + | } | |
| 529 | 601 | } | |
| 530 | - | if i := w.screenNeighbor(0, dir); i >= 0 { | |
| 531 | - | w.Focus = i | |
| 532 | - | w.Columns[i].Focus = 0 | |
| 533 | - | if dir < 0 { | |
| 534 | - | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |
| 602 | + | if dir < 0 { | |
| 603 | + | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 604 | + | if f := w.Floats[i]; f.below && w.coversFloat(f) { | |
| 605 | + | return w.FocusID(f.ID) | |
| 606 | + | } | |
| 535 | 607 | } | |
| 536 | - | return true | |
| 537 | 608 | } | |
| 538 | 609 | return false | |
| 539 | 610 | } | |
| @@ -606,7 +677,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 606 | 677 | func (w *Workspace) insertColumn(at int, col Column) { | |
| 607 | 678 | at = min(max(at, 0), len(w.Columns)) | |
| 608 | 679 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | |
| 609 | - | w.Columns[w.Focus].FullWidth = false | |
| 680 | + | w.unmaximize() | |
| 610 | 681 | } | |
| 611 | 682 | w.Columns = slices.Insert(w.Columns, at, col) | |
| 612 | 683 | w.Focus = at | |
| @@ -636,7 +707,7 @@ func (w *Workspace) CycleWidth() { | |||
| 636 | 707 | } | |
| 637 | 708 | } | |
| 638 | 709 | c.Width = next | |
| 639 | - | c.FullWidth = false | |
| 710 | + | w.unmaximize() | |
| 640 | 711 | w.scroll() | |
| 641 | 712 | } | |
| 642 | 713 | ||
| @@ -651,7 +722,39 @@ func (w *Workspace) toggleExpanded() { | |||
| 651 | 722 | w.Columns[i].Expanded = false | |
| 652 | 723 | } | |
| 653 | 724 | w.Columns[w.Focus].Expanded = on | |
| 725 | + | w.unmaximize() | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles. | |
| 729 | + | func (w *Workspace) maximize(i int) { | |
| 730 | + | if i >= 0 && i < len(w.Columns) { | |
| 731 | + | w.Columns[i].FullWidth = true | |
| 732 | + | id := w.Columns[i].Windows[w.Columns[i].Focus] | |
| 733 | + | w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id }) | |
| 734 | + | w.maximized = slices.Insert(w.maximized, 0, id) | |
| 735 | + | } | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | // unmaximize clears the focused column in scroll mode, or all maximized | |
| 739 | + | // columns in fixed mode (including after Escape changed focus). | |
| 740 | + | func (w *Workspace) unmaximize() { | |
| 741 | + | for i := range w.Columns { | |
| 742 | + | if w.Overflow == OverflowFixed || i == w.Focus { | |
| 743 | + | w.Columns[i].FullWidth = false | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | if w.Overflow == OverflowFixed { | |
| 747 | + | w.maximized = nil | |
| 748 | + | } | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | // transferMaximization changes the maximized column without forgetting MRU. | |
| 752 | + | func (w *Workspace) transferMaximization(i int) { | |
| 753 | + | if i == w.Focus || i < 0 || i >= len(w.Columns) { | |
| 754 | + | return | |
| 755 | + | } | |
| 654 | 756 | w.Columns[w.Focus].FullWidth = false | |
| 757 | + | w.maximize(i) | |
| 655 | 758 | } | |
| 656 | 759 | ||
| 657 | 760 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | |
| @@ -659,8 +762,11 @@ func (w *Workspace) ToggleFullWidth() { | |||
| 659 | 762 | if len(w.Columns) == 0 || w.onFloat() { | |
| 660 | 763 | return | |
| 661 | 764 | } | |
| 662 | - | c := &w.Columns[w.Focus] | |
| 663 | - | c.FullWidth = !c.FullWidth | |
| 765 | + | if w.Columns[w.Focus].FullWidth { | |
| 766 | + | w.unmaximize() | |
| 767 | + | } else { | |
| 768 | + | w.maximize(w.Focus) | |
| 769 | + | } | |
| 664 | 770 | w.scroll() | |
| 665 | 771 | } | |
| 666 | 772 | ||
| @@ -710,6 +816,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||
| 710 | 816 | w.fullscreen = id | |
| 711 | 817 | } else if w.fullscreen == id { | |
| 712 | 818 | w.fullscreen = 0 | |
| 819 | + | w.reconcileFloats() | |
| 713 | 820 | } | |
| 714 | 821 | return | |
| 715 | 822 | } | |
| @@ -750,6 +857,7 @@ func (w *Workspace) SetUsable(r Rect) { | |||
| 750 | 857 | r.W = min(max(r.W, 0), w.Output.W-r.X) | |
| 751 | 858 | r.H = min(max(r.H, 0), w.Output.H-r.Y) | |
| 752 | 859 | w.Usable = r | |
| 860 | + | w.reconcileFloats() | |
| 753 | 861 | w.scroll() | |
| 754 | 862 | } | |
| 755 | 863 | func (w *Workspace) SetGaps(g int) { | |
| @@ -823,7 +931,11 @@ func (w *Workspace) fullscreenColumn(i int) bool { | |||
| 823 | 931 | return false | |
| 824 | 932 | } | |
| 825 | 933 | func (w *Workspace) columnWidth(i int) int { | |
| 826 | - | if w.Columns[i].FullWidth { | |
| 934 | + | return w.columnWidthFor(i, false) | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |
| 938 | + | if w.Columns[i].FullWidth && !ignoreFullWidth { | |
| 827 | 939 | return max(w.Usable.W-2*w.gap(), 0) | |
| 828 | 940 | } | |
| 829 | 941 | if w.fullscreenColumn(i) { | |
| @@ -875,14 +987,23 @@ func (w *Workspace) scroll() { | |||
| 875 | 987 | ||
| 876 | 988 | // columnRects returns each column's area on screen, before stacking windows. | |
| 877 | 989 | func (w *Workspace) columnRects() []Rect { | |
| 990 | + | return w.columnRectsFor(false) | |
| 991 | + | } | |
| 992 | + | ||
| 993 | + | // columnRectsFor shares the fixed layout geometry with the overview's hidden | |
| 994 | + | // columns card, without changing FullWidth or the clients' saved buffers. | |
| 995 | + | func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |
| 878 | 996 | g := w.gap() | |
| 879 | 997 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 880 | 998 | rects := make([]Rect, len(w.Columns)) | |
| 881 | 999 | view := w.ViewX + w.shiftPixels() | |
| 1000 | + | x := w.Usable.X + g | |
| 882 | 1001 | for i := range w.Columns { | |
| 883 | - | rects[i] = Rect{X: w.columnX(i) - view, Y: y, W: w.columnWidth(i), H: h} | |
| 1002 | + | width := w.columnWidthFor(i, ignoreFullWidth) | |
| 1003 | + | rects[i] = Rect{X: x - view, Y: y, W: width, H: h} | |
| 1004 | + | x += width + g | |
| 884 | 1005 | } | |
| 885 | - | if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |
| 1006 | + | if !ignoreFullWidth && w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |
| 886 | 1007 | rects[w.Focus].X = w.Usable.X + g | |
| 887 | 1008 | return rects | |
| 888 | 1009 | } | |
| @@ -1014,23 +1135,48 @@ func (w *Workspace) Layout() []Placement { | |||
| 1014 | 1135 | } | |
| 1015 | 1136 | } | |
| 1016 | 1137 | setVisibleNeighbors(result, gap, w.Output) | |
| 1017 | - | // Floating windows go last: they are drawn and hit on top, native | |
| 1018 | - | // floats (dialogs) over the stash. | |
| 1019 | - | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 1020 | - | for _, f := range w.Floats { | |
| 1021 | - | // A covering fullscreen window hides every float, its own dialogs | |
| 1022 | - | // too: they show again when it leaves fullscreen. | |
| 1023 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover} | |
| 1024 | - | if p.Hidden { | |
| 1025 | - | p.Rect = Rect{} | |
| 1138 | + | // The column/stash group sits between demoted covering floats and | |
| 1139 | + | // floats above it. Keep the order within each group stable. | |
| 1140 | + | tiles := result | |
| 1141 | + | result = nil | |
| 1142 | + | const ( | |
| 1143 | + | belowTiles = iota | |
| 1144 | + | coveringFloats | |
| 1145 | + | dialogs | |
| 1146 | + | ) | |
| 1147 | + | appendFloats := func(group int) { | |
| 1148 | + | for _, f := range w.Floats { | |
| 1149 | + | below := f.below && w.coversFloat(f) | |
| 1150 | + | // Dialogs stay over covering floats even when the latter is | |
| 1151 | + | // selected. Fullscreen floats remain in the upper group. | |
| 1152 | + | order := dialogs | |
| 1153 | + | if below { | |
| 1154 | + | order = belowTiles | |
| 1155 | + | } else if w.coversFloat(f) || w.fullscreen == f.ID { | |
| 1156 | + | order = coveringFloats | |
| 1157 | + | } | |
| 1158 | + | if order != group { | |
| 1159 | + | continue | |
| 1160 | + | } | |
| 1161 | + | // A covering fullscreen window hides every float, its own dialogs | |
| 1162 | + | // too: they show again when it leaves fullscreen. | |
| 1163 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover} | |
| 1164 | + | if p.Hidden { | |
| 1165 | + | p.Rect = Rect{} | |
| 1166 | + | result = append(result, p) | |
| 1167 | + | continue | |
| 1168 | + | } | |
| 1169 | + | if w.fullscreen == f.ID { | |
| 1170 | + | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 | |
| 1171 | + | } | |
| 1026 | 1172 | result = append(result, p) | |
| 1027 | - | continue | |
| 1028 | 1173 | } | |
| 1029 | - | if w.fullscreen == f.ID { | |
| 1030 | - | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 | |
| 1031 | - | } | |
| 1032 | - | result = append(result, p) | |
| 1033 | 1174 | } | |
| 1175 | + | appendFloats(belowTiles) | |
| 1176 | + | result = append(result, tiles...) | |
| 1177 | + | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 1178 | + | appendFloats(coveringFloats) | |
| 1179 | + | appendFloats(dialogs) | |
| 1034 | 1180 | return result | |
| 1035 | 1181 | } | |
| 1036 | 1182 | ||
| @@ -96,8 +96,9 @@ type Placement struct { | |||||
| 96 | ID WindowID | 96 | ID WindowID | ||
| 97 | Rect Rect | 97 | Rect Rect | ||
| 98 | Fullscreen, Focused, Hidden bool | 98 | Fullscreen, Focused, Hidden bool | ||
| 99 | - | // Floating windows sit over the columns at their own size. | 99 | + | // Floating windows sit at their own size. Below marks a native float |
| 100 | - | Floating bool | 100 | + | // ordered behind the columns (never a stashed window). |
| 101 | + | Floating, Below bool | |||
| 101 | // Peek is a stashed window peeking in beside the selected one, or an | 102 | // Peek is a stashed window peeking in beside the selected one, or an | ||
| 102 | // overview preview of a neighbor workspace: dimmed. | 103 | // overview preview of a neighbor workspace: dimmed. | ||
| 103 | Peek bool | 104 | Peek bool | ||
| @@ -129,8 +130,11 @@ type Workspace struct { | |||||
| 129 | Name string | 130 | Name string | ||
| 130 | Overflow Overflow | 131 | Overflow Overflow | ||
| 131 | Columns []Column | 132 | Columns []Column | ||
| 132 | - | Focus int | 133 | + | // maximized remembers recently maximized columns by window ID, newest |
| 133 | - | ViewX int | 134 | + | // first; stack() filters removed windows at read time. |
| 135 | + | maximized []WindowID | |||
| 136 | + | Focus int | |||
| 137 | + | ViewX int | |||
| 134 | // shift slides the columns on screen past ViewX, in logical pixels, | 138 | // shift slides the columns on screen past ViewX, in logical pixels, | ||
| 135 | // while a swipe follows the fingers or its spring (motion) lands. | 139 | // while a swipe follows the fingers or its spring (motion) lands. | ||
| 136 | shift float64 | 140 | shift float64 | ||
| @@ -143,8 +147,8 @@ type Workspace struct { | |||||
| 143 | border int | 147 | border int | ||
| 144 | presets []Width | 148 | presets []Width | ||
| 145 | fullscreen WindowID | 149 | fullscreen WindowID | ||
| 146 | - | // Floats are native floating windows (dialogs), bottom to top; | 150 | + | // Floats are native floating windows in user selection order; |
| 147 | - | // floatFocus is set while the top one has the focus. | 151 | + | // below/above and dialog groups determine their layout order. |
| 148 | Floats []Float | 152 | Floats []Float | ||
| 149 | floatFocus bool | 153 | floatFocus bool | ||
| 150 | // Stash holds the windows set aside by toggle-window-stash, left to | 154 | // Stash holds the windows set aside by toggle-window-stash, left to | ||
| @@ -190,6 +194,8 @@ type origPlace struct { | |||||
| 190 | // fullWidth is the column's maximize-column state. | 194 | // fullWidth is the column's maximize-column state. | ||
| 191 | fullWidth bool | 195 | fullWidth bool | ||
| 192 | float *Float | 196 | float *Float | ||
| 197 | + | // floatAt is the original native float position on fullscreen entry. | |||
| 198 | + | floatAt int | |||
| 193 | // stash is set when float came from the stash, at index col. | 199 | // stash is set when float came from the stash, at index col. | ||
| 194 | stash bool | 200 | stash bool | ||
| 195 | } | 201 | } | ||
| @@ -198,6 +204,8 @@ type origPlace struct { | |||||
| 198 | type Float struct { | 204 | type Float struct { | ||
| 199 | ID WindowID | 205 | ID WindowID | ||
| 200 | W, H int | 206 | W, H int | ||
| 207 | + | // below places a covering native float behind columns and stash. | |||
| 208 | + | below bool | |||
| 201 | // back records the former column of a stashed window; nil for native floats. | 209 | // back records the former column of a stashed window; nil for native floats. | ||
| 202 | back *origPlace | 210 | back *origPlace | ||
| 203 | } | 211 | } | ||
| @@ -226,6 +234,56 @@ func (w *Workspace) floatIndex(id WindowID) int { | |||||
| 226 | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | 234 | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | ||
| 227 | } | 235 | } | ||
| 228 | 236 | ||||
| 237 | + | // coversFloat uses the placed rectangle, not a map-time size. Allow up to | |||
| 238 | + | // two border widths plus two logical pixels per axis for scale rounding. | |||
| 239 | + | func (w *Workspace) coversFloat(f Float) bool { | |||
| 240 | + | if w.fullscreen == f.ID || w.Usable.W <= 0 || w.Usable.H <= 0 { | |||
| 241 | + | return false | |||
| 242 | + | } | |||
| 243 | + | r := w.floatRect(f) | |||
| 244 | + | return w.Usable.W-r.W <= 2*w.border+2 && w.Usable.H-r.H <= 2*w.border+2 | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // reconcileFloats promotes floats that no longer cover, without raising | |||
| 248 | + | // existing covering floats on automatic geometry changes. It also drops | |||
| 249 | + | // float focus when no raised float remains on top. | |||
| 250 | + | func (w *Workspace) reconcileFloats() { | |||
| 251 | + | for i := range w.Floats { | |||
| 252 | + | if w.Floats[i].below && !w.coversFloat(w.Floats[i]) && w.fullscreen != w.Floats[i].ID { | |||
| 253 | + | w.Floats[i].below = false | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { | |||
| 257 | + | w.floatFocus = false | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // canLeaveFloat reports whether columns or a shown stash can receive focus. | |||
| 262 | + | func (w *Workspace) canLeaveFloat() bool { | |||
| 263 | + | return len(w.Columns) > 0 || !w.stashHidden && len(w.Stash) > 0 | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // leaveFloat moves focus to the columns or shown stash under the float. | |||
| 267 | + | // Without either, it keeps focus and reports false. | |||
| 268 | + | func (w *Workspace) leaveFloat() bool { | |||
| 269 | + | if !w.canLeaveFloat() { | |||
| 270 | + | return false | |||
| 271 | + | } | |||
| 272 | + | w.floatFocus = false | |||
| 273 | + | w.raiseColumns() | |||
| 274 | + | return true | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // raiseColumns moves only covering floats above the columns behind them. | |||
| 278 | + | // The user-selected column group rises as one; dialogs remain above it. | |||
| 279 | + | func (w *Workspace) raiseColumns() { | |||
| 280 | + | for i := range w.Floats { | |||
| 281 | + | if w.coversFloat(w.Floats[i]) { | |||
| 282 | + | w.Floats[i].below = true | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 229 | // AddFloating shows a native floating window over the columns and the | 287 | // AddFloating shows a native floating window over the columns and the | ||
| 230 | // stash, and focuses it. | 288 | // stash, and focuses it. | ||
| 231 | func (w *Workspace) AddFloating(id WindowID, width, height int) { | 289 | func (w *Workspace) AddFloating(id WindowID, width, height int) { | ||
| @@ -240,6 +298,7 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||||
| 240 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | 298 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | ||
| 241 | if i := w.floatIndex(id); i >= 0 { | 299 | if i := w.floatIndex(id); i >= 0 { | ||
| 242 | w.Floats[i].W, w.Floats[i].H = width, height | 300 | w.Floats[i].W, w.Floats[i].H = width, height | ||
| 301 | + | w.reconcileFloats() | |||
| 243 | } | 302 | } | ||
| 244 | } | 303 | } | ||
| 245 | 304 | ||||
| @@ -263,7 +322,7 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||||
| 263 | if full := w.cover(); full != 0 { | 322 | if full := w.cover(); full != 0 { | ||
| 264 | return full, true | 323 | return full, true | ||
| 265 | } | 324 | } | ||
| 266 | - | if w.floatFocus && len(w.Floats) > 0 { | 325 | + | if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below { |
| 267 | return w.Floats[len(w.Floats)-1].ID, true | 326 | return w.Floats[len(w.Floats)-1].ID, true | ||
| 268 | } | 327 | } | ||
| 269 | if w.stashFocused() { | 328 | if w.stashFocused() { | ||
| @@ -306,7 +365,7 @@ func (w *Workspace) addColumn(col Column) { | |||||
| 306 | at = w.Focus + 1 | 365 | at = w.Focus + 1 | ||
| 307 | } | 366 | } | ||
| 308 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | 367 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | ||
| 309 | - | w.Columns[w.Focus].FullWidth = false | 368 | + | w.unmaximize() |
| 310 | } | 369 | } | ||
| 311 | w.Columns = append(w.Columns, Column{}) | 370 | w.Columns = append(w.Columns, Column{}) | ||
| 312 | copy(w.Columns[at+1:], w.Columns[at:]) | 371 | copy(w.Columns[at+1:], w.Columns[at:]) | ||
| @@ -366,6 +425,7 @@ func (w *Workspace) unslot(n int) { | |||||
| 366 | } | 425 | } | ||
| 367 | 426 | ||||
| 368 | func (w *Workspace) RemoveWindow(id WindowID) { | 427 | func (w *Workspace) RemoveWindow(id WindowID) { | ||
| 428 | + | w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id }) | |||
| 369 | if w.fullscreen == id { | 429 | if w.fullscreen == id { | ||
| 370 | w.fullscreen = 0 | 430 | w.fullscreen = 0 | ||
| 371 | } | 431 | } | ||
| @@ -374,7 +434,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 374 | } | 434 | } | ||
| 375 | if i := w.floatIndex(id); i >= 0 { | 435 | if i := w.floatIndex(id); i >= 0 { | ||
| 376 | w.Floats = slices.Delete(w.Floats, i, i+1) | 436 | w.Floats = slices.Delete(w.Floats, i, i+1) | ||
| 377 | - | if len(w.Floats) == 0 { | 437 | + | if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below { |
| 378 | w.floatFocus = false | 438 | w.floatFocus = false | ||
| 379 | } | 439 | } | ||
| 380 | return | 440 | return | ||
| @@ -425,6 +485,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 425 | func (w *Workspace) FocusID(id WindowID) bool { | 485 | func (w *Workspace) FocusID(id WindowID) bool { | ||
| 426 | if i := w.floatIndex(id); i >= 0 { | 486 | if i := w.floatIndex(id); i >= 0 { | ||
| 427 | f := w.Floats[i] | 487 | f := w.Floats[i] | ||
| 488 | + | f.below = false | |||
| 428 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | 489 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | ||
| 429 | w.floatFocus = true | 490 | w.floatFocus = true | ||
| 430 | return true | 491 | return true | ||
| @@ -440,8 +501,9 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||||
| 440 | for j, v := range w.Columns[i].Windows { | 501 | for j, v := range w.Columns[i].Windows { | ||
| 441 | if v == id { | 502 | if v == id { | ||
| 442 | w.floatFocus, w.stashFocus = false, false | 503 | w.floatFocus, w.stashFocus = false, false | ||
| 443 | - | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | 504 | + | w.raiseColumns() |
| 444 | - | w.Columns[w.Focus].FullWidth = false | 505 | + | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) && !w.Columns[i].FullWidth { |
| 506 | + | w.unmaximize() | |||
| 445 | } | 507 | } | ||
| 446 | w.Focus = i | 508 | w.Focus = i | ||
| 447 | w.Columns[i].Focus = j | 509 | w.Columns[i].Focus = j | ||
| @@ -457,8 +519,9 @@ func (w *Workspace) FocusColumn(dir int) { | |||||
| 457 | return | 519 | return | ||
| 458 | } | 520 | } | ||
| 459 | if w.floatFocus { | 521 | if w.floatFocus { | ||
| 460 | - | // The first move leaves the native float for the stash or columns. | 522 | + | // The first move leaves the native float for the stash or columns, |
| 461 | - | w.floatFocus = false | 523 | + | // if there is one: otherwise the float keeps the focus. |
| 524 | + | w.leaveFloat() | |||
| 462 | return | 525 | return | ||
| 463 | } | 526 | } | ||
| 464 | if w.stashFocused() { | 527 | if w.stashFocused() { | ||
| @@ -469,8 +532,9 @@ func (w *Workspace) FocusColumn(dir int) { | |||||
| 469 | return | 532 | return | ||
| 470 | } | 533 | } | ||
| 471 | if i := w.columnToward(dir); i >= 0 { | 534 | if i := w.columnToward(dir); i >= 0 { | ||
| 535 | + | w.raiseColumns() | |||
| 472 | if w.Overflow == OverflowFixed { | 536 | if w.Overflow == OverflowFixed { | ||
| 473 | - | w.Columns[w.Focus].FullWidth = false | 537 | + | w.unmaximize() |
| 474 | } | 538 | } | ||
| 475 | w.Focus = i | 539 | w.Focus = i | ||
| 476 | w.scroll() | 540 | w.scroll() | ||
| @@ -500,16 +564,15 @@ func (w *Workspace) onScreenFocus() bool { | |||||
| 500 | return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth | 564 | return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth | ||
| 501 | } | 565 | } | ||
| 502 | 566 | ||||
| 503 | - | // FocusWindow moves focus inside the column; false means it was already at the edge. | 567 | + | // FocusWindow moves focus inside the column, then to a fixed-overflow column |
| 504 | - | // Fixed overflow also stacks columns (spiral, expanded strips): past the | 568 | + | // above or below on screen. Up at the top raises the topmost demoted covering |
| 505 | - | // column edge, focus goes to the column on screen above or below. | 569 | + | // float; false means no window is available in that direction. |
| 506 | func (w *Workspace) FocusWindow(dir int) bool { | 570 | func (w *Workspace) FocusWindow(dir int) bool { | ||
| 507 | if w.pinned() { | 571 | if w.pinned() { | ||
| 508 | return false | 572 | return false | ||
| 509 | } | 573 | } | ||
| 510 | if w.floatFocus { | 574 | if w.floatFocus { | ||
| 511 | - | w.floatFocus = false | 575 | + | return w.leaveFloat() |
| 512 | - | return true | |||
| 513 | } | 576 | } | ||
| 514 | if w.stashFocused() { | 577 | if w.stashFocused() { | ||
| 515 | // The stash is one row: up and down do nothing there. | 578 | // The stash is one row: up and down do nothing there. | ||
| @@ -521,19 +584,27 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||||
| 521 | c := &w.Columns[w.Focus] | 584 | c := &w.Columns[w.Focus] | ||
| 522 | if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) { | 585 | if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) { | ||
| 523 | c.Focus += dir | 586 | c.Focus += dir | ||
| 587 | + | w.raiseColumns() | |||
| 524 | w.scroll() | 588 | w.scroll() | ||
| 525 | return true | 589 | return true | ||
| 526 | } | 590 | } | ||
| 527 | - | if !w.onScreenFocus() { | 591 | + | if w.onScreenFocus() { |
| 528 | - | return false | 592 | + | if i := w.screenNeighbor(0, dir); i >= 0 { |
| 593 | + | w.Focus = i | |||
| 594 | + | w.raiseColumns() | |||
| 595 | + | w.Columns[i].Focus = 0 | |||
| 596 | + | if dir < 0 { | |||
| 597 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |||
| 598 | + | } | |||
| 599 | + | return true | |||
| 600 | + | } | |||
| 529 | } | 601 | } | ||
| 530 | - | if i := w.screenNeighbor(0, dir); i >= 0 { | 602 | + | if dir < 0 { |
| 531 | - | w.Focus = i | 603 | + | for i := len(w.Floats) - 1; i >= 0; i-- { |
| 532 | - | w.Columns[i].Focus = 0 | 604 | + | if f := w.Floats[i]; f.below && w.coversFloat(f) { |
| 533 | - | if dir < 0 { | 605 | + | return w.FocusID(f.ID) |
| 534 | - | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | 606 | + | } |
| 535 | } | 607 | } | ||
| 536 | - | return true | |||
| 537 | } | 608 | } | ||
| 538 | return false | 609 | return false | ||
| 539 | } | 610 | } | ||
| @@ -606,7 +677,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 606 | func (w *Workspace) insertColumn(at int, col Column) { | 677 | func (w *Workspace) insertColumn(at int, col Column) { | ||
| 607 | at = min(max(at, 0), len(w.Columns)) | 678 | at = min(max(at, 0), len(w.Columns)) | ||
| 608 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | 679 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 { | ||
| 609 | - | w.Columns[w.Focus].FullWidth = false | 680 | + | w.unmaximize() |
| 610 | } | 681 | } | ||
| 611 | w.Columns = slices.Insert(w.Columns, at, col) | 682 | w.Columns = slices.Insert(w.Columns, at, col) | ||
| 612 | w.Focus = at | 683 | w.Focus = at | ||
| @@ -636,7 +707,7 @@ func (w *Workspace) CycleWidth() { | |||||
| 636 | } | 707 | } | ||
| 637 | } | 708 | } | ||
| 638 | c.Width = next | 709 | c.Width = next | ||
| 639 | - | c.FullWidth = false | 710 | + | w.unmaximize() |
| 640 | w.scroll() | 711 | w.scroll() | ||
| 641 | } | 712 | } | ||
| 642 | 713 | ||||
| @@ -651,7 +722,39 @@ func (w *Workspace) toggleExpanded() { | |||||
| 651 | w.Columns[i].Expanded = false | 722 | w.Columns[i].Expanded = false | ||
| 652 | } | 723 | } | ||
| 653 | w.Columns[w.Focus].Expanded = on | 724 | w.Columns[w.Focus].Expanded = on | ||
| 725 | + | w.unmaximize() | |||
| 726 | + | } | |||
| 727 | + | ||||
| 728 | + | // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles. | |||
| 729 | + | func (w *Workspace) maximize(i int) { | |||
| 730 | + | if i >= 0 && i < len(w.Columns) { | |||
| 731 | + | w.Columns[i].FullWidth = true | |||
| 732 | + | id := w.Columns[i].Windows[w.Columns[i].Focus] | |||
| 733 | + | w.maximized = slices.DeleteFunc(w.maximized, func(v WindowID) bool { return v == id }) | |||
| 734 | + | w.maximized = slices.Insert(w.maximized, 0, id) | |||
| 735 | + | } | |||
| 736 | + | } | |||
| 737 | + | ||||
| 738 | + | // unmaximize clears the focused column in scroll mode, or all maximized | |||
| 739 | + | // columns in fixed mode (including after Escape changed focus). | |||
| 740 | + | func (w *Workspace) unmaximize() { | |||
| 741 | + | for i := range w.Columns { | |||
| 742 | + | if w.Overflow == OverflowFixed || i == w.Focus { | |||
| 743 | + | w.Columns[i].FullWidth = false | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | if w.Overflow == OverflowFixed { | |||
| 747 | + | w.maximized = nil | |||
| 748 | + | } | |||
| 749 | + | } | |||
| 750 | + | ||||
| 751 | + | // transferMaximization changes the maximized column without forgetting MRU. | |||
| 752 | + | func (w *Workspace) transferMaximization(i int) { | |||
| 753 | + | if i == w.Focus || i < 0 || i >= len(w.Columns) { | |||
| 754 | + | return | |||
| 755 | + | } | |||
| 654 | w.Columns[w.Focus].FullWidth = false | 756 | w.Columns[w.Focus].FullWidth = false | ||
| 757 | + | w.maximize(i) | |||
| 655 | } | 758 | } | ||
| 656 | 759 | ||||
| 657 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | 760 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | ||
| @@ -659,8 +762,11 @@ func (w *Workspace) ToggleFullWidth() { | |||||
| 659 | if len(w.Columns) == 0 || w.onFloat() { | 762 | if len(w.Columns) == 0 || w.onFloat() { | ||
| 660 | return | 763 | return | ||
| 661 | } | 764 | } | ||
| 662 | - | c := &w.Columns[w.Focus] | 765 | + | if w.Columns[w.Focus].FullWidth { |
| 663 | - | c.FullWidth = !c.FullWidth | 766 | + | w.unmaximize() |
| 767 | + | } else { | |||
| 768 | + | w.maximize(w.Focus) | |||
| 769 | + | } | |||
| 664 | w.scroll() | 770 | w.scroll() | ||
| 665 | } | 771 | } | ||
| 666 | 772 | ||||
| @@ -710,6 +816,7 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||||
| 710 | w.fullscreen = id | 816 | w.fullscreen = id | ||
| 711 | } else if w.fullscreen == id { | 817 | } else if w.fullscreen == id { | ||
| 712 | w.fullscreen = 0 | 818 | w.fullscreen = 0 | ||
| 819 | + | w.reconcileFloats() | |||
| 713 | } | 820 | } | ||
| 714 | return | 821 | return | ||
| 715 | } | 822 | } | ||
| @@ -750,6 +857,7 @@ func (w *Workspace) SetUsable(r Rect) { | |||||
| 750 | r.W = min(max(r.W, 0), w.Output.W-r.X) | 857 | r.W = min(max(r.W, 0), w.Output.W-r.X) | ||
| 751 | r.H = min(max(r.H, 0), w.Output.H-r.Y) | 858 | r.H = min(max(r.H, 0), w.Output.H-r.Y) | ||
| 752 | w.Usable = r | 859 | w.Usable = r | ||
| 860 | + | w.reconcileFloats() | |||
| 753 | w.scroll() | 861 | w.scroll() | ||
| 754 | } | 862 | } | ||
| 755 | func (w *Workspace) SetGaps(g int) { | 863 | func (w *Workspace) SetGaps(g int) { | ||
| @@ -823,7 +931,11 @@ func (w *Workspace) fullscreenColumn(i int) bool { | |||||
| 823 | return false | 931 | return false | ||
| 824 | } | 932 | } | ||
| 825 | func (w *Workspace) columnWidth(i int) int { | 933 | func (w *Workspace) columnWidth(i int) int { | ||
| 826 | - | if w.Columns[i].FullWidth { | 934 | + | return w.columnWidthFor(i, false) |
| 935 | + | } | |||
| 936 | + | ||||
| 937 | + | func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int { | |||
| 938 | + | if w.Columns[i].FullWidth && !ignoreFullWidth { | |||
| 827 | return max(w.Usable.W-2*w.gap(), 0) | 939 | return max(w.Usable.W-2*w.gap(), 0) | ||
| 828 | } | 940 | } | ||
| 829 | if w.fullscreenColumn(i) { | 941 | if w.fullscreenColumn(i) { | ||
| @@ -875,14 +987,23 @@ func (w *Workspace) scroll() { | |||||
| 875 | 987 | ||||
| 876 | // columnRects returns each column's area on screen, before stacking windows. | 988 | // columnRects returns each column's area on screen, before stacking windows. | ||
| 877 | func (w *Workspace) columnRects() []Rect { | 989 | func (w *Workspace) columnRects() []Rect { | ||
| 990 | + | return w.columnRectsFor(false) | |||
| 991 | + | } | |||
| 992 | + | ||||
| 993 | + | // columnRectsFor shares the fixed layout geometry with the overview's hidden | |||
| 994 | + | // columns card, without changing FullWidth or the clients' saved buffers. | |||
| 995 | + | func (w *Workspace) columnRectsFor(ignoreFullWidth bool) []Rect { | |||
| 878 | g := w.gap() | 996 | g := w.gap() | ||
| 879 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | 997 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | ||
| 880 | rects := make([]Rect, len(w.Columns)) | 998 | rects := make([]Rect, len(w.Columns)) | ||
| 881 | view := w.ViewX + w.shiftPixels() | 999 | view := w.ViewX + w.shiftPixels() | ||
| 1000 | + | x := w.Usable.X + g | |||
| 882 | for i := range w.Columns { | 1001 | for i := range w.Columns { | ||
| 883 | - | rects[i] = Rect{X: w.columnX(i) - view, Y: y, W: w.columnWidth(i), H: h} | 1002 | + | width := w.columnWidthFor(i, ignoreFullWidth) |
| 1003 | + | rects[i] = Rect{X: x - view, Y: y, W: width, H: h} | |||
| 1004 | + | x += width + g | |||
| 884 | } | 1005 | } | ||
| 885 | - | if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | 1006 | + | if !ignoreFullWidth && w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { |
| 886 | rects[w.Focus].X = w.Usable.X + g | 1007 | rects[w.Focus].X = w.Usable.X + g | ||
| 887 | return rects | 1008 | return rects | ||
| 888 | } | 1009 | } | ||
| @@ -1014,23 +1135,48 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1014 | } | 1135 | } | ||
| 1015 | } | 1136 | } | ||
| 1016 | setVisibleNeighbors(result, gap, w.Output) | 1137 | setVisibleNeighbors(result, gap, w.Output) | ||
| 1017 | - | // Floating windows go last: they are drawn and hit on top, native | 1138 | + | // The column/stash group sits between demoted covering floats and |
| 1018 | - | // floats (dialogs) over the stash. | 1139 | + | // floats above it. Keep the order within each group stable. |
| 1019 | - | result = append(result, w.stashLayout(focusedID, cover)...) | 1140 | + | tiles := result |
| 1020 | - | for _, f := range w.Floats { | 1141 | + | result = nil |
| 1021 | - | // A covering fullscreen window hides every float, its own dialogs | 1142 | + | const ( |
| 1022 | - | // too: they show again when it leaves fullscreen. | 1143 | + | belowTiles = iota |
| 1023 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover} | 1144 | + | coveringFloats |
| 1024 | - | if p.Hidden { | 1145 | + | dialogs |
| 1025 | - | p.Rect = Rect{} | 1146 | + | ) |
| 1147 | + | appendFloats := func(group int) { | |||
| 1148 | + | for _, f := range w.Floats { | |||
| 1149 | + | below := f.below && w.coversFloat(f) | |||
| 1150 | + | // Dialogs stay over covering floats even when the latter is | |||
| 1151 | + | // selected. Fullscreen floats remain in the upper group. | |||
| 1152 | + | order := dialogs | |||
| 1153 | + | if below { | |||
| 1154 | + | order = belowTiles | |||
| 1155 | + | } else if w.coversFloat(f) || w.fullscreen == f.ID { | |||
| 1156 | + | order = coveringFloats | |||
| 1157 | + | } | |||
| 1158 | + | if order != group { | |||
| 1159 | + | continue | |||
| 1160 | + | } | |||
| 1161 | + | // A covering fullscreen window hides every float, its own dialogs | |||
| 1162 | + | // too: they show again when it leaves fullscreen. | |||
| 1163 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover} | |||
| 1164 | + | if p.Hidden { | |||
| 1165 | + | p.Rect = Rect{} | |||
| 1166 | + | result = append(result, p) | |||
| 1167 | + | continue | |||
| 1168 | + | } | |||
| 1169 | + | if w.fullscreen == f.ID { | |||
| 1170 | + | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 | |||
| 1171 | + | } | |||
| 1026 | result = append(result, p) | 1172 | result = append(result, p) | ||
| 1027 | - | continue | |||
| 1028 | } | 1173 | } | ||
| 1029 | - | if w.fullscreen == f.ID { | |||
| 1030 | - | p.Rect, p.Fullscreen, p.Inset = Rect{W: w.Output.W, H: w.Output.H}, true, 0 | |||
| 1031 | - | } | |||
| 1032 | - | result = append(result, p) | |||
| 1033 | } | 1174 | } | ||
| 1175 | + | appendFloats(belowTiles) | |||
| 1176 | + | result = append(result, tiles...) | |||
| 1177 | + | result = append(result, w.stashLayout(focusedID, cover)...) | |||
| 1178 | + | appendFloats(coveringFloats) | |||
| 1179 | + | appendFloats(dialogs) | |||
| 1034 | return result | 1180 | return result | ||
| 1035 | } | 1181 | } | ||
| 1036 | 1182 | ||||
M internal/core/monitor.go +22 -22
| @@ -34,19 +34,8 @@ type Monitor struct { | |||
| 34 | 34 | // one its swipe began on); switchOff is then from the current | |
| 35 | 35 | // workspace's place in it. | |
| 36 | 36 | switchList []*Workspace | |
| 37 | - | // overview is set while the overview is open (overview.go); | |
| 38 | - | // overviewFrom and overviewFromID are where it opened, for Escape. | |
| 39 | - | overview bool | |
| 40 | - | overviewFrom *Workspace | |
| 41 | - | overviewFromID WindowID | |
| 42 | - | // overviewCard is the selected stash card and overviewCardOf its | |
| 43 | - | // workspace; 0 while the selection is on a column. The stash itself | |
| 44 | - | // changes only when the overview closes on the card. | |
| 45 | - | overviewCard WindowID | |
| 46 | - | overviewCardOf *Workspace | |
| 47 | - | // scrollX and scrollY add up scrolling in the overview until it makes | |
| 48 | - | // a step (overviewScroll); opening the overview starts from zero. | |
| 49 | - | scrollX, scrollY float64 | |
| 37 | + | // ov holds the overview selection and its Escape snapshot. | |
| 38 | + | ov overviewState | |
| 50 | 39 | // overviewOpens counts the overview's openings: a swipe sliding when | |
| 51 | 40 | // one happens is dropped (gesture.go). | |
| 52 | 41 | overviewOpens int | |
| @@ -149,7 +138,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 149 | 138 | func (m *Monitor) newWorkspace() *Workspace { | |
| 150 | 139 | w := m.template | |
| 151 | 140 | w.presets = append([]Width(nil), m.template.presets...) | |
| 152 | - | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | |
| 141 | + | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} | |
| 153 | 142 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | |
| 154 | 143 | (*m.nextID)++ | |
| 155 | 144 | w.ID = *m.nextID | |
| @@ -377,7 +366,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||
| 377 | 366 | fs.back.id = id | |
| 378 | 367 | if f := w.floatIndex(id); f >= 0 { | |
| 379 | 368 | fl := w.Floats[f] | |
| 380 | - | fs.back.float = &fl | |
| 369 | + | fs.back.float, fs.back.floatAt = &fl, f | |
| 381 | 370 | w.RemoveWindow(id) | |
| 382 | 371 | fs.AddFloating(id, fl.W, fl.H) | |
| 383 | 372 | fs.Floats[len(fs.Floats)-1] = fl | |
| @@ -439,12 +428,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 439 | 428 | origin.stashAt++ | |
| 440 | 429 | } | |
| 441 | 430 | back.col = len(origin.Columns) | |
| 442 | - | case back.float != nil && focus: | |
| 443 | - | origin.AddFloating(id, back.float.W, back.float.H) | |
| 444 | - | origin.Floats[len(origin.Floats)-1] = *back.float | |
| 445 | 431 | case back.float != nil: | |
| 446 | - | // At the bottom: the top float, maybe focused, stays on top. | |
| 447 | - | origin.Floats = slices.Insert(origin.Floats, 0, *back.float) | |
| 432 | + | // Restore the original position before an optional focus-driven raise. | |
| 433 | + | at := min(back.floatAt, len(origin.Floats)) | |
| 434 | + | if !focus && origin.floatFocus && len(origin.Floats) > 0 { | |
| 435 | + | at = min(at, len(origin.Floats)-1) | |
| 436 | + | } | |
| 437 | + | origin.Floats = slices.Insert(origin.Floats, at, *back.float) | |
| 438 | + | origin.reconcileFloats() | |
| 439 | + | if focus { | |
| 440 | + | origin.FocusID(id) | |
| 441 | + | } | |
| 448 | 442 | case slices.ContainsFunc(origin.Columns, back.holdsStack): | |
| 449 | 443 | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | |
| 450 | 444 | c := &origin.Columns[back.col] | |
| @@ -459,6 +453,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 459 | 453 | c.Windows = slices.Insert(c.Windows, row, id) | |
| 460 | 454 | if focus { | |
| 461 | 455 | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false | |
| 456 | + | origin.raiseColumns() | |
| 462 | 457 | } else if row <= c.Focus && len(c.Windows) > 1 { | |
| 463 | 458 | c.Focus++ | |
| 464 | 459 | } | |
| @@ -470,6 +465,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 470 | 465 | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |
| 471 | 466 | if focus { | |
| 472 | 467 | origin.floatFocus, origin.stashFocus = false, false | |
| 468 | + | origin.raiseColumns() | |
| 473 | 469 | } else if len(origin.Columns) > 1 { | |
| 474 | 470 | origin.Focus = prev | |
| 475 | 471 | if at <= prev { | |
| @@ -510,6 +506,7 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi | |||
| 510 | 506 | // The user is elsewhere: below, the focused float keeps the focus. | |
| 511 | 507 | origin.Floats = append(floats, origin.Floats...) | |
| 512 | 508 | } | |
| 509 | + | origin.reconcileFloats() | |
| 513 | 510 | // Windows stashed there join the end of the stash. | |
| 514 | 511 | for _, f := range fs.Stash { | |
| 515 | 512 | origin.Stash = append(origin.Stash, f.rehome()) | |
| @@ -661,7 +658,7 @@ func (m *Monitor) Focused() (WindowID, bool) { | |||
| 661 | 658 | ||
| 662 | 659 | // Layout places every window: the workspace on screen laid out, others hidden. | |
| 663 | 660 | func (m *Monitor) Layout() []Placement { | |
| 664 | - | if m.overview { | |
| 661 | + | if m.ov.open { | |
| 665 | 662 | return m.overviewLayout() | |
| 666 | 663 | } | |
| 667 | 664 | var result []Placement | |
| @@ -694,7 +691,9 @@ func (m *Monitor) SetOutput(width, height int) { | |||
| 694 | 691 | } | |
| 695 | 692 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 696 | 693 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 697 | - | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |
| 694 | + | func (m *Monitor) SetBorder(b int) { | |
| 695 | + | m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() }) | |
| 696 | + | } | |
| 698 | 697 | ||
| 699 | 698 | // SetStash sets the width of a stashed window (stash.width; 0 is the | |
| 700 | 699 | // default) and the space between it and its neighbors (stash.gap), in | |
| @@ -777,6 +776,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 777 | 776 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | |
| 778 | 777 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | |
| 779 | 778 | } | |
| 779 | + | m.Workspaces[m.Active].reconcileFloats() | |
| 780 | 780 | for _, f := range w.Stash { | |
| 781 | 781 | m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome()) | |
| 782 | 782 | } | |
| @@ -34,19 +34,8 @@ type Monitor struct { | |||||
| 34 | // one its swipe began on); switchOff is then from the current | 34 | // one its swipe began on); switchOff is then from the current | ||
| 35 | // workspace's place in it. | 35 | // workspace's place in it. | ||
| 36 | switchList []*Workspace | 36 | switchList []*Workspace | ||
| 37 | - | // overview is set while the overview is open (overview.go); | 37 | + | // ov holds the overview selection and its Escape snapshot. |
| 38 | - | // overviewFrom and overviewFromID are where it opened, for Escape. | 38 | + | ov overviewState |
| 39 | - | overview bool | |||
| 40 | - | overviewFrom *Workspace | |||
| 41 | - | overviewFromID WindowID | |||
| 42 | - | // overviewCard is the selected stash card and overviewCardOf its | |||
| 43 | - | // workspace; 0 while the selection is on a column. The stash itself | |||
| 44 | - | // changes only when the overview closes on the card. | |||
| 45 | - | overviewCard WindowID | |||
| 46 | - | overviewCardOf *Workspace | |||
| 47 | - | // scrollX and scrollY add up scrolling in the overview until it makes | |||
| 48 | - | // a step (overviewScroll); opening the overview starts from zero. | |||
| 49 | - | scrollX, scrollY float64 | |||
| 50 | // overviewOpens counts the overview's openings: a swipe sliding when | 39 | // overviewOpens counts the overview's openings: a swipe sliding when | ||
| 51 | // one happens is dropped (gesture.go). | 40 | // one happens is dropped (gesture.go). | ||
| 52 | overviewOpens int | 41 | overviewOpens int | ||
| @@ -149,7 +138,7 @@ func (m *Monitor) Current() *Workspace { | |||||
| 149 | func (m *Monitor) newWorkspace() *Workspace { | 138 | func (m *Monitor) newWorkspace() *Workspace { | ||
| 150 | w := m.template | 139 | w := m.template | ||
| 151 | w.presets = append([]Width(nil), m.template.presets...) | 140 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 152 | - | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | 141 | + | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home, w.origin, w.back = nil, nil, nil, false, "", nil, origPlace{} |
| 153 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | 142 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | ||
| 154 | (*m.nextID)++ | 143 | (*m.nextID)++ | ||
| 155 | w.ID = *m.nextID | 144 | w.ID = *m.nextID | ||
| @@ -377,7 +366,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||||
| 377 | fs.back.id = id | 366 | fs.back.id = id | ||
| 378 | if f := w.floatIndex(id); f >= 0 { | 367 | if f := w.floatIndex(id); f >= 0 { | ||
| 379 | fl := w.Floats[f] | 368 | fl := w.Floats[f] | ||
| 380 | - | fs.back.float = &fl | 369 | + | fs.back.float, fs.back.floatAt = &fl, f |
| 381 | w.RemoveWindow(id) | 370 | w.RemoveWindow(id) | ||
| 382 | fs.AddFloating(id, fl.W, fl.H) | 371 | fs.AddFloating(id, fl.W, fl.H) | ||
| 383 | fs.Floats[len(fs.Floats)-1] = fl | 372 | fs.Floats[len(fs.Floats)-1] = fl | ||
| @@ -439,12 +428,17 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 439 | origin.stashAt++ | 428 | origin.stashAt++ | ||
| 440 | } | 429 | } | ||
| 441 | back.col = len(origin.Columns) | 430 | back.col = len(origin.Columns) | ||
| 442 | - | case back.float != nil && focus: | |||
| 443 | - | origin.AddFloating(id, back.float.W, back.float.H) | |||
| 444 | - | origin.Floats[len(origin.Floats)-1] = *back.float | |||
| 445 | case back.float != nil: | 431 | case back.float != nil: | ||
| 446 | - | // At the bottom: the top float, maybe focused, stays on top. | 432 | + | // Restore the original position before an optional focus-driven raise. |
| 447 | - | origin.Floats = slices.Insert(origin.Floats, 0, *back.float) | 433 | + | at := min(back.floatAt, len(origin.Floats)) |
| 434 | + | if !focus && origin.floatFocus && len(origin.Floats) > 0 { | |||
| 435 | + | at = min(at, len(origin.Floats)-1) | |||
| 436 | + | } | |||
| 437 | + | origin.Floats = slices.Insert(origin.Floats, at, *back.float) | |||
| 438 | + | origin.reconcileFloats() | |||
| 439 | + | if focus { | |||
| 440 | + | origin.FocusID(id) | |||
| 441 | + | } | |||
| 448 | case slices.ContainsFunc(origin.Columns, back.holdsStack): | 442 | case slices.ContainsFunc(origin.Columns, back.holdsStack): | ||
| 449 | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | 443 | back.col = slices.IndexFunc(origin.Columns, back.holdsStack) | ||
| 450 | c := &origin.Columns[back.col] | 444 | c := &origin.Columns[back.col] | ||
| @@ -459,6 +453,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 459 | c.Windows = slices.Insert(c.Windows, row, id) | 453 | c.Windows = slices.Insert(c.Windows, row, id) | ||
| 460 | if focus { | 454 | if focus { | ||
| 461 | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false | 455 | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false | ||
| 456 | + | origin.raiseColumns() | |||
| 462 | } else if row <= c.Focus && len(c.Windows) > 1 { | 457 | } else if row <= c.Focus && len(c.Windows) > 1 { | ||
| 463 | c.Focus++ | 458 | c.Focus++ | ||
| 464 | } | 459 | } | ||
| @@ -470,6 +465,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 470 | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | 465 | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | ||
| 471 | if focus { | 466 | if focus { | ||
| 472 | origin.floatFocus, origin.stashFocus = false, false | 467 | origin.floatFocus, origin.stashFocus = false, false | ||
| 468 | + | origin.raiseColumns() | |||
| 473 | } else if len(origin.Columns) > 1 { | 469 | } else if len(origin.Columns) > 1 { | ||
| 474 | origin.Focus = prev | 470 | origin.Focus = prev | ||
| 475 | if at <= prev { | 471 | if at <= prev { | ||
| @@ -510,6 +506,7 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi | |||||
| 510 | // The user is elsewhere: below, the focused float keeps the focus. | 506 | // The user is elsewhere: below, the focused float keeps the focus. | ||
| 511 | origin.Floats = append(floats, origin.Floats...) | 507 | origin.Floats = append(floats, origin.Floats...) | ||
| 512 | } | 508 | } | ||
| 509 | + | origin.reconcileFloats() | |||
| 513 | // Windows stashed there join the end of the stash. | 510 | // Windows stashed there join the end of the stash. | ||
| 514 | for _, f := range fs.Stash { | 511 | for _, f := range fs.Stash { | ||
| 515 | origin.Stash = append(origin.Stash, f.rehome()) | 512 | origin.Stash = append(origin.Stash, f.rehome()) | ||
| @@ -661,7 +658,7 @@ func (m *Monitor) Focused() (WindowID, bool) { | |||||
| 661 | 658 | ||||
| 662 | // Layout places every window: the workspace on screen laid out, others hidden. | 659 | // Layout places every window: the workspace on screen laid out, others hidden. | ||
| 663 | func (m *Monitor) Layout() []Placement { | 660 | func (m *Monitor) Layout() []Placement { | ||
| 664 | - | if m.overview { | 661 | + | if m.ov.open { |
| 665 | return m.overviewLayout() | 662 | return m.overviewLayout() | ||
| 666 | } | 663 | } | ||
| 667 | var result []Placement | 664 | var result []Placement | ||
| @@ -694,7 +691,9 @@ func (m *Monitor) SetOutput(width, height int) { | |||||
| 694 | } | 691 | } | ||
| 695 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | 692 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | ||
| 696 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | 693 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | ||
| 697 | - | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | 694 | + | func (m *Monitor) SetBorder(b int) { |
| 695 | + | m.each(func(w *Workspace) { w.border = max(b, 0); w.reconcileFloats() }) | |||
| 696 | + | } | |||
| 698 | 697 | ||||
| 699 | // SetStash sets the width of a stashed window (stash.width; 0 is the | 698 | // SetStash sets the width of a stashed window (stash.width; 0 is the | ||
| 700 | // default) and the space between it and its neighbors (stash.gap), in | 699 | // default) and the space between it and its neighbors (stash.gap), in | ||
| @@ -777,6 +776,7 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||||
| 777 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | 776 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | ||
| 778 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | 777 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | ||
| 779 | } | 778 | } | ||
| 779 | + | m.Workspaces[m.Active].reconcileFloats() | |||
| 780 | for _, f := range w.Stash { | 780 | for _, f := range w.Stash { | ||
| 781 | m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome()) | 781 | m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome()) | ||
| 782 | } | 782 | } | ||
M internal/core/monitor_test.go +1 -1
| @@ -440,7 +440,7 @@ func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) { | |||
| 440 | 440 | } | |
| 441 | 441 | w := m.Current() | |
| 442 | 442 | w.FocusID(2) | |
| 443 | - | w.stack(2, 1) // column {1, 2}, then {3}, {4} | |
| 443 | + | w.stackWindow(2, 1) // column {1, 2}, then {3}, {4} | |
| 444 | 444 | w.FocusID(2) | |
| 445 | 445 | m.Apply(ActionCycleColumnWidth) | |
| 446 | 446 | m.ToggleFullscreen() | |
| @@ -440,7 +440,7 @@ func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) { | |||||
| 440 | } | 440 | } | ||
| 441 | w := m.Current() | 441 | w := m.Current() | ||
| 442 | w.FocusID(2) | 442 | w.FocusID(2) | ||
| 443 | - | w.stack(2, 1) // column {1, 2}, then {3}, {4} | 443 | + | w.stackWindow(2, 1) // column {1, 2}, then {3}, {4} |
| 444 | w.FocusID(2) | 444 | w.FocusID(2) | ||
| 445 | m.Apply(ActionCycleColumnWidth) | 445 | m.Apply(ActionCycleColumnWidth) | ||
| 446 | m.ToggleFullscreen() | 446 | m.ToggleFullscreen() | ||
M internal/core/overview.go +419 -97
| @@ -3,18 +3,35 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | - | // The overview shows every column of the workspace on screen in one row, | |
| 11 | - | // each tile scaled by the same factor so windows keep their shape, with | |
| 12 | - | // the workspaces above and below it dimmed. A workspace pinned under a | |
| 13 | - | // covering fullscreen window (a game) shows only that window, as on | |
| 14 | - | // screen. The previews | |
| 15 | - | // are the windows' last buffers: clients are not resized. h/l (or left/right) select a | |
| 16 | - | // column, j/k (or down/up) a workspace, Return keeps the selection and | |
| 17 | - | // Escape returns to where the overview opened. | |
| 11 | + | // overviewState owns the provisional selection and opening snapshot. row is | |
| 12 | + | // the workspace whose stack has a provisional front; nil uses its real front. | |
| 13 | + | type overviewState struct { | |
| 14 | + | open bool | |
| 15 | + | from *Workspace | |
| 16 | + | fromID WindowID | |
| 17 | + | floats []Float | |
| 18 | + | fullWidth WindowID | |
| 19 | + | row *Workspace | |
| 20 | + | front stackItem | |
| 21 | + | frontAt int | |
| 22 | + | cardOf *Workspace | |
| 23 | + | card WindowID | |
| 24 | + | selected WindowID | |
| 25 | + | selectedAt int | |
| 26 | + | // FullWidth history is restored on Escape when active binds changed it. | |
| 27 | + | maximized []WindowID | |
| 28 | + | scrollX, scrollY float64 | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // The overview scales workspace rows without resizing clients. Covering | |
| 32 | + | // floats and the columns form a linear stack of cards; small floats stay | |
| 33 | + | // hidden. Up/down traverse the stack before changing workspace. A pinned | |
| 34 | + | // fullscreen window remains the only preview on its row. | |
| 18 | 35 | ||
| 19 | 36 | // A workspace's stash shows as a pile of cards pinned to the left edge of | |
| 20 | 37 | // its row: the selected stashed window in front at overviewCardZoom, up to | |
| @@ -24,6 +41,7 @@ const ( | |||
| 24 | 41 | overviewCardZoom = 0.2 | |
| 25 | 42 | overviewCards = 4 | |
| 26 | 43 | overviewCardStep = 8 | |
| 44 | + | overviewPeekStep = 0.07 | |
| 27 | 45 | ) | |
| 28 | 46 | ||
| 29 | 47 | // overviewMaxZoom caps the current row so its neighbors show above and | |
| @@ -35,7 +53,7 @@ const overviewMaxZoom, overviewMinZoom = 0.6, 0.25 | |||
| 35 | 53 | // a covering fullscreen window (a game) too: its row shows it, composed | |
| 36 | 54 | // rather than scanned out while the overview is open. | |
| 37 | 55 | func (m *Monitor) ToggleOverview() { | |
| 38 | - | if m.overview { | |
| 56 | + | if m.ov.open { | |
| 39 | 57 | m.closeOverview() | |
| 40 | 58 | return | |
| 41 | 59 | } | |
| @@ -44,55 +62,118 @@ func (m *Monitor) ToggleOverview() { | |||
| 44 | 62 | // settled state. | |
| 45 | 63 | m.stopSwitch() | |
| 46 | 64 | m.each(func(w *Workspace) { w.stopSlide() }) | |
| 47 | - | m.overview, m.overviewFrom = true, w | |
| 48 | - | m.overviewFromID, _ = w.Focused() | |
| 49 | - | m.scrollX, m.scrollY = 0, 0 | |
| 65 | + | m.ov.open, m.ov.from = true, w | |
| 66 | + | m.ov.fromID, _ = w.Focused() | |
| 67 | + | m.ov.floats = append([]Float(nil), w.Floats...) | |
| 68 | + | m.ov.maximized = slices.Clone(w.maximized) | |
| 69 | + | m.ov.fullWidth = 0 | |
| 70 | + | if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |
| 71 | + | c := w.Columns[w.Focus] | |
| 72 | + | m.ov.fullWidth = c.Windows[c.Focus] | |
| 73 | + | } | |
| 74 | + | m.ov.scrollX, m.ov.scrollY = 0, 0 | |
| 50 | 75 | m.overviewOpens++ | |
| 51 | 76 | m.selectRow() | |
| 52 | 77 | } | |
| 53 | 78 | ||
| 54 | - | // selectRow selects the stash card of a workspace whose stash has the | |
| 55 | - | // focus (or only stashed windows), else its focused column. The overview | |
| 56 | - | // hides native floats, so a float's focus moves to its column; Escape | |
| 57 | - | // gives it back. A pinning fullscreen window is the exception: it is the | |
| 58 | - | // row. | |
| 79 | + | // selectRow resets provisional stack and stash selection for a new row. | |
| 59 | 80 | func (m *Monitor) selectRow() { | |
| 60 | - | m.overviewCard, m.overviewCardOf = 0, nil | |
| 81 | + | m.ov.card, m.ov.cardOf = 0, nil | |
| 82 | + | m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0 | |
| 83 | + | m.ov.selected, m.ov.selectedAt = 0, -1 | |
| 61 | 84 | w := m.Current() | |
| 62 | 85 | if w.pinned() { | |
| 63 | - | // The covering window is the row's only preview: it keeps the focus. | |
| 64 | 86 | return | |
| 65 | 87 | } | |
| 66 | - | w.floatFocus = false | |
| 67 | - | if len(w.Stash) > 0 && (len(w.Columns) == 0 || w.stashFocused()) { | |
| 88 | + | if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) { | |
| 89 | + | w.floatFocus = false | |
| 90 | + | } | |
| 91 | + | if len(w.Stash) > 0 && (len(w.stack()) == 0 || w.stashFocused() && !w.floatFocus) { | |
| 68 | 92 | m.selectCard(w, w.stashAt) | |
| 69 | 93 | } | |
| 70 | 94 | } | |
| 71 | 95 | ||
| 72 | 96 | // selectCard selects stash entry i of w. | |
| 73 | 97 | func (m *Monitor) selectCard(w *Workspace, i int) { | |
| 74 | - | m.overviewCard, m.overviewCardOf = w.Stash[i].ID, w | |
| 98 | + | m.ov.card, m.ov.cardOf = w.Stash[i].ID, w | |
| 75 | 99 | } | |
| 76 | 100 | ||
| 77 | 101 | // card is the selected stash card, 0 when there is none: the overview is | |
| 78 | 102 | // closed, the selection is on a column, or its window left the stash on | |
| 79 | 103 | // screen (unmapped, unstashed, or another workspace shown by a bind). | |
| 80 | 104 | func (m *Monitor) card() WindowID { | |
| 81 | - | w := m.overviewCardOf | |
| 82 | - | if !m.overview || w == nil || w != m.Current() || w.stashIndex(m.overviewCard) < 0 { | |
| 105 | + | w := m.ov.cardOf | |
| 106 | + | if !m.ov.open || w == nil || w != m.Current() || w.stashIndex(m.ov.card) < 0 { | |
| 83 | 107 | return 0 | |
| 84 | 108 | } | |
| 85 | - | return m.overviewCard | |
| 109 | + | return m.ov.card | |
| 86 | 110 | } | |
| 87 | 111 | ||
| 88 | 112 | // cardAt is the pile entry of w that is selected, -1 when none. | |
| 89 | 113 | func (m *Monitor) cardAt(w *Workspace) int { | |
| 90 | - | if id := m.card(); id != 0 && w == m.overviewCardOf { | |
| 114 | + | if id := m.card(); id != 0 && w == m.ov.cardOf { | |
| 91 | 115 | return w.stashIndex(id) | |
| 92 | 116 | } | |
| 93 | 117 | return -1 | |
| 94 | 118 | } | |
| 95 | 119 | ||
| 120 | + | // overviewTarget resolves the active preview to one window for validation | |
| 121 | + | // and window actions. Presentation state never falls back to a covering float. | |
| 122 | + | func (m *Monitor) overviewTarget() WindowID { | |
| 123 | + | if id := m.card(); id != 0 { | |
| 124 | + | return id | |
| 125 | + | } | |
| 126 | + | w := m.Current() | |
| 127 | + | if w.pinned() { | |
| 128 | + | return w.cover() | |
| 129 | + | } | |
| 130 | + | item := m.stackFront(w) | |
| 131 | + | switch item.kind { | |
| 132 | + | case stackFloat: | |
| 133 | + | return item.id | |
| 134 | + | case stackColumn: | |
| 135 | + | i := w.columnOf(item.id) | |
| 136 | + | if i >= 0 { | |
| 137 | + | c := w.Columns[i] | |
| 138 | + | return c.Windows[c.Focus] | |
| 139 | + | } | |
| 140 | + | case stackColumns: | |
| 141 | + | i := w.Focus | |
| 142 | + | if len(w.Columns) == 0 { | |
| 143 | + | return 0 | |
| 144 | + | } | |
| 145 | + | if w.overviewMaximized() { | |
| 146 | + | i = w.columnOf(m.ov.selected) | |
| 147 | + | if i < 0 || i == w.Focus { | |
| 148 | + | i = m.hiddenColumn(w) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | if i >= 0 { | |
| 152 | + | c := w.Columns[i] | |
| 153 | + | return c.Windows[c.Focus] | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | return 0 | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // overviewBlocks makes window mutations explicit: they are disabled while | |
| 160 | + | // selection is provisional. Workspace and monitor navigation remain active. | |
| 161 | + | func overviewBlocks(a Action) bool { | |
| 162 | + | if _, op, ok := WorkspaceArg(a); ok { | |
| 163 | + | return op != FocusWorkspace | |
| 164 | + | } | |
| 165 | + | switch a { | |
| 166 | + | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |
| 167 | + | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 168 | + | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | |
| 169 | + | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |
| 170 | + | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |
| 171 | + | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |
| 172 | + | return true | |
| 173 | + | } | |
| 174 | + | return false | |
| 175 | + | } | |
| 176 | + | ||
| 96 | 177 | // closeOverview closes the overview on the selection: a card shows the | |
| 97 | 178 | // stash on it. A fullscreen request another stashed window made while | |
| 98 | 179 | // the stash was hidden is dropped: the picked card stays in front. | |
| @@ -104,32 +185,66 @@ func (m *Monitor) closeOverview() { | |||
| 104 | 185 | } | |
| 105 | 186 | w.stashAt = i | |
| 106 | 187 | w.showStash() | |
| 188 | + | } else if !w.pinned() && len(w.stack()) > 0 { | |
| 189 | + | w.apply(m.stackFront(w), m.overviewTarget()) | |
| 107 | 190 | } | |
| 108 | - | m.overview, m.overviewFrom = false, nil | |
| 109 | - | m.overviewCard, m.overviewCardOf = 0, nil | |
| 191 | + | m.ov = overviewState{} | |
| 110 | 192 | } | |
| 111 | 193 | ||
| 112 | 194 | // CancelOverview closes the overview and returns to the workspace and | |
| 113 | 195 | // window it opened on, if they are still there. | |
| 114 | 196 | func (m *Monitor) CancelOverview() { | |
| 115 | - | from, id := m.overviewFrom, m.overviewFromID | |
| 116 | - | m.overview, m.overviewFrom = false, nil | |
| 117 | - | m.overviewCard, m.overviewCardOf = 0, nil | |
| 118 | - | if from == nil || !m.has(from) { | |
| 119 | - | return | |
| 120 | - | } | |
| 121 | - | m.show(from) | |
| 122 | - | if id != 0 { | |
| 123 | - | from.FocusID(id) | |
| 197 | + | from, id := m.ov.from, m.ov.fromID | |
| 198 | + | if from != nil && m.has(from) { | |
| 199 | + | m.show(from) | |
| 200 | + | if id != 0 { | |
| 201 | + | from.FocusID(id) | |
| 202 | + | } else { | |
| 203 | + | from.floatFocus, from.stashFocus = false, false | |
| 204 | + | } | |
| 205 | + | // FocusID on a float raises it; restore the initial order and below | |
| 206 | + | // flags for floats still present, leaving new floats in their order. | |
| 207 | + | initial := m.ov.floats | |
| 208 | + | current := from.Floats | |
| 209 | + | from.Floats = nil | |
| 210 | + | for _, f := range initial { | |
| 211 | + | for i, now := range current { | |
| 212 | + | if now.ID == f.ID { | |
| 213 | + | now.below = f.below | |
| 214 | + | from.Floats = append(from.Floats, now) | |
| 215 | + | current = append(current[:i], current[i+1:]...) | |
| 216 | + | break | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | from.Floats = append(from.Floats, current...) | |
| 221 | + | // Fixed overflow maximizes at most one column: only the one holding | |
| 222 | + | // the anchor window, wherever active binds moved it. Scroll overflow | |
| 223 | + | // keeps each column's own width. | |
| 224 | + | if from.Overflow == OverflowFixed { | |
| 225 | + | from.unmaximize() | |
| 226 | + | for i := range from.Columns { | |
| 227 | + | if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) { | |
| 228 | + | from.maximize(i) | |
| 229 | + | break | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | from.maximized = slices.Clone(m.ov.maximized) | |
| 234 | + | from.scroll() | |
| 124 | 235 | } | |
| 236 | + | m.ov = overviewState{} | |
| 125 | 237 | } | |
| 126 | 238 | ||
| 127 | - | // OverviewMove selects the neighbor column (dx) or numbered workspace | |
| 128 | - | // (dy). It stops at the ends and never enters an empty workspace, which | |
| 129 | - | // would open a terminal there. Left of the first column is the stash | |
| 130 | - | // pile; right of its last entry, the first column again. | |
| 239 | + | // OverviewMove routes every vertical input through moveStack, then falls | |
| 240 | + | // through to workspace navigation at the stack boundary. Horizontal moves | |
| 241 | + | // stay inside a column group; left on a single card sends it behind. | |
| 131 | 242 | func (m *Monitor) OverviewMove(dx, dy int) { | |
| 243 | + | w := m.Current() | |
| 132 | 244 | if dy != 0 { | |
| 245 | + | if m.moveStack(-dy) { | |
| 246 | + | return | |
| 247 | + | } | |
| 133 | 248 | i := m.Active + dy | |
| 134 | 249 | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | |
| 135 | 250 | return | |
| @@ -138,7 +253,6 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||
| 138 | 253 | m.selectRow() | |
| 139 | 254 | return | |
| 140 | 255 | } | |
| 141 | - | w := m.Current() | |
| 142 | 256 | if w.pinned() { | |
| 143 | 257 | return | |
| 144 | 258 | } | |
| @@ -147,20 +261,62 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||
| 147 | 261 | case at+dx >= 0 && at+dx < len(w.Stash): | |
| 148 | 262 | m.selectCard(w, at+dx) | |
| 149 | 263 | case dx > 0 && len(w.Columns) > 0: | |
| 150 | - | m.overviewCard, m.overviewCardOf = 0, nil | |
| 151 | - | w.FocusID(w.Columns[0].Windows[w.Columns[0].Focus]) | |
| 264 | + | m.ov.card, m.ov.cardOf = 0, nil | |
| 265 | + | item := m.stackFront(w) | |
| 266 | + | if item.kind == stackColumns { | |
| 267 | + | if w.overviewMaximized() { | |
| 268 | + | i := m.hiddenColumn(w) | |
| 269 | + | m.ov.selected, m.ov.selectedAt = w.Columns[i].Windows[w.Columns[i].Focus], i | |
| 270 | + | } else { | |
| 271 | + | w.selectOverviewColumn(0) | |
| 272 | + | m.ov.selected = w.Columns[0].Windows[w.Columns[0].Focus] | |
| 273 | + | } | |
| 274 | + | } | |
| 152 | 275 | } | |
| 153 | 276 | return | |
| 154 | 277 | } | |
| 155 | - | if dx < 0 && (len(w.Columns) == 0 || w.Focus == 0) && len(w.Stash) > 0 { | |
| 156 | - | m.selectCard(w, w.stashAt) | |
| 278 | + | front := m.stackFront(w) | |
| 279 | + | if front.kind != stackColumns { | |
| 280 | + | if dx < 0 && m.moveStack(1) { | |
| 281 | + | return | |
| 282 | + | } | |
| 283 | + | if dx < 0 && len(w.Stash) > 0 { | |
| 284 | + | m.selectCard(w, w.stashAt) | |
| 285 | + | } | |
| 157 | 286 | return | |
| 158 | 287 | } | |
| 159 | 288 | if len(w.Columns) == 0 { | |
| 160 | 289 | return | |
| 161 | 290 | } | |
| 162 | - | c := w.Columns[min(max(w.Focus+dx, 0), len(w.Columns)-1)] | |
| 163 | - | w.FocusID(c.Windows[c.Focus]) | |
| 291 | + | i := w.columnOf(m.ov.selected) | |
| 292 | + | if i < 0 || w.overviewMaximized() && i == w.Focus { | |
| 293 | + | if w.overviewMaximized() { | |
| 294 | + | i = m.hiddenColumn(w) | |
| 295 | + | } else { | |
| 296 | + | i = w.Focus | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | first := 0 | |
| 300 | + | if w.overviewMaximized() && w.Focus == 0 { | |
| 301 | + | first = 1 | |
| 302 | + | } | |
| 303 | + | if dx < 0 && i == first && len(w.Stash) > 0 { | |
| 304 | + | m.selectCard(w, w.stashAt) | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | next := min(max(i+dx, 0), len(w.Columns)-1) | |
| 308 | + | if w.overviewMaximized() && next == w.Focus { | |
| 309 | + | next = min(max(next+dx, 0), len(w.Columns)-1) | |
| 310 | + | if next == w.Focus { | |
| 311 | + | return | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | m.ov.selected = w.Columns[next].Windows[w.Columns[next].Focus] | |
| 315 | + | m.ov.selectedAt = next | |
| 316 | + | if !w.overviewMaximized() { | |
| 317 | + | // Ordinary group navigation retains its existing scroll-to-selection. | |
| 318 | + | w.selectOverviewColumn(next) | |
| 319 | + | } | |
| 164 | 320 | } | |
| 165 | 321 | ||
| 166 | 322 | // OverviewPick closes the overview on the clicked window. | |
| @@ -169,33 +325,52 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 169 | 325 | if w == nil { | |
| 170 | 326 | return | |
| 171 | 327 | } | |
| 328 | + | if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 { | |
| 329 | + | i := w.floatIndex(id) | |
| 330 | + | if !w.coversFloat(w.Floats[i]) && !w.pinned() { | |
| 331 | + | return | |
| 332 | + | } | |
| 333 | + | } | |
| 172 | 334 | m.show(w) | |
| 173 | - | m.overviewCard, m.overviewCardOf = 0, nil | |
| 335 | + | m.ov.card, m.ov.cardOf = 0, nil | |
| 174 | 336 | if i := w.stashIndex(id); i >= 0 { | |
| 175 | 337 | m.selectCard(w, i) | |
| 176 | - | } else { | |
| 338 | + | } else if w.pinned() { | |
| 177 | 339 | w.FocusID(id) | |
| 340 | + | } else if item, ok := w.itemOf(id); ok { | |
| 341 | + | m.setFront(w, item) | |
| 342 | + | if i := w.columnOf(id); i >= 0 { | |
| 343 | + | if item.kind == stackColumns && !w.overviewMaximized() { | |
| 344 | + | w.selectOverviewColumn(i) | |
| 345 | + | } | |
| 346 | + | m.ov.selected = id | |
| 347 | + | for j, v := range w.Columns[i].Windows { | |
| 348 | + | if v == id { | |
| 349 | + | w.Columns[i].Focus = j | |
| 350 | + | break | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 178 | 354 | } | |
| 179 | 355 | m.closeOverview() | |
| 180 | 356 | } | |
| 181 | 357 | ||
| 182 | 358 | // overviewLayout places the current workspace's row in the middle of the | |
| 183 | 359 | // usable area and its numbered neighbors above and below, dimmed, each | |
| 184 | - | // with its stash pile. Other workspaces and native floats are hidden. | |
| 360 | + | // with its stash pile and covering-float stack. Other workspaces are hidden. | |
| 185 | 361 | func (m *Monitor) overviewLayout() []Placement { | |
| 186 | 362 | cur := m.Current() | |
| 187 | 363 | u := cur.Usable | |
| 188 | 364 | gap := u.H * 3 / 100 | |
| 189 | 365 | var result []Placement | |
| 190 | - | zc := cur.overviewZoom() | |
| 191 | - | hc := int(math.Round(float64(u.H) * zc)) | |
| 366 | + | hc := cur.rowHeight() | |
| 192 | 367 | y := u.Y + (u.H-hc)/2 | |
| 193 | 368 | rows := map[*Workspace]int{} | |
| 194 | 369 | rows[cur] = y | |
| 195 | 370 | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | |
| 196 | 371 | if i > 0 { | |
| 197 | 372 | up := m.Workspaces[i-1] | |
| 198 | - | rows[up] = y - gap - int(math.Round(float64(u.H)*up.overviewZoom())) | |
| 373 | + | rows[up] = y - gap - up.rowHeight() | |
| 199 | 374 | } | |
| 200 | 375 | if i+1 < len(m.Workspaces) { | |
| 201 | 376 | rows[m.Workspaces[i+1]] = y + hc + gap | |
| @@ -215,11 +390,13 @@ func (m *Monitor) overviewLayout() []Placement { | |||
| 215 | 390 | } | |
| 216 | 391 | if shown { | |
| 217 | 392 | at := m.cardAt(w) | |
| 218 | - | result = append(result, w.previewRow(ry, w != cur, at < 0)...) | |
| 393 | + | result = append(result, m.stackRow(w, ry, w != cur, at < 0)...) | |
| 219 | 394 | result = append(result, w.pile(ry, w != cur, at)...) | |
| 220 | 395 | } | |
| 221 | 396 | for _, f := range w.Floats { | |
| 222 | - | result = append(result, Placement{ID: f.ID, Hidden: true}) | |
| 397 | + | if _, drawn := w.itemOf(f.ID); !shown || !drawn { | |
| 398 | + | result = append(result, Placement{ID: f.ID, Hidden: true}) | |
| 399 | + | } | |
| 223 | 400 | } | |
| 224 | 401 | if !shown { | |
| 225 | 402 | for _, id := range w.windows() { | |
| @@ -232,6 +409,72 @@ func (m *Monitor) overviewLayout() []Placement { | |||
| 232 | 409 | return result | |
| 233 | 410 | } | |
| 234 | 411 | ||
| 412 | + | // fan places front and neighboring cards, farthest visible first and front | |
| 413 | + | // last. A card may contain several tiles; omitted cards stay in the layout | |
| 414 | + | // hidden so their clients are not left showing on screen. | |
| 415 | + | func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool, | |
| 416 | + | behind, before func(int) (int, int), dim, lit bool) []Placement { | |
| 417 | + | n := len(cards) | |
| 418 | + | if n == 0 { | |
| 419 | + | return nil | |
| 420 | + | } | |
| 421 | + | front = min(max(front, 0), n-1) | |
| 422 | + | out := make([]Placement, 0, n) | |
| 423 | + | add := func(i, distance int, offset func(int) (int, int), peek bool) { | |
| 424 | + | dx, dy := offset(distance) | |
| 425 | + | for _, p := range cards[i] { | |
| 426 | + | if !p.Hidden { | |
| 427 | + | p.Rect.X += dx | |
| 428 | + | p.Rect.Y += dy | |
| 429 | + | p.Peek = p.Peek || peek || dim | |
| 430 | + | p.Focused = p.Focused && !peek && !dim && lit | |
| 431 | + | } | |
| 432 | + | out = append(out, p) | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | shown := make([]bool, n) | |
| 436 | + | // Behind and before are in nearest-first order; paint farthest first. | |
| 437 | + | for d := min(maxBehind, n-1); d >= 1; d-- { | |
| 438 | + | i := front + d | |
| 439 | + | if circular { | |
| 440 | + | i = (front + d) % n | |
| 441 | + | } | |
| 442 | + | if i >= n { | |
| 443 | + | continue | |
| 444 | + | } | |
| 445 | + | add(i, d, behind, true) | |
| 446 | + | shown[i] = true | |
| 447 | + | } | |
| 448 | + | for d := min(maxBefore, n-1); d >= 1; d-- { | |
| 449 | + | i := front - d | |
| 450 | + | if circular { | |
| 451 | + | i = (front - d + n) % n | |
| 452 | + | } | |
| 453 | + | if i < 0 { | |
| 454 | + | continue | |
| 455 | + | } | |
| 456 | + | if shown[i] { | |
| 457 | + | continue | |
| 458 | + | } | |
| 459 | + | add(i, d, before, true) | |
| 460 | + | shown[i] = true | |
| 461 | + | } | |
| 462 | + | add(front, 0, behind, false) | |
| 463 | + | shown[front] = true | |
| 464 | + | for i, card := range cards { | |
| 465 | + | if shown[i] { | |
| 466 | + | continue | |
| 467 | + | } | |
| 468 | + | for _, p := range card { | |
| 469 | + | p.Hidden = true | |
| 470 | + | p.Preview = 0 | |
| 471 | + | p.Focused = false | |
| 472 | + | out = append(out, p) | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | return out | |
| 476 | + | } | |
| 477 | + | ||
| 235 | 478 | // pileWidth is the room the stash pile of w takes on the left of its row, | |
| 236 | 479 | // its margin included; 0 without a stash. | |
| 237 | 480 | func (w *Workspace) pileWidth() int { | |
| @@ -267,19 +510,17 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||
| 267 | 510 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | |
| 268 | 511 | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | |
| 269 | 512 | rowH := int(math.Round(float64(u.H) * w.overviewZoom())) | |
| 270 | - | x0, y0 := u.X+u.W/100, y+(rowH-ch)/2 | |
| 271 | - | shown := min(n, overviewCards) | |
| 272 | - | out := make([]Placement, 0, n) | |
| 273 | - | // Farthest card first: the front one is drawn and hit on top. | |
| 274 | - | for k := shown - 1; k >= 0; k-- { | |
| 275 | - | i := (front + k) % n | |
| 276 | - | step := k * w.cardStep() | |
| 277 | - | out = append(out, Placement{ID: w.Stash[i].ID, Rect: Rect{X: x0 + step, Y: y0 - step, W: cw, H: ch}, Preview: overviewCardZoom, Peek: dim || k > 0, Focused: k == 0 && selected}) | |
| 278 | - | } | |
| 279 | - | for k := shown; k < n; k++ { | |
| 280 | - | out = append(out, Placement{ID: w.Stash[(front+k)%n].ID, Hidden: true}) | |
| 281 | - | } | |
| 282 | - | return out | |
| 513 | + | // Stack rows reserve two peek steps above the front card. Align the | |
| 514 | + | // stash with that front band, not with the top of its envelope. | |
| 515 | + | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 | |
| 516 | + | cards := make([][]Placement, n) | |
| 517 | + | for i := range w.Stash { | |
| 518 | + | cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}} | |
| 519 | + | } | |
| 520 | + | step := w.cardStep() | |
| 521 | + | return fan(cards, front, min(n, overviewCards)-1, 0, true, | |
| 522 | + | func(d int) (int, int) { return d * step, -d * step }, | |
| 523 | + | func(int) (int, int) { return 0, 0 }, dim, selected) | |
| 283 | 524 | } | |
| 284 | 525 | ||
| 285 | 526 | // overviewZoom is the scale that fits every column of w in the usable | |
| @@ -290,13 +531,41 @@ func (w *Workspace) overviewZoom() float64 { | |||
| 290 | 531 | return overviewMaxZoom | |
| 291 | 532 | } | |
| 292 | 533 | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | |
| 293 | - | return min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 534 | + | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | |
| 535 | + | if len(w.stack()) > 1 { | |
| 536 | + | // Reserve two clear peeks on either side of the front row. | |
| 537 | + | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | |
| 538 | + | } | |
| 539 | + | return z | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | // peekStep is the shared step for a stack row and its front-card inset. | |
| 543 | + | func (w *Workspace) peekStep() int { | |
| 544 | + | return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep))) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // frontRowY skips the space reserved for two cards peeking above the front. | |
| 548 | + | // A single-card row starts at y, as before. | |
| 549 | + | func (w *Workspace) frontRowY(y int) int { | |
| 550 | + | if len(w.stack()) > 1 { | |
| 551 | + | return y + 2*w.peekStep() | |
| 552 | + | } | |
| 553 | + | return y | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | // rowHeight includes the largest possible stack envelope (two peeks above | |
| 557 | + | // and two below). Neighbor rows use the same measure as the current row. | |
| 558 | + | func (w *Workspace) rowHeight() int { | |
| 559 | + | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |
| 560 | + | if len(w.stack()) > 1 { | |
| 561 | + | h += 4 * w.peekStep() | |
| 562 | + | } | |
| 563 | + | return h | |
| 294 | 564 | } | |
| 295 | 565 | ||
| 296 | - | // previewTiles lays the tiles of w out unscrolled, relative to the | |
| 297 | - | // usable area's corner: every column side by side at its width, as if | |
| 298 | - | // the output were wide enough (fixed overflow's spiral included). span is | |
| 299 | - | // the row's width; sel is the focused column. | |
| 566 | + | // previewTiles lays tiles out unscrolled, relative to the usable area's | |
| 567 | + | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses | |
| 568 | + | // an unscrolled row. span is the row's width; sel is the focused column. | |
| 300 | 569 | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |
| 301 | 570 | g := w.gap() | |
| 302 | 571 | h := max(w.Usable.H-2*g, 0) | |
| @@ -307,16 +576,36 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||
| 307 | 576 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h} | |
| 308 | 577 | return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r | |
| 309 | 578 | } | |
| 579 | + | var rects []Rect | |
| 580 | + | if w.Overflow == OverflowFixed { | |
| 581 | + | rects = w.columnRects() | |
| 582 | + | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | |
| 583 | + | } | |
| 584 | + | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |
| 310 | 585 | for i, c := range w.Columns { | |
| 311 | 586 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | |
| 312 | - | if w.fullscreenColumn(i) { | |
| 587 | + | if w.Overflow == OverflowFixed { | |
| 588 | + | r = rects[i] | |
| 589 | + | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | |
| 590 | + | } else if w.fullscreenColumn(i) { | |
| 313 | 591 | // A fullscreen column shows at the width it had in the row. | |
| 314 | 592 | r.W = max(w.Usable.W-2*g, 0) | |
| 315 | 593 | } | |
| 594 | + | if maximized && i != w.Focus { | |
| 595 | + | // FullWidth hides other columns on screen. Their old buffers | |
| 596 | + | // cannot fit their current spiral rects; show only the maximized | |
| 597 | + | // column until navigation restores the normal layout. | |
| 598 | + | for _, id := range c.Windows { | |
| 599 | + | tiles = append(tiles, Placement{ID: id, Hidden: true}) | |
| 600 | + | } | |
| 601 | + | continue | |
| 602 | + | } | |
| 316 | 603 | for j, t := range stackRects(r, len(c.Windows), g) { | |
| 317 | 604 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | |
| 318 | 605 | } | |
| 319 | - | span = max(span, r.X+r.W+g) | |
| 606 | + | if w.Overflow != OverflowFixed { | |
| 607 | + | span = max(span, r.X+r.W+g) | |
| 608 | + | } | |
| 320 | 609 | if i == w.Focus { | |
| 321 | 610 | sel = r | |
| 322 | 611 | } | |
| @@ -331,18 +620,27 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||
| 331 | 620 | // false). | |
| 332 | 621 | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | |
| 333 | 622 | tiles, span, sel := w.previewTiles() | |
| 623 | + | return w.previewRowTiles(y, dim, lit, tiles, span, sel) | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | |
| 334 | 627 | u := w.Usable | |
| 335 | - | left := w.pileWidth() | |
| 336 | - | u.X, u.W = u.X+left, u.W-left | |
| 337 | 628 | z := w.overviewZoom() | |
| 338 | 629 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| 339 | 630 | width := scale(span) | |
| 340 | - | x := u.X + (u.W-width)/2 | |
| 631 | + | // Rows stay centred on the output like rows without a stash; the pile | |
| 632 | + | // only pushes a row right when they would overlap. | |
| 633 | + | x := max(u.X+(u.W-width)/2, u.X+w.pileWidth()) | |
| 634 | + | left := w.pileWidth() | |
| 635 | + | u.X, u.W = u.X+left, u.W-left | |
| 341 | 636 | if width > u.W { | |
| 342 | 637 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | |
| 343 | 638 | x = min(max(x, u.X+u.W-width), u.X) | |
| 344 | 639 | } | |
| 345 | 640 | for i := range tiles { | |
| 641 | + | if tiles[i].Hidden { | |
| 642 | + | continue | |
| 643 | + | } | |
| 346 | 644 | r := tiles[i].Rect | |
| 347 | 645 | x0, y0 := scale(r.X), scale(r.Y) | |
| 348 | 646 | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | |
| @@ -369,9 +667,18 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 369 | 667 | return nil | |
| 370 | 668 | } | |
| 371 | 669 | ||
| 372 | - | // overviewAt is the preview under the output-local point, if any. | |
| 670 | + | // overviewAt is the preview under the output-local point, if any. Front | |
| 671 | + | // cards take priority over peeks, including peeks appended after a front | |
| 672 | + | // tile in another row's layout. | |
| 373 | 673 | func (m *Monitor) overviewAt(x, y float64) WindowID { | |
| 374 | 674 | layout := m.Layout() | |
| 675 | + | for i := len(layout) - 1; i >= 0; i-- { | |
| 676 | + | p := layout[i] | |
| 677 | + | r := p.Rect | |
| 678 | + | if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |
| 679 | + | return p.ID | |
| 680 | + | } | |
| 681 | + | } | |
| 375 | 682 | for i := len(layout) - 1; i >= 0; i-- { | |
| 376 | 683 | p := layout[i] | |
| 377 | 684 | r := p.Rect | |
| @@ -391,7 +698,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 391 | 698 | } | |
| 392 | 699 | i := c.screenIndex(o.Info.Name) | |
| 393 | 700 | sc := c.screens[i] | |
| 394 | - | if !sc.mon.overview { | |
| 701 | + | if !sc.mon.ov.open { | |
| 395 | 702 | return false, nil | |
| 396 | 703 | } | |
| 397 | 704 | id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y)) | |
| @@ -410,16 +717,32 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 410 | 717 | // overviewSwipe moves the selection like the keys: a workspace swipe | |
| 411 | 718 | // must not enter the empty workspace below the last one. | |
| 412 | 719 | func (m *Monitor) overviewSwipe(a Action) { | |
| 720 | + | switch a { | |
| 721 | + | case ActionFocusWorkspaceUp: | |
| 722 | + | a = ActionFocusWindowUp | |
| 723 | + | case ActionFocusWorkspaceDown: | |
| 724 | + | a = ActionFocusWindowDown | |
| 725 | + | } | |
| 726 | + | m.overviewFocus(a) | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | // overviewFocus runs a focus bind as an overview move, so the focus binds | |
| 730 | + | // and the bare keys navigate the overview the same way: true when a was | |
| 731 | + | // one of them. | |
| 732 | + | func (m *Monitor) overviewFocus(a Action) bool { | |
| 413 | 733 | switch a { | |
| 414 | 734 | case ActionFocusColumnLeft: | |
| 415 | 735 | m.OverviewMove(-1, 0) | |
| 416 | 736 | case ActionFocusColumnRight: | |
| 417 | 737 | m.OverviewMove(1, 0) | |
| 418 | - | case ActionFocusWorkspaceUp: | |
| 738 | + | case ActionFocusWindowUp: | |
| 419 | 739 | m.OverviewMove(0, -1) | |
| 420 | - | case ActionFocusWorkspaceDown: | |
| 740 | + | case ActionFocusWindowDown: | |
| 421 | 741 | m.OverviewMove(0, 1) | |
| 742 | + | default: | |
| 743 | + | return false | |
| 422 | 744 | } | |
| 745 | + | return true | |
| 423 | 746 | } | |
| 424 | 747 | ||
| 425 | 748 | // overviewScrollStep is the two-finger scroll distance, in libinput's | |
| @@ -429,13 +752,12 @@ const overviewScrollStep = 60 | |||
| 429 | 752 | // overviewScroll moves the overview selection with a scroll frame: two | |
| 430 | 753 | // fingers step once per overviewScrollStep on the axis they move most | |
| 431 | 754 | // along, a wheel once per notch (high-resolution wheels add up their | |
| 432 | - | // fractions of a notch). Scrolling down or right selects the workspace | |
| 433 | - | // below or the column on the right; touchpad.natural-scroll flips both, | |
| 434 | - | // as libinput reports. changed is false while the distance adds up. | |
| 755 | + | // fractions of a notch). Vertical steps select cards first, then rows; | |
| 756 | + | // touchpad.natural-scroll flips both axes as libinput reports. | |
| 435 | 757 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |
| 436 | 758 | if a.Vertical.Stop || a.Horizontal.Stop { | |
| 437 | 759 | // Fingers lifted: the next scroll starts from zero. | |
| 438 | - | m.scrollX, m.scrollY = 0, 0 | |
| 760 | + | m.ov.scrollX, m.ov.scrollY = 0, 0 | |
| 439 | 761 | return false | |
| 440 | 762 | } | |
| 441 | 763 | add := func(acc *float64, ax ports.ScrollAxis) { | |
| @@ -447,18 +769,18 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 447 | 769 | *acc += ax.Value | |
| 448 | 770 | } | |
| 449 | 771 | } | |
| 450 | - | add(&m.scrollY, a.Vertical) | |
| 451 | - | add(&m.scrollX, a.Horizontal) | |
| 772 | + | add(&m.ov.scrollY, a.Vertical) | |
| 773 | + | add(&m.ov.scrollX, a.Horizontal) | |
| 452 | 774 | for { | |
| 453 | 775 | switch { | |
| 454 | - | case math.Abs(m.scrollY) >= overviewScrollStep && math.Abs(m.scrollY) >= math.Abs(m.scrollX): | |
| 455 | - | m.OverviewMove(0, sign(m.scrollY)) | |
| 456 | - | m.scrollY -= float64(sign(m.scrollY)) * overviewScrollStep | |
| 457 | - | m.scrollX = 0 | |
| 458 | - | case math.Abs(m.scrollX) >= overviewScrollStep: | |
| 459 | - | m.OverviewMove(sign(m.scrollX), 0) | |
| 460 | - | m.scrollX -= float64(sign(m.scrollX)) * overviewScrollStep | |
| 461 | - | m.scrollY = 0 | |
| 776 | + | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): | |
| 777 | + | m.OverviewMove(0, sign(m.ov.scrollY)) | |
| 778 | + | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | |
| 779 | + | m.ov.scrollX = 0 | |
| 780 | + | case math.Abs(m.ov.scrollX) >= overviewScrollStep: | |
| 781 | + | m.OverviewMove(sign(m.ov.scrollX), 0) | |
| 782 | + | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * overviewScrollStep | |
| 783 | + | m.ov.scrollY = 0 | |
| 462 | 784 | default: | |
| 463 | 785 | return changed | |
| 464 | 786 | } | |
| @@ -3,18 +3,35 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | + | "slices" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/neferwl/internal/ports" | 8 | "github.com/bnema/neferwl/internal/ports" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | - | // The overview shows every column of the workspace on screen in one row, | 11 | + | // overviewState owns the provisional selection and opening snapshot. row is |
| 11 | - | // each tile scaled by the same factor so windows keep their shape, with | 12 | + | // the workspace whose stack has a provisional front; nil uses its real front. |
| 12 | - | // the workspaces above and below it dimmed. A workspace pinned under a | 13 | + | type overviewState struct { |
| 13 | - | // covering fullscreen window (a game) shows only that window, as on | 14 | + | open bool |
| 14 | - | // screen. The previews | 15 | + | from *Workspace |
| 15 | - | // are the windows' last buffers: clients are not resized. h/l (or left/right) select a | 16 | + | fromID WindowID |
| 16 | - | // column, j/k (or down/up) a workspace, Return keeps the selection and | 17 | + | floats []Float |
| 17 | - | // Escape returns to where the overview opened. | 18 | + | fullWidth WindowID |
| 19 | + | row *Workspace | |||
| 20 | + | front stackItem | |||
| 21 | + | frontAt int | |||
| 22 | + | cardOf *Workspace | |||
| 23 | + | card WindowID | |||
| 24 | + | selected WindowID | |||
| 25 | + | selectedAt int | |||
| 26 | + | // FullWidth history is restored on Escape when active binds changed it. | |||
| 27 | + | maximized []WindowID | |||
| 28 | + | scrollX, scrollY float64 | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // The overview scales workspace rows without resizing clients. Covering | |||
| 32 | + | // floats and the columns form a linear stack of cards; small floats stay | |||
| 33 | + | // hidden. Up/down traverse the stack before changing workspace. A pinned | |||
| 34 | + | // fullscreen window remains the only preview on its row. | |||
| 18 | 35 | ||||
| 19 | // A workspace's stash shows as a pile of cards pinned to the left edge of | 36 | // A workspace's stash shows as a pile of cards pinned to the left edge of | ||
| 20 | // its row: the selected stashed window in front at overviewCardZoom, up to | 37 | // its row: the selected stashed window in front at overviewCardZoom, up to | ||
| @@ -24,6 +41,7 @@ const ( | |||||
| 24 | overviewCardZoom = 0.2 | 41 | overviewCardZoom = 0.2 | ||
| 25 | overviewCards = 4 | 42 | overviewCards = 4 | ||
| 26 | overviewCardStep = 8 | 43 | overviewCardStep = 8 | ||
| 44 | + | overviewPeekStep = 0.07 | |||
| 27 | ) | 45 | ) | ||
| 28 | 46 | ||||
| 29 | // overviewMaxZoom caps the current row so its neighbors show above and | 47 | // overviewMaxZoom caps the current row so its neighbors show above and | ||
| @@ -35,7 +53,7 @@ const overviewMaxZoom, overviewMinZoom = 0.6, 0.25 | |||||
| 35 | // a covering fullscreen window (a game) too: its row shows it, composed | 53 | // a covering fullscreen window (a game) too: its row shows it, composed | ||
| 36 | // rather than scanned out while the overview is open. | 54 | // rather than scanned out while the overview is open. | ||
| 37 | func (m *Monitor) ToggleOverview() { | 55 | func (m *Monitor) ToggleOverview() { | ||
| 38 | - | if m.overview { | 56 | + | if m.ov.open { |
| 39 | m.closeOverview() | 57 | m.closeOverview() | ||
| 40 | return | 58 | return | ||
| 41 | } | 59 | } | ||
| @@ -44,55 +62,118 @@ func (m *Monitor) ToggleOverview() { | |||||
| 44 | // settled state. | 62 | // settled state. | ||
| 45 | m.stopSwitch() | 63 | m.stopSwitch() | ||
| 46 | m.each(func(w *Workspace) { w.stopSlide() }) | 64 | m.each(func(w *Workspace) { w.stopSlide() }) | ||
| 47 | - | m.overview, m.overviewFrom = true, w | 65 | + | m.ov.open, m.ov.from = true, w |
| 48 | - | m.overviewFromID, _ = w.Focused() | 66 | + | m.ov.fromID, _ = w.Focused() |
| 49 | - | m.scrollX, m.scrollY = 0, 0 | 67 | + | m.ov.floats = append([]Float(nil), w.Floats...) |
| 68 | + | m.ov.maximized = slices.Clone(w.maximized) | |||
| 69 | + | m.ov.fullWidth = 0 | |||
| 70 | + | if len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |||
| 71 | + | c := w.Columns[w.Focus] | |||
| 72 | + | m.ov.fullWidth = c.Windows[c.Focus] | |||
| 73 | + | } | |||
| 74 | + | m.ov.scrollX, m.ov.scrollY = 0, 0 | |||
| 50 | m.overviewOpens++ | 75 | m.overviewOpens++ | ||
| 51 | m.selectRow() | 76 | m.selectRow() | ||
| 52 | } | 77 | } | ||
| 53 | 78 | ||||
| 54 | - | // selectRow selects the stash card of a workspace whose stash has the | 79 | + | // selectRow resets provisional stack and stash selection for a new row. |
| 55 | - | // focus (or only stashed windows), else its focused column. The overview | |||
| 56 | - | // hides native floats, so a float's focus moves to its column; Escape | |||
| 57 | - | // gives it back. A pinning fullscreen window is the exception: it is the | |||
| 58 | - | // row. | |||
| 59 | func (m *Monitor) selectRow() { | 80 | func (m *Monitor) selectRow() { | ||
| 60 | - | m.overviewCard, m.overviewCardOf = 0, nil | 81 | + | m.ov.card, m.ov.cardOf = 0, nil |
| 82 | + | m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0 | |||
| 83 | + | m.ov.selected, m.ov.selectedAt = 0, -1 | |||
| 61 | w := m.Current() | 84 | w := m.Current() | ||
| 62 | if w.pinned() { | 85 | if w.pinned() { | ||
| 63 | - | // The covering window is the row's only preview: it keeps the focus. | |||
| 64 | return | 86 | return | ||
| 65 | } | 87 | } | ||
| 66 | - | w.floatFocus = false | 88 | + | if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) { |
| 67 | - | if len(w.Stash) > 0 && (len(w.Columns) == 0 || w.stashFocused()) { | 89 | + | w.floatFocus = false |
| 90 | + | } | |||
| 91 | + | if len(w.Stash) > 0 && (len(w.stack()) == 0 || w.stashFocused() && !w.floatFocus) { | |||
| 68 | m.selectCard(w, w.stashAt) | 92 | m.selectCard(w, w.stashAt) | ||
| 69 | } | 93 | } | ||
| 70 | } | 94 | } | ||
| 71 | 95 | ||||
| 72 | // selectCard selects stash entry i of w. | 96 | // selectCard selects stash entry i of w. | ||
| 73 | func (m *Monitor) selectCard(w *Workspace, i int) { | 97 | func (m *Monitor) selectCard(w *Workspace, i int) { | ||
| 74 | - | m.overviewCard, m.overviewCardOf = w.Stash[i].ID, w | 98 | + | m.ov.card, m.ov.cardOf = w.Stash[i].ID, w |
| 75 | } | 99 | } | ||
| 76 | 100 | ||||
| 77 | // card is the selected stash card, 0 when there is none: the overview is | 101 | // card is the selected stash card, 0 when there is none: the overview is | ||
| 78 | // closed, the selection is on a column, or its window left the stash on | 102 | // closed, the selection is on a column, or its window left the stash on | ||
| 79 | // screen (unmapped, unstashed, or another workspace shown by a bind). | 103 | // screen (unmapped, unstashed, or another workspace shown by a bind). | ||
| 80 | func (m *Monitor) card() WindowID { | 104 | func (m *Monitor) card() WindowID { | ||
| 81 | - | w := m.overviewCardOf | 105 | + | w := m.ov.cardOf |
| 82 | - | if !m.overview || w == nil || w != m.Current() || w.stashIndex(m.overviewCard) < 0 { | 106 | + | if !m.ov.open || w == nil || w != m.Current() || w.stashIndex(m.ov.card) < 0 { |
| 83 | return 0 | 107 | return 0 | ||
| 84 | } | 108 | } | ||
| 85 | - | return m.overviewCard | 109 | + | return m.ov.card |
| 86 | } | 110 | } | ||
| 87 | 111 | ||||
| 88 | // cardAt is the pile entry of w that is selected, -1 when none. | 112 | // cardAt is the pile entry of w that is selected, -1 when none. | ||
| 89 | func (m *Monitor) cardAt(w *Workspace) int { | 113 | func (m *Monitor) cardAt(w *Workspace) int { | ||
| 90 | - | if id := m.card(); id != 0 && w == m.overviewCardOf { | 114 | + | if id := m.card(); id != 0 && w == m.ov.cardOf { |
| 91 | return w.stashIndex(id) | 115 | return w.stashIndex(id) | ||
| 92 | } | 116 | } | ||
| 93 | return -1 | 117 | return -1 | ||
| 94 | } | 118 | } | ||
| 95 | 119 | ||||
| 120 | + | // overviewTarget resolves the active preview to one window for validation | |||
| 121 | + | // and window actions. Presentation state never falls back to a covering float. | |||
| 122 | + | func (m *Monitor) overviewTarget() WindowID { | |||
| 123 | + | if id := m.card(); id != 0 { | |||
| 124 | + | return id | |||
| 125 | + | } | |||
| 126 | + | w := m.Current() | |||
| 127 | + | if w.pinned() { | |||
| 128 | + | return w.cover() | |||
| 129 | + | } | |||
| 130 | + | item := m.stackFront(w) | |||
| 131 | + | switch item.kind { | |||
| 132 | + | case stackFloat: | |||
| 133 | + | return item.id | |||
| 134 | + | case stackColumn: | |||
| 135 | + | i := w.columnOf(item.id) | |||
| 136 | + | if i >= 0 { | |||
| 137 | + | c := w.Columns[i] | |||
| 138 | + | return c.Windows[c.Focus] | |||
| 139 | + | } | |||
| 140 | + | case stackColumns: | |||
| 141 | + | i := w.Focus | |||
| 142 | + | if len(w.Columns) == 0 { | |||
| 143 | + | return 0 | |||
| 144 | + | } | |||
| 145 | + | if w.overviewMaximized() { | |||
| 146 | + | i = w.columnOf(m.ov.selected) | |||
| 147 | + | if i < 0 || i == w.Focus { | |||
| 148 | + | i = m.hiddenColumn(w) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | if i >= 0 { | |||
| 152 | + | c := w.Columns[i] | |||
| 153 | + | return c.Windows[c.Focus] | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | return 0 | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // overviewBlocks makes window mutations explicit: they are disabled while | |||
| 160 | + | // selection is provisional. Workspace and monitor navigation remain active. | |||
| 161 | + | func overviewBlocks(a Action) bool { | |||
| 162 | + | if _, op, ok := WorkspaceArg(a); ok { | |||
| 163 | + | return op != FocusWorkspace | |||
| 164 | + | } | |||
| 165 | + | switch a { | |||
| 166 | + | case ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |||
| 167 | + | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |||
| 168 | + | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | |||
| 169 | + | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |||
| 170 | + | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |||
| 171 | + | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight: | |||
| 172 | + | return true | |||
| 173 | + | } | |||
| 174 | + | return false | |||
| 175 | + | } | |||
| 176 | + | ||||
| 96 | // closeOverview closes the overview on the selection: a card shows the | 177 | // closeOverview closes the overview on the selection: a card shows the | ||
| 97 | // stash on it. A fullscreen request another stashed window made while | 178 | // stash on it. A fullscreen request another stashed window made while | ||
| 98 | // the stash was hidden is dropped: the picked card stays in front. | 179 | // the stash was hidden is dropped: the picked card stays in front. | ||
| @@ -104,32 +185,66 @@ func (m *Monitor) closeOverview() { | |||||
| 104 | } | 185 | } | ||
| 105 | w.stashAt = i | 186 | w.stashAt = i | ||
| 106 | w.showStash() | 187 | w.showStash() | ||
| 188 | + | } else if !w.pinned() && len(w.stack()) > 0 { | |||
| 189 | + | w.apply(m.stackFront(w), m.overviewTarget()) | |||
| 107 | } | 190 | } | ||
| 108 | - | m.overview, m.overviewFrom = false, nil | 191 | + | m.ov = overviewState{} |
| 109 | - | m.overviewCard, m.overviewCardOf = 0, nil | |||
| 110 | } | 192 | } | ||
| 111 | 193 | ||||
| 112 | // CancelOverview closes the overview and returns to the workspace and | 194 | // CancelOverview closes the overview and returns to the workspace and | ||
| 113 | // window it opened on, if they are still there. | 195 | // window it opened on, if they are still there. | ||
| 114 | func (m *Monitor) CancelOverview() { | 196 | func (m *Monitor) CancelOverview() { | ||
| 115 | - | from, id := m.overviewFrom, m.overviewFromID | 197 | + | from, id := m.ov.from, m.ov.fromID |
| 116 | - | m.overview, m.overviewFrom = false, nil | 198 | + | if from != nil && m.has(from) { |
| 117 | - | m.overviewCard, m.overviewCardOf = 0, nil | 199 | + | m.show(from) |
| 118 | - | if from == nil || !m.has(from) { | 200 | + | if id != 0 { |
| 119 | - | return | 201 | + | from.FocusID(id) |
| 120 | - | } | 202 | + | } else { |
| 121 | - | m.show(from) | 203 | + | from.floatFocus, from.stashFocus = false, false |
| 122 | - | if id != 0 { | 204 | + | } |
| 123 | - | from.FocusID(id) | 205 | + | // FocusID on a float raises it; restore the initial order and below |
| 206 | + | // flags for floats still present, leaving new floats in their order. | |||
| 207 | + | initial := m.ov.floats | |||
| 208 | + | current := from.Floats | |||
| 209 | + | from.Floats = nil | |||
| 210 | + | for _, f := range initial { | |||
| 211 | + | for i, now := range current { | |||
| 212 | + | if now.ID == f.ID { | |||
| 213 | + | now.below = f.below | |||
| 214 | + | from.Floats = append(from.Floats, now) | |||
| 215 | + | current = append(current[:i], current[i+1:]...) | |||
| 216 | + | break | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | from.Floats = append(from.Floats, current...) | |||
| 221 | + | // Fixed overflow maximizes at most one column: only the one holding | |||
| 222 | + | // the anchor window, wherever active binds moved it. Scroll overflow | |||
| 223 | + | // keeps each column's own width. | |||
| 224 | + | if from.Overflow == OverflowFixed { | |||
| 225 | + | from.unmaximize() | |||
| 226 | + | for i := range from.Columns { | |||
| 227 | + | if m.ov.fullWidth != 0 && slices.Contains(from.Columns[i].Windows, m.ov.fullWidth) { | |||
| 228 | + | from.maximize(i) | |||
| 229 | + | break | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | from.maximized = slices.Clone(m.ov.maximized) | |||
| 234 | + | from.scroll() | |||
| 124 | } | 235 | } | ||
| 236 | + | m.ov = overviewState{} | |||
| 125 | } | 237 | } | ||
| 126 | 238 | ||||
| 127 | - | // OverviewMove selects the neighbor column (dx) or numbered workspace | 239 | + | // OverviewMove routes every vertical input through moveStack, then falls |
| 128 | - | // (dy). It stops at the ends and never enters an empty workspace, which | 240 | + | // through to workspace navigation at the stack boundary. Horizontal moves |
| 129 | - | // would open a terminal there. Left of the first column is the stash | 241 | + | // stay inside a column group; left on a single card sends it behind. |
| 130 | - | // pile; right of its last entry, the first column again. | |||
| 131 | func (m *Monitor) OverviewMove(dx, dy int) { | 242 | func (m *Monitor) OverviewMove(dx, dy int) { | ||
| 243 | + | w := m.Current() | |||
| 132 | if dy != 0 { | 244 | if dy != 0 { | ||
| 245 | + | if m.moveStack(-dy) { | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 133 | i := m.Active + dy | 248 | i := m.Active + dy | ||
| 134 | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | 249 | if m.shown != nil || i < 0 || i >= len(m.Workspaces) || m.Workspaces[i].empty() { | ||
| 135 | return | 250 | return | ||
| @@ -138,7 +253,6 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||||
| 138 | m.selectRow() | 253 | m.selectRow() | ||
| 139 | return | 254 | return | ||
| 140 | } | 255 | } | ||
| 141 | - | w := m.Current() | |||
| 142 | if w.pinned() { | 256 | if w.pinned() { | ||
| 143 | return | 257 | return | ||
| 144 | } | 258 | } | ||
| @@ -147,20 +261,62 @@ func (m *Monitor) OverviewMove(dx, dy int) { | |||||
| 147 | case at+dx >= 0 && at+dx < len(w.Stash): | 261 | case at+dx >= 0 && at+dx < len(w.Stash): | ||
| 148 | m.selectCard(w, at+dx) | 262 | m.selectCard(w, at+dx) | ||
| 149 | case dx > 0 && len(w.Columns) > 0: | 263 | case dx > 0 && len(w.Columns) > 0: | ||
| 150 | - | m.overviewCard, m.overviewCardOf = 0, nil | 264 | + | m.ov.card, m.ov.cardOf = 0, nil |
| 151 | - | w.FocusID(w.Columns[0].Windows[w.Columns[0].Focus]) | 265 | + | item := m.stackFront(w) |
| 266 | + | if item.kind == stackColumns { | |||
| 267 | + | if w.overviewMaximized() { | |||
| 268 | + | i := m.hiddenColumn(w) | |||
| 269 | + | m.ov.selected, m.ov.selectedAt = w.Columns[i].Windows[w.Columns[i].Focus], i | |||
| 270 | + | } else { | |||
| 271 | + | w.selectOverviewColumn(0) | |||
| 272 | + | m.ov.selected = w.Columns[0].Windows[w.Columns[0].Focus] | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 152 | } | 275 | } | ||
| 153 | return | 276 | return | ||
| 154 | } | 277 | } | ||
| 155 | - | if dx < 0 && (len(w.Columns) == 0 || w.Focus == 0) && len(w.Stash) > 0 { | 278 | + | front := m.stackFront(w) |
| 156 | - | m.selectCard(w, w.stashAt) | 279 | + | if front.kind != stackColumns { |
| 280 | + | if dx < 0 && m.moveStack(1) { | |||
| 281 | + | return | |||
| 282 | + | } | |||
| 283 | + | if dx < 0 && len(w.Stash) > 0 { | |||
| 284 | + | m.selectCard(w, w.stashAt) | |||
| 285 | + | } | |||
| 157 | return | 286 | return | ||
| 158 | } | 287 | } | ||
| 159 | if len(w.Columns) == 0 { | 288 | if len(w.Columns) == 0 { | ||
| 160 | return | 289 | return | ||
| 161 | } | 290 | } | ||
| 162 | - | c := w.Columns[min(max(w.Focus+dx, 0), len(w.Columns)-1)] | 291 | + | i := w.columnOf(m.ov.selected) |
| 163 | - | w.FocusID(c.Windows[c.Focus]) | 292 | + | if i < 0 || w.overviewMaximized() && i == w.Focus { |
| 293 | + | if w.overviewMaximized() { | |||
| 294 | + | i = m.hiddenColumn(w) | |||
| 295 | + | } else { | |||
| 296 | + | i = w.Focus | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | first := 0 | |||
| 300 | + | if w.overviewMaximized() && w.Focus == 0 { | |||
| 301 | + | first = 1 | |||
| 302 | + | } | |||
| 303 | + | if dx < 0 && i == first && len(w.Stash) > 0 { | |||
| 304 | + | m.selectCard(w, w.stashAt) | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | next := min(max(i+dx, 0), len(w.Columns)-1) | |||
| 308 | + | if w.overviewMaximized() && next == w.Focus { | |||
| 309 | + | next = min(max(next+dx, 0), len(w.Columns)-1) | |||
| 310 | + | if next == w.Focus { | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | m.ov.selected = w.Columns[next].Windows[w.Columns[next].Focus] | |||
| 315 | + | m.ov.selectedAt = next | |||
| 316 | + | if !w.overviewMaximized() { | |||
| 317 | + | // Ordinary group navigation retains its existing scroll-to-selection. | |||
| 318 | + | w.selectOverviewColumn(next) | |||
| 319 | + | } | |||
| 164 | } | 320 | } | ||
| 165 | 321 | ||||
| 166 | // OverviewPick closes the overview on the clicked window. | 322 | // OverviewPick closes the overview on the clicked window. | ||
| @@ -169,33 +325,52 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||||
| 169 | if w == nil { | 325 | if w == nil { | ||
| 170 | return | 326 | return | ||
| 171 | } | 327 | } | ||
| 328 | + | if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 { | |||
| 329 | + | i := w.floatIndex(id) | |||
| 330 | + | if !w.coversFloat(w.Floats[i]) && !w.pinned() { | |||
| 331 | + | return | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 172 | m.show(w) | 334 | m.show(w) | ||
| 173 | - | m.overviewCard, m.overviewCardOf = 0, nil | 335 | + | m.ov.card, m.ov.cardOf = 0, nil |
| 174 | if i := w.stashIndex(id); i >= 0 { | 336 | if i := w.stashIndex(id); i >= 0 { | ||
| 175 | m.selectCard(w, i) | 337 | m.selectCard(w, i) | ||
| 176 | - | } else { | 338 | + | } else if w.pinned() { |
| 177 | w.FocusID(id) | 339 | w.FocusID(id) | ||
| 340 | + | } else if item, ok := w.itemOf(id); ok { | |||
| 341 | + | m.setFront(w, item) | |||
| 342 | + | if i := w.columnOf(id); i >= 0 { | |||
| 343 | + | if item.kind == stackColumns && !w.overviewMaximized() { | |||
| 344 | + | w.selectOverviewColumn(i) | |||
| 345 | + | } | |||
| 346 | + | m.ov.selected = id | |||
| 347 | + | for j, v := range w.Columns[i].Windows { | |||
| 348 | + | if v == id { | |||
| 349 | + | w.Columns[i].Focus = j | |||
| 350 | + | break | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 178 | } | 354 | } | ||
| 179 | m.closeOverview() | 355 | m.closeOverview() | ||
| 180 | } | 356 | } | ||
| 181 | 357 | ||||
| 182 | // overviewLayout places the current workspace's row in the middle of the | 358 | // overviewLayout places the current workspace's row in the middle of the | ||
| 183 | // usable area and its numbered neighbors above and below, dimmed, each | 359 | // usable area and its numbered neighbors above and below, dimmed, each | ||
| 184 | - | // with its stash pile. Other workspaces and native floats are hidden. | 360 | + | // with its stash pile and covering-float stack. Other workspaces are hidden. |
| 185 | func (m *Monitor) overviewLayout() []Placement { | 361 | func (m *Monitor) overviewLayout() []Placement { | ||
| 186 | cur := m.Current() | 362 | cur := m.Current() | ||
| 187 | u := cur.Usable | 363 | u := cur.Usable | ||
| 188 | gap := u.H * 3 / 100 | 364 | gap := u.H * 3 / 100 | ||
| 189 | var result []Placement | 365 | var result []Placement | ||
| 190 | - | zc := cur.overviewZoom() | 366 | + | hc := cur.rowHeight() |
| 191 | - | hc := int(math.Round(float64(u.H) * zc)) | |||
| 192 | y := u.Y + (u.H-hc)/2 | 367 | y := u.Y + (u.H-hc)/2 | ||
| 193 | rows := map[*Workspace]int{} | 368 | rows := map[*Workspace]int{} | ||
| 194 | rows[cur] = y | 369 | rows[cur] = y | ||
| 195 | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | 370 | if i := indexOf(m.Workspaces, cur); i >= 0 && m.shown == nil { | ||
| 196 | if i > 0 { | 371 | if i > 0 { | ||
| 197 | up := m.Workspaces[i-1] | 372 | up := m.Workspaces[i-1] | ||
| 198 | - | rows[up] = y - gap - int(math.Round(float64(u.H)*up.overviewZoom())) | 373 | + | rows[up] = y - gap - up.rowHeight() |
| 199 | } | 374 | } | ||
| 200 | if i+1 < len(m.Workspaces) { | 375 | if i+1 < len(m.Workspaces) { | ||
| 201 | rows[m.Workspaces[i+1]] = y + hc + gap | 376 | rows[m.Workspaces[i+1]] = y + hc + gap | ||
| @@ -215,11 +390,13 @@ func (m *Monitor) overviewLayout() []Placement { | |||||
| 215 | } | 390 | } | ||
| 216 | if shown { | 391 | if shown { | ||
| 217 | at := m.cardAt(w) | 392 | at := m.cardAt(w) | ||
| 218 | - | result = append(result, w.previewRow(ry, w != cur, at < 0)...) | 393 | + | result = append(result, m.stackRow(w, ry, w != cur, at < 0)...) |
| 219 | result = append(result, w.pile(ry, w != cur, at)...) | 394 | result = append(result, w.pile(ry, w != cur, at)...) | ||
| 220 | } | 395 | } | ||
| 221 | for _, f := range w.Floats { | 396 | for _, f := range w.Floats { | ||
| 222 | - | result = append(result, Placement{ID: f.ID, Hidden: true}) | 397 | + | if _, drawn := w.itemOf(f.ID); !shown || !drawn { |
| 398 | + | result = append(result, Placement{ID: f.ID, Hidden: true}) | |||
| 399 | + | } | |||
| 223 | } | 400 | } | ||
| 224 | if !shown { | 401 | if !shown { | ||
| 225 | for _, id := range w.windows() { | 402 | for _, id := range w.windows() { | ||
| @@ -232,6 +409,72 @@ func (m *Monitor) overviewLayout() []Placement { | |||||
| 232 | return result | 409 | return result | ||
| 233 | } | 410 | } | ||
| 234 | 411 | ||||
| 412 | + | // fan places front and neighboring cards, farthest visible first and front | |||
| 413 | + | // last. A card may contain several tiles; omitted cards stay in the layout | |||
| 414 | + | // hidden so their clients are not left showing on screen. | |||
| 415 | + | func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool, | |||
| 416 | + | behind, before func(int) (int, int), dim, lit bool) []Placement { | |||
| 417 | + | n := len(cards) | |||
| 418 | + | if n == 0 { | |||
| 419 | + | return nil | |||
| 420 | + | } | |||
| 421 | + | front = min(max(front, 0), n-1) | |||
| 422 | + | out := make([]Placement, 0, n) | |||
| 423 | + | add := func(i, distance int, offset func(int) (int, int), peek bool) { | |||
| 424 | + | dx, dy := offset(distance) | |||
| 425 | + | for _, p := range cards[i] { | |||
| 426 | + | if !p.Hidden { | |||
| 427 | + | p.Rect.X += dx | |||
| 428 | + | p.Rect.Y += dy | |||
| 429 | + | p.Peek = p.Peek || peek || dim | |||
| 430 | + | p.Focused = p.Focused && !peek && !dim && lit | |||
| 431 | + | } | |||
| 432 | + | out = append(out, p) | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | shown := make([]bool, n) | |||
| 436 | + | // Behind and before are in nearest-first order; paint farthest first. | |||
| 437 | + | for d := min(maxBehind, n-1); d >= 1; d-- { | |||
| 438 | + | i := front + d | |||
| 439 | + | if circular { | |||
| 440 | + | i = (front + d) % n | |||
| 441 | + | } | |||
| 442 | + | if i >= n { | |||
| 443 | + | continue | |||
| 444 | + | } | |||
| 445 | + | add(i, d, behind, true) | |||
| 446 | + | shown[i] = true | |||
| 447 | + | } | |||
| 448 | + | for d := min(maxBefore, n-1); d >= 1; d-- { | |||
| 449 | + | i := front - d | |||
| 450 | + | if circular { | |||
| 451 | + | i = (front - d + n) % n | |||
| 452 | + | } | |||
| 453 | + | if i < 0 { | |||
| 454 | + | continue | |||
| 455 | + | } | |||
| 456 | + | if shown[i] { | |||
| 457 | + | continue | |||
| 458 | + | } | |||
| 459 | + | add(i, d, before, true) | |||
| 460 | + | shown[i] = true | |||
| 461 | + | } | |||
| 462 | + | add(front, 0, behind, false) | |||
| 463 | + | shown[front] = true | |||
| 464 | + | for i, card := range cards { | |||
| 465 | + | if shown[i] { | |||
| 466 | + | continue | |||
| 467 | + | } | |||
| 468 | + | for _, p := range card { | |||
| 469 | + | p.Hidden = true | |||
| 470 | + | p.Preview = 0 | |||
| 471 | + | p.Focused = false | |||
| 472 | + | out = append(out, p) | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | return out | |||
| 476 | + | } | |||
| 477 | + | ||||
| 235 | // pileWidth is the room the stash pile of w takes on the left of its row, | 478 | // pileWidth is the room the stash pile of w takes on the left of its row, | ||
| 236 | // its margin included; 0 without a stash. | 479 | // its margin included; 0 without a stash. | ||
| 237 | func (w *Workspace) pileWidth() int { | 480 | func (w *Workspace) pileWidth() int { | ||
| @@ -267,19 +510,17 @@ func (w *Workspace) pile(y int, dim bool, front int) []Placement { | |||||
| 267 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | 510 | cw := int(math.Round(float64(r.W) * overviewCardZoom)) | ||
| 268 | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | 511 | ch := int(math.Round(float64(r.H) * overviewCardZoom)) | ||
| 269 | rowH := int(math.Round(float64(u.H) * w.overviewZoom())) | 512 | rowH := int(math.Round(float64(u.H) * w.overviewZoom())) | ||
| 270 | - | x0, y0 := u.X+u.W/100, y+(rowH-ch)/2 | 513 | + | // Stack rows reserve two peek steps above the front card. Align the |
| 271 | - | shown := min(n, overviewCards) | 514 | + | // stash with that front band, not with the top of its envelope. |
| 272 | - | out := make([]Placement, 0, n) | 515 | + | x0, y0 := u.X+u.W/100, w.frontRowY(y)+(rowH-ch)/2 |
| 273 | - | // Farthest card first: the front one is drawn and hit on top. | 516 | + | cards := make([][]Placement, n) |
| 274 | - | for k := shown - 1; k >= 0; k-- { | 517 | + | for i := range w.Stash { |
| 275 | - | i := (front + k) % n | 518 | + | cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected}} |
| 276 | - | step := k * w.cardStep() | 519 | + | } |
| 277 | - | out = append(out, Placement{ID: w.Stash[i].ID, Rect: Rect{X: x0 + step, Y: y0 - step, W: cw, H: ch}, Preview: overviewCardZoom, Peek: dim || k > 0, Focused: k == 0 && selected}) | 520 | + | step := w.cardStep() |
| 278 | - | } | 521 | + | return fan(cards, front, min(n, overviewCards)-1, 0, true, |
| 279 | - | for k := shown; k < n; k++ { | 522 | + | func(d int) (int, int) { return d * step, -d * step }, |
| 280 | - | out = append(out, Placement{ID: w.Stash[(front+k)%n].ID, Hidden: true}) | 523 | + | func(int) (int, int) { return 0, 0 }, dim, selected) |
| 281 | - | } | |||
| 282 | - | return out | |||
| 283 | } | 524 | } | ||
| 284 | 525 | ||||
| 285 | // overviewZoom is the scale that fits every column of w in the usable | 526 | // overviewZoom is the scale that fits every column of w in the usable | ||
| @@ -290,13 +531,41 @@ func (w *Workspace) overviewZoom() float64 { | |||||
| 290 | return overviewMaxZoom | 531 | return overviewMaxZoom | ||
| 291 | } | 532 | } | ||
| 292 | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | 533 | room := float64(w.Usable.W-w.pileWidth()) * 0.96 | ||
| 293 | - | return min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) | 534 | + | z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom) |
| 535 | + | if len(w.stack()) > 1 { | |||
| 536 | + | // Reserve two clear peeks on either side of the front row. | |||
| 537 | + | z = min(z, overviewMaxZoom/(1+4*overviewPeekStep)) | |||
| 538 | + | } | |||
| 539 | + | return z | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | // peekStep is the shared step for a stack row and its front-card inset. | |||
| 543 | + | func (w *Workspace) peekStep() int { | |||
| 544 | + | return max(1, int(math.Round(float64(w.Usable.H)*w.overviewZoom()*overviewPeekStep))) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | // frontRowY skips the space reserved for two cards peeking above the front. | |||
| 548 | + | // A single-card row starts at y, as before. | |||
| 549 | + | func (w *Workspace) frontRowY(y int) int { | |||
| 550 | + | if len(w.stack()) > 1 { | |||
| 551 | + | return y + 2*w.peekStep() | |||
| 552 | + | } | |||
| 553 | + | return y | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | // rowHeight includes the largest possible stack envelope (two peeks above | |||
| 557 | + | // and two below). Neighbor rows use the same measure as the current row. | |||
| 558 | + | func (w *Workspace) rowHeight() int { | |||
| 559 | + | h := int(math.Round(float64(w.Usable.H) * w.overviewZoom())) | |||
| 560 | + | if len(w.stack()) > 1 { | |||
| 561 | + | h += 4 * w.peekStep() | |||
| 562 | + | } | |||
| 563 | + | return h | |||
| 294 | } | 564 | } | ||
| 295 | 565 | ||||
| 296 | - | // previewTiles lays the tiles of w out unscrolled, relative to the | 566 | + | // previewTiles lays tiles out unscrolled, relative to the usable area's |
| 297 | - | // usable area's corner: every column side by side at its width, as if | 567 | + | // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses |
| 298 | - | // the output were wide enough (fixed overflow's spiral included). span is | 568 | + | // an unscrolled row. span is the row's width; sel is the focused column. |
| 299 | - | // the row's width; sel is the focused column. | |||
| 300 | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | 569 | func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | ||
| 301 | g := w.gap() | 570 | g := w.gap() | ||
| 302 | h := max(w.Usable.H-2*g, 0) | 571 | h := max(w.Usable.H-2*g, 0) | ||
| @@ -307,16 +576,36 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||||
| 307 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h} | 576 | r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h} | ||
| 308 | return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r | 577 | return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r | ||
| 309 | } | 578 | } | ||
| 579 | + | var rects []Rect | |||
| 580 | + | if w.Overflow == OverflowFixed { | |||
| 581 | + | rects = w.columnRects() | |||
| 582 | + | span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile. | |||
| 583 | + | } | |||
| 584 | + | maximized := w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth | |||
| 310 | for i, c := range w.Columns { | 585 | for i, c := range w.Columns { | ||
| 311 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | 586 | r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h} | ||
| 312 | - | if w.fullscreenColumn(i) { | 587 | + | if w.Overflow == OverflowFixed { |
| 588 | + | r = rects[i] | |||
| 589 | + | r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y | |||
| 590 | + | } else if w.fullscreenColumn(i) { | |||
| 313 | // A fullscreen column shows at the width it had in the row. | 591 | // A fullscreen column shows at the width it had in the row. | ||
| 314 | r.W = max(w.Usable.W-2*g, 0) | 592 | r.W = max(w.Usable.W-2*g, 0) | ||
| 315 | } | 593 | } | ||
| 594 | + | if maximized && i != w.Focus { | |||
| 595 | + | // FullWidth hides other columns on screen. Their old buffers | |||
| 596 | + | // cannot fit their current spiral rects; show only the maximized | |||
| 597 | + | // column until navigation restores the normal layout. | |||
| 598 | + | for _, id := range c.Windows { | |||
| 599 | + | tiles = append(tiles, Placement{ID: id, Hidden: true}) | |||
| 600 | + | } | |||
| 601 | + | continue | |||
| 602 | + | } | |||
| 316 | for j, t := range stackRects(r, len(c.Windows), g) { | 603 | for j, t := range stackRects(r, len(c.Windows), g) { | ||
| 317 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | 604 | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus}) | ||
| 318 | } | 605 | } | ||
| 319 | - | span = max(span, r.X+r.W+g) | 606 | + | if w.Overflow != OverflowFixed { |
| 607 | + | span = max(span, r.X+r.W+g) | |||
| 608 | + | } | |||
| 320 | if i == w.Focus { | 609 | if i == w.Focus { | ||
| 321 | sel = r | 610 | sel = r | ||
| 322 | } | 611 | } | ||
| @@ -331,18 +620,27 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) { | |||||
| 331 | // false). | 620 | // false). | ||
| 332 | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | 621 | func (w *Workspace) previewRow(y int, dim, lit bool) []Placement { | ||
| 333 | tiles, span, sel := w.previewTiles() | 622 | tiles, span, sel := w.previewTiles() | ||
| 623 | + | return w.previewRowTiles(y, dim, lit, tiles, span, sel) | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | func (w *Workspace) previewRowTiles(y int, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement { | |||
| 334 | u := w.Usable | 627 | u := w.Usable | ||
| 335 | - | left := w.pileWidth() | |||
| 336 | - | u.X, u.W = u.X+left, u.W-left | |||
| 337 | z := w.overviewZoom() | 628 | z := w.overviewZoom() | ||
| 338 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | 629 | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | ||
| 339 | width := scale(span) | 630 | width := scale(span) | ||
| 340 | - | x := u.X + (u.W-width)/2 | 631 | + | // Rows stay centred on the output like rows without a stash; the pile |
| 632 | + | // only pushes a row right when they would overlap. | |||
| 633 | + | x := max(u.X+(u.W-width)/2, u.X+w.pileWidth()) | |||
| 634 | + | left := w.pileWidth() | |||
| 635 | + | u.X, u.W = u.X+left, u.W-left | |||
| 341 | if width > u.W { | 636 | if width > u.W { | ||
| 342 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | 637 | x = u.X + u.W/2 - scale(sel.X+sel.W/2) | ||
| 343 | x = min(max(x, u.X+u.W-width), u.X) | 638 | x = min(max(x, u.X+u.W-width), u.X) | ||
| 344 | } | 639 | } | ||
| 345 | for i := range tiles { | 640 | for i := range tiles { | ||
| 641 | + | if tiles[i].Hidden { | |||
| 642 | + | continue | |||
| 643 | + | } | |||
| 346 | r := tiles[i].Rect | 644 | r := tiles[i].Rect | ||
| 347 | x0, y0 := scale(r.X), scale(r.Y) | 645 | x0, y0 := scale(r.X), scale(r.Y) | ||
| 348 | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | 646 | tiles[i].Rect = Rect{X: x + x0, Y: y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0} | ||
| @@ -369,9 +667,18 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||||
| 369 | return nil | 667 | return nil | ||
| 370 | } | 668 | } | ||
| 371 | 669 | ||||
| 372 | - | // overviewAt is the preview under the output-local point, if any. | 670 | + | // overviewAt is the preview under the output-local point, if any. Front |
| 671 | + | // cards take priority over peeks, including peeks appended after a front | |||
| 672 | + | // tile in another row's layout. | |||
| 373 | func (m *Monitor) overviewAt(x, y float64) WindowID { | 673 | func (m *Monitor) overviewAt(x, y float64) WindowID { | ||
| 374 | layout := m.Layout() | 674 | layout := m.Layout() | ||
| 675 | + | for i := len(layout) - 1; i >= 0; i-- { | |||
| 676 | + | p := layout[i] | |||
| 677 | + | r := p.Rect | |||
| 678 | + | if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |||
| 679 | + | return p.ID | |||
| 680 | + | } | |||
| 681 | + | } | |||
| 375 | for i := len(layout) - 1; i >= 0; i-- { | 682 | for i := len(layout) - 1; i >= 0; i-- { | ||
| 376 | p := layout[i] | 683 | p := layout[i] | ||
| 377 | r := p.Rect | 684 | r := p.Rect | ||
| @@ -391,7 +698,7 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 391 | } | 698 | } | ||
| 392 | i := c.screenIndex(o.Info.Name) | 699 | i := c.screenIndex(o.Info.Name) | ||
| 393 | sc := c.screens[i] | 700 | sc := c.screens[i] | ||
| 394 | - | if !sc.mon.overview { | 701 | + | if !sc.mon.ov.open { |
| 395 | return false, nil | 702 | return false, nil | ||
| 396 | } | 703 | } | ||
| 397 | id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y)) | 704 | id := sc.mon.overviewAt(c.cursorX-float64(o.X), c.cursorY-float64(o.Y)) | ||
| @@ -410,16 +717,32 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 410 | // overviewSwipe moves the selection like the keys: a workspace swipe | 717 | // overviewSwipe moves the selection like the keys: a workspace swipe | ||
| 411 | // must not enter the empty workspace below the last one. | 718 | // must not enter the empty workspace below the last one. | ||
| 412 | func (m *Monitor) overviewSwipe(a Action) { | 719 | func (m *Monitor) overviewSwipe(a Action) { | ||
| 720 | + | switch a { | |||
| 721 | + | case ActionFocusWorkspaceUp: | |||
| 722 | + | a = ActionFocusWindowUp | |||
| 723 | + | case ActionFocusWorkspaceDown: | |||
| 724 | + | a = ActionFocusWindowDown | |||
| 725 | + | } | |||
| 726 | + | m.overviewFocus(a) | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | // overviewFocus runs a focus bind as an overview move, so the focus binds | |||
| 730 | + | // and the bare keys navigate the overview the same way: true when a was | |||
| 731 | + | // one of them. | |||
| 732 | + | func (m *Monitor) overviewFocus(a Action) bool { | |||
| 413 | switch a { | 733 | switch a { | ||
| 414 | case ActionFocusColumnLeft: | 734 | case ActionFocusColumnLeft: | ||
| 415 | m.OverviewMove(-1, 0) | 735 | m.OverviewMove(-1, 0) | ||
| 416 | case ActionFocusColumnRight: | 736 | case ActionFocusColumnRight: | ||
| 417 | m.OverviewMove(1, 0) | 737 | m.OverviewMove(1, 0) | ||
| 418 | - | case ActionFocusWorkspaceUp: | 738 | + | case ActionFocusWindowUp: |
| 419 | m.OverviewMove(0, -1) | 739 | m.OverviewMove(0, -1) | ||
| 420 | - | case ActionFocusWorkspaceDown: | 740 | + | case ActionFocusWindowDown: |
| 421 | m.OverviewMove(0, 1) | 741 | m.OverviewMove(0, 1) | ||
| 742 | + | default: | |||
| 743 | + | return false | |||
| 422 | } | 744 | } | ||
| 745 | + | return true | |||
| 423 | } | 746 | } | ||
| 424 | 747 | ||||
| 425 | // overviewScrollStep is the two-finger scroll distance, in libinput's | 748 | // overviewScrollStep is the two-finger scroll distance, in libinput's | ||
| @@ -429,13 +752,12 @@ const overviewScrollStep = 60 | |||||
| 429 | // overviewScroll moves the overview selection with a scroll frame: two | 752 | // overviewScroll moves the overview selection with a scroll frame: two | ||
| 430 | // fingers step once per overviewScrollStep on the axis they move most | 753 | // fingers step once per overviewScrollStep on the axis they move most | ||
| 431 | // along, a wheel once per notch (high-resolution wheels add up their | 754 | // along, a wheel once per notch (high-resolution wheels add up their | ||
| 432 | - | // fractions of a notch). Scrolling down or right selects the workspace | 755 | + | // fractions of a notch). Vertical steps select cards first, then rows; |
| 433 | - | // below or the column on the right; touchpad.natural-scroll flips both, | 756 | + | // touchpad.natural-scroll flips both axes as libinput reports. |
| 434 | - | // as libinput reports. changed is false while the distance adds up. | |||
| 435 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | 757 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | ||
| 436 | if a.Vertical.Stop || a.Horizontal.Stop { | 758 | if a.Vertical.Stop || a.Horizontal.Stop { | ||
| 437 | // Fingers lifted: the next scroll starts from zero. | 759 | // Fingers lifted: the next scroll starts from zero. | ||
| 438 | - | m.scrollX, m.scrollY = 0, 0 | 760 | + | m.ov.scrollX, m.ov.scrollY = 0, 0 |
| 439 | return false | 761 | return false | ||
| 440 | } | 762 | } | ||
| 441 | add := func(acc *float64, ax ports.ScrollAxis) { | 763 | add := func(acc *float64, ax ports.ScrollAxis) { | ||
| @@ -447,18 +769,18 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||||
| 447 | *acc += ax.Value | 769 | *acc += ax.Value | ||
| 448 | } | 770 | } | ||
| 449 | } | 771 | } | ||
| 450 | - | add(&m.scrollY, a.Vertical) | 772 | + | add(&m.ov.scrollY, a.Vertical) |
| 451 | - | add(&m.scrollX, a.Horizontal) | 773 | + | add(&m.ov.scrollX, a.Horizontal) |
| 452 | for { | 774 | for { | ||
| 453 | switch { | 775 | switch { | ||
| 454 | - | case math.Abs(m.scrollY) >= overviewScrollStep && math.Abs(m.scrollY) >= math.Abs(m.scrollX): | 776 | + | case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX): |
| 455 | - | m.OverviewMove(0, sign(m.scrollY)) | 777 | + | m.OverviewMove(0, sign(m.ov.scrollY)) |
| 456 | - | m.scrollY -= float64(sign(m.scrollY)) * overviewScrollStep | 778 | + | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep |
| 457 | - | m.scrollX = 0 | 779 | + | m.ov.scrollX = 0 |
| 458 | - | case math.Abs(m.scrollX) >= overviewScrollStep: | 780 | + | case math.Abs(m.ov.scrollX) >= overviewScrollStep: |
| 459 | - | m.OverviewMove(sign(m.scrollX), 0) | 781 | + | m.OverviewMove(sign(m.ov.scrollX), 0) |
| 460 | - | m.scrollX -= float64(sign(m.scrollX)) * overviewScrollStep | 782 | + | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * overviewScrollStep |
| 461 | - | m.scrollY = 0 | 783 | + | m.ov.scrollY = 0 |
| 462 | default: | 784 | default: | ||
| 463 | return changed | 785 | return changed | ||
| 464 | } | 786 | } | ||
A internal/core/overview_behavior_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestOverviewVerticalBoundariesAndInputs(t *testing.T) { | |
| 12 | + | m := stackMonitor() | |
| 13 | + | w := m.Current() | |
| 14 | + | m.ToggleOverview() | |
| 15 | + | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | |
| 16 | + | t.Fatal("screen front") | |
| 17 | + | } | |
| 18 | + | m.overviewSwipe(ActionFocusWorkspaceUp) | |
| 19 | + | if m.stackFront(w).kind != stackColumns || m.Active != 0 { | |
| 20 | + | t.Fatal("swipe did not visit card") | |
| 21 | + | } | |
| 22 | + | m.overviewSwipe(ActionFocusWorkspaceUp) | |
| 23 | + | if m.Active != 0 { | |
| 24 | + | t.Fatal("up at top crossed workspace boundary") | |
| 25 | + | } | |
| 26 | + | m.overviewSwipe(ActionFocusWorkspaceDown) | |
| 27 | + | if m.stackFront(w).kind != stackFloat || m.Active != 0 { | |
| 28 | + | t.Fatal("down did not return to front") | |
| 29 | + | } | |
| 30 | + | m.OverviewMove(0, 1) | |
| 31 | + | if m.Active != 1 { | |
| 32 | + | t.Fatal("down did not reach workspace below") | |
| 33 | + | } | |
| 34 | + | m.OverviewMove(0, -1) | |
| 35 | + | if m.Active != 0 || m.stackFront(w).kind != stackFloat { | |
| 36 | + | t.Fatal("row change kept stack cursor") | |
| 37 | + | } | |
| 38 | + | if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: -120}}) || m.stackFront(w).kind != stackColumns { | |
| 39 | + | t.Fatal("wheel did not visit card") | |
| 40 | + | } | |
| 41 | + | m.CancelOverview() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestOverviewPeekEnvelopeAndSingleCardHorizontal(t *testing.T) { | |
| 45 | + | m := stackMonitor() | |
| 46 | + | w := m.Current() | |
| 47 | + | w.AddFloating(8, 300, 200) | |
| 48 | + | w.AddFloating(7, 300, 200) | |
| 49 | + | m.ToggleOverview() | |
| 50 | + | front := previewOf(t, m.Layout(), 7) | |
| 51 | + | back := previewOf(t, m.Layout(), 8) | |
| 52 | + | step := float64(front.Rect.Y - back.Rect.Y) | |
| 53 | + | if step < float64(w.Usable.H)*w.overviewZoom()*0.06 { | |
| 54 | + | t.Fatalf("peek step %.1f", step) | |
| 55 | + | } | |
| 56 | + | y := w.Usable.Y + (w.Usable.H-w.rowHeight())/2 | |
| 57 | + | if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+w.rowHeight() { | |
| 58 | + | t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+w.rowHeight()) | |
| 59 | + | } | |
| 60 | + | m.OverviewMove(1, 0) | |
| 61 | + | if m.stackFront(w) != (stackItem{stackFloat, 7}) { | |
| 62 | + | t.Fatal("right moved single card") | |
| 63 | + | } | |
| 64 | + | m.OverviewMove(-1, 0) | |
| 65 | + | if m.stackFront(w) != (stackItem{stackFloat, 8}) { | |
| 66 | + | t.Fatal("left did not send single card behind") | |
| 67 | + | } | |
| 68 | + | m.CancelOverview() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // The stash front is centred on the same row band as the stack's front, | |
| 72 | + | // whether that row has peeking cards or just one column-group card. | |
| 73 | + | func TestOverviewPileAlignedWithRowFront(t *testing.T) { | |
| 74 | + | for _, stacked := range []bool{false, true} { | |
| 75 | + | m := pileMonitor() | |
| 76 | + | w := m.Current() | |
| 77 | + | if stacked { | |
| 78 | + | m.SetBorder(2) | |
| 79 | + | w.AddFloating(9, 300, 200) | |
| 80 | + | } | |
| 81 | + | m.ToggleOverview() | |
| 82 | + | ps := m.Layout() | |
| 83 | + | pile := previewOf(t, ps, 7) | |
| 84 | + | frontID := WindowID(1) | |
| 85 | + | if stacked { | |
| 86 | + | frontID = 9 | |
| 87 | + | } | |
| 88 | + | front := previewOf(t, ps, frontID) | |
| 89 | + | pileCentre := float64(pile.Rect.Y) + float64(pile.Rect.H)/2 | |
| 90 | + | frontBandCentre := float64(front.Rect.Y) + float64(w.Usable.H)*front.Preview/2 | |
| 91 | + | if math.Abs(pileCentre-frontBandCentre) > 1.5 { | |
| 92 | + | t.Fatalf("stacked %v: pile centre %.1f, front band centre %.1f: pile %+v front %+v", stacked, pileCentre, frontBandCentre, pile, front) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestOverviewSingleCardLeftFallsBackToStash(t *testing.T) { | |
| 98 | + | m := stackMonitor() | |
| 99 | + | w := m.Current() | |
| 100 | + | w.FocusID(1) | |
| 101 | + | w.ToggleWindowStash() | |
| 102 | + | w.RemoveWindow(2) | |
| 103 | + | w.RemoveWindow(3) | |
| 104 | + | // A covering float is the only stack item; stash stays independently reachable. | |
| 105 | + | m.ToggleOverview() | |
| 106 | + | m.OverviewMove(-1, 0) | |
| 107 | + | if m.cardAt(w) < 0 { | |
| 108 | + | t.Fatal("left with no card behind did not enter stash") | |
| 109 | + | } | |
| 110 | + | m.CancelOverview() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestOverviewStackMRUAndSize(t *testing.T) { | |
| 114 | + | m := maximizedOverview() | |
| 115 | + | w := m.Current() | |
| 116 | + | m.ToggleOverview() | |
| 117 | + | m.OverviewMove(0, -1) | |
| 118 | + | m.ToggleOverview() | |
| 119 | + | m.ToggleOverview() | |
| 120 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 1}, {kind: stackColumns}}) { | |
| 121 | + | t.Fatalf("stack %v", got) | |
| 122 | + | } | |
| 123 | + | if m.hiddenColumn(w) != 1 { | |
| 124 | + | t.Fatalf("MRU preselection %d, want Firefox column", m.hiddenColumn(w)) | |
| 125 | + | } | |
| 126 | + | if p := previewOf(t, m.Layout(), 2); math.Abs(float64(p.Rect.W)-float64(w.columnRectsFor(true)[1].W)*p.Preview) > 1 { | |
| 127 | + | t.Fatalf("prior maximized buffer not fitted to cell: %+v", p) | |
| 128 | + | } | |
| 129 | + | m.CancelOverview() | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestOverviewVerticalBoundariesAndInputs(t *testing.T) { | |||
| 12 | + | m := stackMonitor() | |||
| 13 | + | w := m.Current() | |||
| 14 | + | m.ToggleOverview() | |||
| 15 | + | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | |||
| 16 | + | t.Fatal("screen front") | |||
| 17 | + | } | |||
| 18 | + | m.overviewSwipe(ActionFocusWorkspaceUp) | |||
| 19 | + | if m.stackFront(w).kind != stackColumns || m.Active != 0 { | |||
| 20 | + | t.Fatal("swipe did not visit card") | |||
| 21 | + | } | |||
| 22 | + | m.overviewSwipe(ActionFocusWorkspaceUp) | |||
| 23 | + | if m.Active != 0 { | |||
| 24 | + | t.Fatal("up at top crossed workspace boundary") | |||
| 25 | + | } | |||
| 26 | + | m.overviewSwipe(ActionFocusWorkspaceDown) | |||
| 27 | + | if m.stackFront(w).kind != stackFloat || m.Active != 0 { | |||
| 28 | + | t.Fatal("down did not return to front") | |||
| 29 | + | } | |||
| 30 | + | m.OverviewMove(0, 1) | |||
| 31 | + | if m.Active != 1 { | |||
| 32 | + | t.Fatal("down did not reach workspace below") | |||
| 33 | + | } | |||
| 34 | + | m.OverviewMove(0, -1) | |||
| 35 | + | if m.Active != 0 || m.stackFront(w).kind != stackFloat { | |||
| 36 | + | t.Fatal("row change kept stack cursor") | |||
| 37 | + | } | |||
| 38 | + | if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: -120}}) || m.stackFront(w).kind != stackColumns { | |||
| 39 | + | t.Fatal("wheel did not visit card") | |||
| 40 | + | } | |||
| 41 | + | m.CancelOverview() | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestOverviewPeekEnvelopeAndSingleCardHorizontal(t *testing.T) { | |||
| 45 | + | m := stackMonitor() | |||
| 46 | + | w := m.Current() | |||
| 47 | + | w.AddFloating(8, 300, 200) | |||
| 48 | + | w.AddFloating(7, 300, 200) | |||
| 49 | + | m.ToggleOverview() | |||
| 50 | + | front := previewOf(t, m.Layout(), 7) | |||
| 51 | + | back := previewOf(t, m.Layout(), 8) | |||
| 52 | + | step := float64(front.Rect.Y - back.Rect.Y) | |||
| 53 | + | if step < float64(w.Usable.H)*w.overviewZoom()*0.06 { | |||
| 54 | + | t.Fatalf("peek step %.1f", step) | |||
| 55 | + | } | |||
| 56 | + | y := w.Usable.Y + (w.Usable.H-w.rowHeight())/2 | |||
| 57 | + | if back.Rect.Y < y || front.Rect.Y+front.Rect.H > y+w.rowHeight() { | |||
| 58 | + | t.Fatalf("outside row front %+v back %+v row %d..%d", front, back, y, y+w.rowHeight()) | |||
| 59 | + | } | |||
| 60 | + | m.OverviewMove(1, 0) | |||
| 61 | + | if m.stackFront(w) != (stackItem{stackFloat, 7}) { | |||
| 62 | + | t.Fatal("right moved single card") | |||
| 63 | + | } | |||
| 64 | + | m.OverviewMove(-1, 0) | |||
| 65 | + | if m.stackFront(w) != (stackItem{stackFloat, 8}) { | |||
| 66 | + | t.Fatal("left did not send single card behind") | |||
| 67 | + | } | |||
| 68 | + | m.CancelOverview() | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // The stash front is centred on the same row band as the stack's front, | |||
| 72 | + | // whether that row has peeking cards or just one column-group card. | |||
| 73 | + | func TestOverviewPileAlignedWithRowFront(t *testing.T) { | |||
| 74 | + | for _, stacked := range []bool{false, true} { | |||
| 75 | + | m := pileMonitor() | |||
| 76 | + | w := m.Current() | |||
| 77 | + | if stacked { | |||
| 78 | + | m.SetBorder(2) | |||
| 79 | + | w.AddFloating(9, 300, 200) | |||
| 80 | + | } | |||
| 81 | + | m.ToggleOverview() | |||
| 82 | + | ps := m.Layout() | |||
| 83 | + | pile := previewOf(t, ps, 7) | |||
| 84 | + | frontID := WindowID(1) | |||
| 85 | + | if stacked { | |||
| 86 | + | frontID = 9 | |||
| 87 | + | } | |||
| 88 | + | front := previewOf(t, ps, frontID) | |||
| 89 | + | pileCentre := float64(pile.Rect.Y) + float64(pile.Rect.H)/2 | |||
| 90 | + | frontBandCentre := float64(front.Rect.Y) + float64(w.Usable.H)*front.Preview/2 | |||
| 91 | + | if math.Abs(pileCentre-frontBandCentre) > 1.5 { | |||
| 92 | + | t.Fatalf("stacked %v: pile centre %.1f, front band centre %.1f: pile %+v front %+v", stacked, pileCentre, frontBandCentre, pile, front) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestOverviewSingleCardLeftFallsBackToStash(t *testing.T) { | |||
| 98 | + | m := stackMonitor() | |||
| 99 | + | w := m.Current() | |||
| 100 | + | w.FocusID(1) | |||
| 101 | + | w.ToggleWindowStash() | |||
| 102 | + | w.RemoveWindow(2) | |||
| 103 | + | w.RemoveWindow(3) | |||
| 104 | + | // A covering float is the only stack item; stash stays independently reachable. | |||
| 105 | + | m.ToggleOverview() | |||
| 106 | + | m.OverviewMove(-1, 0) | |||
| 107 | + | if m.cardAt(w) < 0 { | |||
| 108 | + | t.Fatal("left with no card behind did not enter stash") | |||
| 109 | + | } | |||
| 110 | + | m.CancelOverview() | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestOverviewStackMRUAndSize(t *testing.T) { | |||
| 114 | + | m := maximizedOverview() | |||
| 115 | + | w := m.Current() | |||
| 116 | + | m.ToggleOverview() | |||
| 117 | + | m.OverviewMove(0, -1) | |||
| 118 | + | m.ToggleOverview() | |||
| 119 | + | m.ToggleOverview() | |||
| 120 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 1}, {kind: stackColumns}}) { | |||
| 121 | + | t.Fatalf("stack %v", got) | |||
| 122 | + | } | |||
| 123 | + | if m.hiddenColumn(w) != 1 { | |||
| 124 | + | t.Fatalf("MRU preselection %d, want Firefox column", m.hiddenColumn(w)) | |||
| 125 | + | } | |||
| 126 | + | if p := previewOf(t, m.Layout(), 2); math.Abs(float64(p.Rect.W)-float64(w.columnRectsFor(true)[1].W)*p.Preview) > 1 { | |||
| 127 | + | t.Fatalf("prior maximized buffer not fitted to cell: %+v", p) | |||
| 128 | + | } | |||
| 129 | + | m.CancelOverview() | |||
| 130 | + | } | |||
A internal/core/overview_configure_test.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A neighbor workspace is visible only as an overview preview; its real | |
| 11 | + | // placement on the output is hidden. Its last configure must not be resized. | |
| 12 | + | func TestOverviewNeighborPreviewKeepsHiddenConfigureSize(t *testing.T) { | |
| 13 | + | cfg := ports.Config{} | |
| 14 | + | cfg.Keyboard.CmdKey = "super" | |
| 15 | + | cfg.Layout.MaxColumns = 3 | |
| 16 | + | commands := make(chan ports.ClientCommand, 256) | |
| 17 | + | scenes := make(chan []ports.Scene, 1) | |
| 18 | + | c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400}) | |
| 23 | + | m := c.cur().mon | |
| 24 | + | m.AddWindow(1) | |
| 25 | + | m.Focus(1) | |
| 26 | + | m.AddWindow(2) | |
| 27 | + | ctx := context.Background() | |
| 28 | + | publish := func() []ports.ConfigureWindow { | |
| 29 | + | t.Helper() | |
| 30 | + | if err := c.publish(ctx); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | <-scenes | |
| 34 | + | var sent []ports.ConfigureWindow | |
| 35 | + | for len(commands) > 0 { | |
| 36 | + | if v, ok := (<-commands).(ports.ConfigureWindow); ok { | |
| 37 | + | sent = append(sent, v) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | return sent | |
| 41 | + | } | |
| 42 | + | publish() // establish a sized configure while workspace 2 is on screen | |
| 43 | + | before := c.configures.sent[2] | |
| 44 | + | if before.Width == 0 || before.Height == 0 { | |
| 45 | + | t.Fatalf("missing prior size: %+v", before) | |
| 46 | + | } | |
| 47 | + | m.Focus(0) | |
| 48 | + | publish() // the workspace is hidden on the output | |
| 49 | + | if c.configures.sent[2].Visible { | |
| 50 | + | t.Fatal("neighbor was not hidden before overview") | |
| 51 | + | } | |
| 52 | + | m.ToggleOverview() | |
| 53 | + | preview := previewOf(t, m.Layout(), 2) | |
| 54 | + | if preview.Hidden || preview.Preview <= 0 { | |
| 55 | + | t.Fatalf("neighbor not shown as preview: %+v", preview) | |
| 56 | + | } | |
| 57 | + | for _, v := range publish() { | |
| 58 | + | if v.ID == 2 && (v.Width != before.Width || v.Height != before.Height) { | |
| 59 | + | t.Fatalf("hidden neighbor resized from %+v to %+v", before, v) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | got := c.configures.sent[2] | |
| 63 | + | if got.Width != before.Width || got.Height != before.Height { | |
| 64 | + | t.Fatalf("neighbor size from %+v to %+v", before, got) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestOverviewConfigureUsesRealLayoutAfterMaximize(t *testing.T) { | |
| 69 | + | cfg := ports.Config{} | |
| 70 | + | cfg.Keyboard.CmdKey = "super" | |
| 71 | + | cfg.Layout.Overflow = "fixed" | |
| 72 | + | cfg.Layout.MaxColumns = 3 | |
| 73 | + | commands := make(chan ports.ClientCommand, 256) | |
| 74 | + | scenes := make(chan []ports.Scene, 1) | |
| 75 | + | c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}) | |
| 76 | + | if err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400}) | |
| 80 | + | m := c.cur().mon | |
| 81 | + | for id := WindowID(1); id <= 3; id++ { | |
| 82 | + | m.AddWindow(id) | |
| 83 | + | } | |
| 84 | + | ctx := context.Background() | |
| 85 | + | publish := func() []ports.ConfigureWindow { | |
| 86 | + | t.Helper() | |
| 87 | + | if err := c.publish(ctx); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | <-scenes | |
| 91 | + | var out []ports.ConfigureWindow | |
| 92 | + | for len(commands) > 0 { | |
| 93 | + | if v, ok := (<-commands).(ports.ConfigureWindow); ok { | |
| 94 | + | out = append(out, v) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | return out | |
| 98 | + | } | |
| 99 | + | publish() | |
| 100 | + | before := c.configures.sent[3] | |
| 101 | + | m.ToggleOverview() | |
| 102 | + | publish() | |
| 103 | + | m.Current().ToggleFullWidth() // a real workspace mutation, not an overview bind | |
| 104 | + | real := previewOf(t, m.Current().Layout(), 3) | |
| 105 | + | expected := c.clientRect(real) | |
| 106 | + | ps := m.Layout() | |
| 107 | + | preview := previewOf(t, ps, 3) | |
| 108 | + | if preview.Preview == 0 || expected.W <= before.Width { | |
| 109 | + | t.Fatalf("setup before %+v expected %+v preview %+v", before, expected, preview) | |
| 110 | + | } | |
| 111 | + | got := publish() | |
| 112 | + | found := false | |
| 113 | + | for _, v := range got { | |
| 114 | + | if v.ID == 3 { | |
| 115 | + | found = true | |
| 116 | + | if v.Width != expected.W || v.Height != expected.H { | |
| 117 | + | t.Fatalf("preview sized configure %+v want %dx%d", v, expected.W, expected.H) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | if !found { | |
| 122 | + | t.Fatalf("no immediate resize while overview open; configures %v", got) | |
| 123 | + | } | |
| 124 | + | } | |
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A neighbor workspace is visible only as an overview preview; its real | |||
| 11 | + | // placement on the output is hidden. Its last configure must not be resized. | |||
| 12 | + | func TestOverviewNeighborPreviewKeepsHiddenConfigureSize(t *testing.T) { | |||
| 13 | + | cfg := ports.Config{} | |||
| 14 | + | cfg.Keyboard.CmdKey = "super" | |||
| 15 | + | cfg.Layout.MaxColumns = 3 | |||
| 16 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 17 | + | scenes := make(chan []ports.Scene, 1) | |||
| 18 | + | c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400}) | |||
| 23 | + | m := c.cur().mon | |||
| 24 | + | m.AddWindow(1) | |||
| 25 | + | m.Focus(1) | |||
| 26 | + | m.AddWindow(2) | |||
| 27 | + | ctx := context.Background() | |||
| 28 | + | publish := func() []ports.ConfigureWindow { | |||
| 29 | + | t.Helper() | |||
| 30 | + | if err := c.publish(ctx); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | <-scenes | |||
| 34 | + | var sent []ports.ConfigureWindow | |||
| 35 | + | for len(commands) > 0 { | |||
| 36 | + | if v, ok := (<-commands).(ports.ConfigureWindow); ok { | |||
| 37 | + | sent = append(sent, v) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | return sent | |||
| 41 | + | } | |||
| 42 | + | publish() // establish a sized configure while workspace 2 is on screen | |||
| 43 | + | before := c.configures.sent[2] | |||
| 44 | + | if before.Width == 0 || before.Height == 0 { | |||
| 45 | + | t.Fatalf("missing prior size: %+v", before) | |||
| 46 | + | } | |||
| 47 | + | m.Focus(0) | |||
| 48 | + | publish() // the workspace is hidden on the output | |||
| 49 | + | if c.configures.sent[2].Visible { | |||
| 50 | + | t.Fatal("neighbor was not hidden before overview") | |||
| 51 | + | } | |||
| 52 | + | m.ToggleOverview() | |||
| 53 | + | preview := previewOf(t, m.Layout(), 2) | |||
| 54 | + | if preview.Hidden || preview.Preview <= 0 { | |||
| 55 | + | t.Fatalf("neighbor not shown as preview: %+v", preview) | |||
| 56 | + | } | |||
| 57 | + | for _, v := range publish() { | |||
| 58 | + | if v.ID == 2 && (v.Width != before.Width || v.Height != before.Height) { | |||
| 59 | + | t.Fatalf("hidden neighbor resized from %+v to %+v", before, v) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | got := c.configures.sent[2] | |||
| 63 | + | if got.Width != before.Width || got.Height != before.Height { | |||
| 64 | + | t.Fatalf("neighbor size from %+v to %+v", before, got) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestOverviewConfigureUsesRealLayoutAfterMaximize(t *testing.T) { | |||
| 69 | + | cfg := ports.Config{} | |||
| 70 | + | cfg.Keyboard.CmdKey = "super" | |||
| 71 | + | cfg.Layout.Overflow = "fixed" | |||
| 72 | + | cfg.Layout.MaxColumns = 3 | |||
| 73 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 74 | + | scenes := make(chan []ports.Scene, 1) | |||
| 75 | + | c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}) | |||
| 76 | + | if err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 600, Height: 400}) | |||
| 80 | + | m := c.cur().mon | |||
| 81 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 82 | + | m.AddWindow(id) | |||
| 83 | + | } | |||
| 84 | + | ctx := context.Background() | |||
| 85 | + | publish := func() []ports.ConfigureWindow { | |||
| 86 | + | t.Helper() | |||
| 87 | + | if err := c.publish(ctx); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | <-scenes | |||
| 91 | + | var out []ports.ConfigureWindow | |||
| 92 | + | for len(commands) > 0 { | |||
| 93 | + | if v, ok := (<-commands).(ports.ConfigureWindow); ok { | |||
| 94 | + | out = append(out, v) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | return out | |||
| 98 | + | } | |||
| 99 | + | publish() | |||
| 100 | + | before := c.configures.sent[3] | |||
| 101 | + | m.ToggleOverview() | |||
| 102 | + | publish() | |||
| 103 | + | m.Current().ToggleFullWidth() // a real workspace mutation, not an overview bind | |||
| 104 | + | real := previewOf(t, m.Current().Layout(), 3) | |||
| 105 | + | expected := c.clientRect(real) | |||
| 106 | + | ps := m.Layout() | |||
| 107 | + | preview := previewOf(t, ps, 3) | |||
| 108 | + | if preview.Preview == 0 || expected.W <= before.Width { | |||
| 109 | + | t.Fatalf("setup before %+v expected %+v preview %+v", before, expected, preview) | |||
| 110 | + | } | |||
| 111 | + | got := publish() | |||
| 112 | + | found := false | |||
| 113 | + | for _, v := range got { | |||
| 114 | + | if v.ID == 3 { | |||
| 115 | + | found = true | |||
| 116 | + | if v.Width != expected.W || v.Height != expected.H { | |||
| 117 | + | t.Fatalf("preview sized configure %+v want %dx%d", v, expected.W, expected.H) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | if !found { | |||
| 122 | + | t.Fatalf("no immediate resize while overview open; configures %v", got) | |||
| 123 | + | } | |||
| 124 | + | } | |||
M internal/core/overview_input_test.go +125 -0
| @@ -61,6 +61,131 @@ func TestOverviewLauncherKeepsKeys(t *testing.T) { | |||
| 61 | 61 | }) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | // Focus binds (cmd+arrows and their Vim twins) navigate the overview over | |
| 65 | + | // a covering float: cmd+k/j round-trips through the columns card, then | |
| 66 | + | // cmd+up brings it forward, cmd+left selects a column and cmd+down returns. | |
| 67 | + | func TestOverviewFocusBindsNavigate(t *testing.T) { | |
| 68 | + | cfg := config.Defaults() | |
| 69 | + | client := make(chan ports.ClientEvent, 8) | |
| 70 | + | input := make(chan ports.InputEvent, 8) | |
| 71 | + | output := make(chan ports.OutputEvent, 8) | |
| 72 | + | commands := make(chan ports.ClientCommand, 256) | |
| 73 | + | scenes := make(chan []ports.Scene, 1) | |
| 74 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 79 | + | defer cancel() | |
| 80 | + | go c.Run(ctx) | |
| 81 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |
| 82 | + | scene(t, scenes) | |
| 83 | + | for id := ports.WindowID(1); id <= 2; id++ { | |
| 84 | + | client <- ports.WindowMapped{ID: id} | |
| 85 | + | scene(t, scenes) | |
| 86 | + | } | |
| 87 | + | client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200} | |
| 88 | + | scene(t, scenes) | |
| 89 | + | focused := func(id ports.WindowID) func(ports.Scene) bool { | |
| 90 | + | return func(s ports.Scene) bool { | |
| 91 | + | for _, w := range s.Windows { | |
| 92 | + | if w.ID == id && w.Focused && w.Preview > 0 && w.Dim == 0 { | |
| 93 | + | return true | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | press := func(key string) { | |
| 100 | + | input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper, Pressed: true} | |
| 101 | + | input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper} | |
| 102 | + | } | |
| 103 | + | press("o") | |
| 104 | + | sceneMatch(t, scenes, focused(9)) | |
| 105 | + | press("k") | |
| 106 | + | sceneMatch(t, scenes, focused(2)) | |
| 107 | + | press("j") | |
| 108 | + | sceneMatch(t, scenes, focused(9)) | |
| 109 | + | press("Up") | |
| 110 | + | sceneMatch(t, scenes, focused(2)) | |
| 111 | + | press("Left") | |
| 112 | + | sceneMatch(t, scenes, focused(1)) | |
| 113 | + | press("Down") | |
| 114 | + | sceneMatch(t, scenes, focused(9)) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Closing through a real bind follows the preview, not the covering float. | |
| 118 | + | func TestOverviewCloseBindTargetsPreview(t *testing.T) { | |
| 119 | + | cfg := config.Defaults() | |
| 120 | + | client := make(chan ports.ClientEvent, 8) | |
| 121 | + | input := make(chan ports.InputEvent, 8) | |
| 122 | + | output := make(chan ports.OutputEvent, 8) | |
| 123 | + | commands := make(chan ports.ClientCommand, 256) | |
| 124 | + | scenes := make(chan []ports.Scene, 1) | |
| 125 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 130 | + | defer cancel() | |
| 131 | + | go c.Run(ctx) | |
| 132 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |
| 133 | + | scene(t, scenes) | |
| 134 | + | for _, id := range []ports.WindowID{1, 2} { | |
| 135 | + | client <- ports.WindowMapped{ID: id} | |
| 136 | + | scene(t, scenes) | |
| 137 | + | } | |
| 138 | + | client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200} | |
| 139 | + | scene(t, scenes) | |
| 140 | + | press := func(key string, mods ports.Mods) { | |
| 141 | + | input <- ports.KeyEvent{Keysym: key, Mods: mods, Pressed: true} | |
| 142 | + | input <- ports.KeyEvent{Keysym: key, Mods: mods} | |
| 143 | + | } | |
| 144 | + | press("o", ports.ModSuper) | |
| 145 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { | |
| 146 | + | for _, w := range s.Windows { | |
| 147 | + | if w.ID == 9 && w.Focused && w.Preview > 0 { | |
| 148 | + | return true | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | return false | |
| 152 | + | }) | |
| 153 | + | press("Up", 0) | |
| 154 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { | |
| 155 | + | for _, w := range s.Windows { | |
| 156 | + | if w.ID == 2 && w.Focused && w.Preview > 0 { | |
| 157 | + | return true | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | return false | |
| 161 | + | }) | |
| 162 | + | press("q", ports.ModSuper) | |
| 163 | + | if got := next(t, commands, anyOf[ports.CloseWindow]); got.ID != 2 { | |
| 164 | + | t.Fatalf("close target %d, want preview 2", got.ID) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestFloatOnlyMonitorDirectionalExit(t *testing.T) { | |
| 169 | + | for _, dir := range []int{-1, 1} { | |
| 170 | + | r := startMulti(t, nil, left, right) | |
| 171 | + | if dir < 0 { | |
| 172 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 173 | + | } | |
| 174 | + | r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 20, Height: 20} | |
| 175 | + | receive(t, r.scenes) | |
| 176 | + | key := "Right" | |
| 177 | + | want := right.Name | |
| 178 | + | if dir < 0 { | |
| 179 | + | key, want = "Left", left.Name | |
| 180 | + | } | |
| 181 | + | r.key(t, key, ports.ModAlt) | |
| 182 | + | out := lastOutputs(t, r.commands) | |
| 183 | + | if out.Focused != want { | |
| 184 | + | t.Fatalf("direction %d: focused output %q, want %q", dir, out.Focused, want) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 64 | 189 | // Two-finger scrolling moves the overview selection one column per step, | |
| 65 | 190 | // along the axis the fingers move most; a small scroll does nothing and | |
| 66 | 191 | // lifting the fingers starts over. Outside the overview it goes to the | |
| @@ -61,6 +61,131 @@ func TestOverviewLauncherKeepsKeys(t *testing.T) { | |||||
| 61 | }) | 61 | }) | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | + | // Focus binds (cmd+arrows and their Vim twins) navigate the overview over | |||
| 65 | + | // a covering float: cmd+k/j round-trips through the columns card, then | |||
| 66 | + | // cmd+up brings it forward, cmd+left selects a column and cmd+down returns. | |||
| 67 | + | func TestOverviewFocusBindsNavigate(t *testing.T) { | |||
| 68 | + | cfg := config.Defaults() | |||
| 69 | + | client := make(chan ports.ClientEvent, 8) | |||
| 70 | + | input := make(chan ports.InputEvent, 8) | |||
| 71 | + | output := make(chan ports.OutputEvent, 8) | |||
| 72 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 73 | + | scenes := make(chan []ports.Scene, 1) | |||
| 74 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 79 | + | defer cancel() | |||
| 80 | + | go c.Run(ctx) | |||
| 81 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |||
| 82 | + | scene(t, scenes) | |||
| 83 | + | for id := ports.WindowID(1); id <= 2; id++ { | |||
| 84 | + | client <- ports.WindowMapped{ID: id} | |||
| 85 | + | scene(t, scenes) | |||
| 86 | + | } | |||
| 87 | + | client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200} | |||
| 88 | + | scene(t, scenes) | |||
| 89 | + | focused := func(id ports.WindowID) func(ports.Scene) bool { | |||
| 90 | + | return func(s ports.Scene) bool { | |||
| 91 | + | for _, w := range s.Windows { | |||
| 92 | + | if w.ID == id && w.Focused && w.Preview > 0 && w.Dim == 0 { | |||
| 93 | + | return true | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | press := func(key string) { | |||
| 100 | + | input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper, Pressed: true} | |||
| 101 | + | input <- ports.KeyEvent{Keysym: key, Mods: ports.ModSuper} | |||
| 102 | + | } | |||
| 103 | + | press("o") | |||
| 104 | + | sceneMatch(t, scenes, focused(9)) | |||
| 105 | + | press("k") | |||
| 106 | + | sceneMatch(t, scenes, focused(2)) | |||
| 107 | + | press("j") | |||
| 108 | + | sceneMatch(t, scenes, focused(9)) | |||
| 109 | + | press("Up") | |||
| 110 | + | sceneMatch(t, scenes, focused(2)) | |||
| 111 | + | press("Left") | |||
| 112 | + | sceneMatch(t, scenes, focused(1)) | |||
| 113 | + | press("Down") | |||
| 114 | + | sceneMatch(t, scenes, focused(9)) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Closing through a real bind follows the preview, not the covering float. | |||
| 118 | + | func TestOverviewCloseBindTargetsPreview(t *testing.T) { | |||
| 119 | + | cfg := config.Defaults() | |||
| 120 | + | client := make(chan ports.ClientEvent, 8) | |||
| 121 | + | input := make(chan ports.InputEvent, 8) | |||
| 122 | + | output := make(chan ports.OutputEvent, 8) | |||
| 123 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 124 | + | scenes := make(chan []ports.Scene, 1) | |||
| 125 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 129 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 130 | + | defer cancel() | |||
| 131 | + | go c.Run(ctx) | |||
| 132 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200}} | |||
| 133 | + | scene(t, scenes) | |||
| 134 | + | for _, id := range []ports.WindowID{1, 2} { | |||
| 135 | + | client <- ports.WindowMapped{ID: id} | |||
| 136 | + | scene(t, scenes) | |||
| 137 | + | } | |||
| 138 | + | client <- ports.WindowMapped{ID: 9, Floating: true, Width: 300, Height: 200} | |||
| 139 | + | scene(t, scenes) | |||
| 140 | + | press := func(key string, mods ports.Mods) { | |||
| 141 | + | input <- ports.KeyEvent{Keysym: key, Mods: mods, Pressed: true} | |||
| 142 | + | input <- ports.KeyEvent{Keysym: key, Mods: mods} | |||
| 143 | + | } | |||
| 144 | + | press("o", ports.ModSuper) | |||
| 145 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { | |||
| 146 | + | for _, w := range s.Windows { | |||
| 147 | + | if w.ID == 9 && w.Focused && w.Preview > 0 { | |||
| 148 | + | return true | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | return false | |||
| 152 | + | }) | |||
| 153 | + | press("Up", 0) | |||
| 154 | + | sceneMatch(t, scenes, func(s ports.Scene) bool { | |||
| 155 | + | for _, w := range s.Windows { | |||
| 156 | + | if w.ID == 2 && w.Focused && w.Preview > 0 { | |||
| 157 | + | return true | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | return false | |||
| 161 | + | }) | |||
| 162 | + | press("q", ports.ModSuper) | |||
| 163 | + | if got := next(t, commands, anyOf[ports.CloseWindow]); got.ID != 2 { | |||
| 164 | + | t.Fatalf("close target %d, want preview 2", got.ID) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestFloatOnlyMonitorDirectionalExit(t *testing.T) { | |||
| 169 | + | for _, dir := range []int{-1, 1} { | |||
| 170 | + | r := startMulti(t, nil, left, right) | |||
| 171 | + | if dir < 0 { | |||
| 172 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 173 | + | } | |||
| 174 | + | r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 20, Height: 20} | |||
| 175 | + | receive(t, r.scenes) | |||
| 176 | + | key := "Right" | |||
| 177 | + | want := right.Name | |||
| 178 | + | if dir < 0 { | |||
| 179 | + | key, want = "Left", left.Name | |||
| 180 | + | } | |||
| 181 | + | r.key(t, key, ports.ModAlt) | |||
| 182 | + | out := lastOutputs(t, r.commands) | |||
| 183 | + | if out.Focused != want { | |||
| 184 | + | t.Fatalf("direction %d: focused output %q, want %q", dir, out.Focused, want) | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 64 | // Two-finger scrolling moves the overview selection one column per step, | 189 | // Two-finger scrolling moves the overview selection one column per step, | ||
| 65 | // along the axis the fingers move most; a small scroll does nothing and | 190 | // along the axis the fingers move most; a small scroll does nothing and | ||
| 66 | // lifting the fingers starts over. Outside the overview it goes to the | 191 | // lifting the fingers starts over. Outside the overview it goes to the | ||
A internal/core/overview_maximized_test.go +306 -0
| @@ -0,0 +1,306 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func maximizedOverview() *Monitor { | |
| 12 | + | m := newMonitor("", "") | |
| 13 | + | m.SetOutput(600, 400) | |
| 14 | + | m.SetOverflow(OverflowFixed) | |
| 15 | + | m.SetMaxColumns(3) | |
| 16 | + | for id := WindowID(1); id <= 4; id++ { | |
| 17 | + | m.AddWindow(id) | |
| 18 | + | } | |
| 19 | + | m.Current().FocusID(2) | |
| 20 | + | m.Current().ToggleFullWidth() | |
| 21 | + | return m | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestOverviewMaximizedGeometryAndNavigation(t *testing.T) { | |
| 25 | + | m := maximizedOverview() | |
| 26 | + | w := m.Current() | |
| 27 | + | w.Columns[2].Windows = append(w.Columns[2].Windows, 5) | |
| 28 | + | rects := w.hiddenColumnRects() | |
| 29 | + | m.ToggleOverview() | |
| 30 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 2}, {kind: stackColumns}}) { | |
| 31 | + | t.Fatalf("items %v", got) | |
| 32 | + | } | |
| 33 | + | if p := previewOf(t, m.Layout(), 2); p.Hidden || p.Peek || !p.Focused { | |
| 34 | + | t.Fatalf("maximized front %+v", p) | |
| 35 | + | } | |
| 36 | + | ps := m.Layout() | |
| 37 | + | for _, id := range []WindowID{1, 3, 4, 5} { | |
| 38 | + | p := previewOf(t, ps, id) | |
| 39 | + | i := w.columnOf(id) | |
| 40 | + | r := rects[i] | |
| 41 | + | if id == 3 || id == 5 { | |
| 42 | + | r = stackRects(r, 2, w.gap())[map[WindowID]int{3: 0, 5: 1}[id]] | |
| 43 | + | } | |
| 44 | + | if p.Hidden || !p.Peek || p.Focused || math.Abs(float64(p.Rect.W)-float64(r.W)*p.Preview) > 1 || math.Abs(float64(p.Rect.H)-float64(r.H)*p.Preview) > 1 { | |
| 45 | + | t.Fatalf("hidden tile %d: %+v cell %+v", id, p, r) | |
| 46 | + | } | |
| 47 | + | for _, other := range []WindowID{1, 3, 4, 5} { | |
| 48 | + | if id < other && p.Rect.Overlaps(previewOf(t, ps, other).Rect) { | |
| 49 | + | t.Fatalf("hidden tiles %d/%d overlap", id, other) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | // The hidden columns fill the card as if the maximized column were | |
| 54 | + | // gone: no hole where it was. | |
| 55 | + | area := 0 | |
| 56 | + | for _, id := range []WindowID{1, 3, 4, 5} { | |
| 57 | + | r := previewOf(t, ps, id).Rect | |
| 58 | + | area += r.W * r.H | |
| 59 | + | } | |
| 60 | + | z := previewOf(t, ps, 1).Preview | |
| 61 | + | full := float64(w.Usable.W) * float64(w.Usable.H) * z * z | |
| 62 | + | if float64(area) < 0.8*full { | |
| 63 | + | t.Fatalf("hidden tiles cover %d of %.0f: hole left by the maximized column", area, full) | |
| 64 | + | } | |
| 65 | + | m.OverviewMove(0, -1) | |
| 66 | + | if m.stackFront(w).kind != stackColumns || m.hiddenColumn(w) != 0 || !previewOf(t, m.Layout(), 1).Focused || w.Focus != 1 || !w.Columns[1].FullWidth { | |
| 67 | + | t.Fatal("up changed real maximization or failed to select hidden card") | |
| 68 | + | } | |
| 69 | + | m.OverviewMove(1, 0) | |
| 70 | + | if m.hiddenColumn(w) != 2 || !previewOf(t, m.Layout(), 3).Focused || w.Focus != 1 { | |
| 71 | + | t.Fatal("right did not select column 3 provisionally") | |
| 72 | + | } | |
| 73 | + | m.OverviewMove(-1, 0) | |
| 74 | + | if m.hiddenColumn(w) != 0 { | |
| 75 | + | t.Fatal("left did not skip maximized slot") | |
| 76 | + | } | |
| 77 | + | m.OverviewMove(1, 0) | |
| 78 | + | m.ToggleOverview() | |
| 79 | + | if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth || w.Columns[1].FullWidth || !previewOf(t, w.Layout(), 1).Hidden { | |
| 80 | + | t.Fatalf("confirm focus %d columns %+v", id, w.Columns) | |
| 81 | + | } | |
| 82 | + | m.ToggleOverview() | |
| 83 | + | if m.hiddenColumn(w) != 1 || m.stackFront(w).kind != stackColumn { | |
| 84 | + | t.Fatal("previously maximized column not selected behind front") | |
| 85 | + | } | |
| 86 | + | m.OverviewMove(0, -1) | |
| 87 | + | if p := previewOf(t, m.Layout(), 2); !p.Focused || p.Peek { | |
| 88 | + | t.Fatalf("MRU selection %+v", p) | |
| 89 | + | } | |
| 90 | + | m.CancelOverview() | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestOverviewMaximizedCancelAndClick(t *testing.T) { | |
| 94 | + | m := maximizedOverview() | |
| 95 | + | w := m.Current() | |
| 96 | + | w.AddFloating(9, 600, 400) | |
| 97 | + | w.FocusID(2) | |
| 98 | + | before := slices.Clone(order(w)) | |
| 99 | + | m.ToggleOverview() | |
| 100 | + | m.OverviewMove(0, -1) | |
| 101 | + | m.OverviewMove(0, -1) | |
| 102 | + | m.CancelOverview() | |
| 103 | + | if id, _ := w.Focused(); id != 2 || !w.Columns[1].FullWidth || !slices.Equal(order(w), before) { | |
| 104 | + | t.Fatalf("cancel focus %d order %v", id, order(w)) | |
| 105 | + | } | |
| 106 | + | m.ToggleOverview() | |
| 107 | + | p := previewOf(t, m.Layout(), 3) | |
| 108 | + | if !p.Peek { | |
| 109 | + | t.Fatalf("peek %+v", p) | |
| 110 | + | } | |
| 111 | + | m.OverviewPick(3) | |
| 112 | + | if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth { | |
| 113 | + | t.Fatalf("click focus %d max %+v", id, w.Columns) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestOverviewMaximizedFloatWrapAndNeighbor(t *testing.T) { | |
| 118 | + | m := maximizedOverview() | |
| 119 | + | w := m.Current() | |
| 120 | + | w.AddFloating(9, 600, 400) | |
| 121 | + | m.Focus(1) | |
| 122 | + | m.AddWindow(10) | |
| 123 | + | m.AddWindow(11) | |
| 124 | + | m.Current().FocusID(10) | |
| 125 | + | m.Current().ToggleFullWidth() | |
| 126 | + | m.Focus(0) | |
| 127 | + | m.ToggleOverview() | |
| 128 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{stackFloat, 9}, {stackColumn, 2}, {kind: stackColumns}}) { | |
| 129 | + | t.Fatalf("stack %v", got) | |
| 130 | + | } | |
| 131 | + | if p, q := previewOf(t, m.Layout(), 10), previewOf(t, m.Layout(), 11); !p.Peek || !q.Peek || p.Focused || q.Focused { | |
| 132 | + | t.Fatalf("neighbor %+v %+v", p, q) | |
| 133 | + | } | |
| 134 | + | m.OverviewMove(0, -1) | |
| 135 | + | if m.stackFront(w) != (stackItem{stackColumn, 2}) { | |
| 136 | + | t.Fatalf("front %v", m.stackFront(w)) | |
| 137 | + | } | |
| 138 | + | m.OverviewMove(0, 1) | |
| 139 | + | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | |
| 140 | + | t.Fatalf("back %v", m.stackFront(w)) | |
| 141 | + | } | |
| 142 | + | m.CancelOverview() | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestOverviewMaximizedLeftAndStash(t *testing.T) { | |
| 146 | + | m := maximizedOverview() | |
| 147 | + | w := m.Current() | |
| 148 | + | w.FocusID(4) | |
| 149 | + | w.ToggleWindowStash() | |
| 150 | + | w.FocusID(2) | |
| 151 | + | w.ToggleFullWidth() | |
| 152 | + | m.ToggleOverview() | |
| 153 | + | m.OverviewMove(-1, 0) | |
| 154 | + | if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns { | |
| 155 | + | t.Fatalf("left should send card behind: %v", m.stackFront(w)) | |
| 156 | + | } | |
| 157 | + | m.OverviewMove(0, 1) | |
| 158 | + | if m.stackFront(w) != (stackItem{stackColumn, 2}) { | |
| 159 | + | t.Fatalf("front %v", m.stackFront(w)) | |
| 160 | + | } | |
| 161 | + | m.CancelOverview() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Escape restores the opening column state after a real workspace mutation. | |
| 165 | + | func TestOverviewCancelRestoresFullWidthAfterMutation(t *testing.T) { | |
| 166 | + | m := maximizedOverview() | |
| 167 | + | w := m.Current() | |
| 168 | + | m.ToggleOverview() | |
| 169 | + | w.ToggleFullWidth() | |
| 170 | + | if w.Columns[1].FullWidth { | |
| 171 | + | t.Fatal("workspace mutation did not toggle maximization") | |
| 172 | + | } | |
| 173 | + | m.CancelOverview() | |
| 174 | + | if w.Focus != 1 || !w.Columns[1].FullWidth { | |
| 175 | + | t.Fatalf("cancel lost maximization: focus %d columns %+v", w.Focus, w.Columns) | |
| 176 | + | } | |
| 177 | + | // The snapshot follows a surviving column if another column disappears. | |
| 178 | + | m.ToggleOverview() | |
| 179 | + | w.RemoveWindow(1) | |
| 180 | + | w.ToggleFullWidth() | |
| 181 | + | m.CancelOverview() | |
| 182 | + | if id, _ := w.Focused(); id != 2 || !w.Columns[0].FullWidth { | |
| 183 | + | t.Fatalf("cancel after removal: focus %d columns %+v", id, w.Columns) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Escape maximizes only the column holding the window focused at opening, | |
| 188 | + | // where the focus returns, even when binds split its column meanwhile. | |
| 189 | + | func TestOverviewCancelFullWidthAfterSplit(t *testing.T) { | |
| 190 | + | m := maximizedOverview() | |
| 191 | + | w := m.Current() | |
| 192 | + | w.FocusID(3) | |
| 193 | + | w.ConsumeOrExpel(-1) // column 2 holds [2 3] | |
| 194 | + | w.FocusID(3) | |
| 195 | + | w.ToggleFullWidth() | |
| 196 | + | m.ToggleOverview() | |
| 197 | + | w.FocusID(3) | |
| 198 | + | w.ConsumeOrExpel(1) // 3 leaves for another column | |
| 199 | + | w.FocusID(2) | |
| 200 | + | w.ToggleFullWidth() // 2's column maximized instead | |
| 201 | + | m.CancelOverview() | |
| 202 | + | maxed := 0 | |
| 203 | + | for _, c := range w.Columns { | |
| 204 | + | if c.FullWidth { | |
| 205 | + | maxed++ | |
| 206 | + | if !slices.Contains(c.Windows, 3) { | |
| 207 | + | t.Fatalf("wrong column maximized: %+v", w.Columns) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | if id, _ := w.Focused(); maxed != 1 || id != 3 { | |
| 212 | + | t.Fatalf("%d maximized, focus %d: %+v", maxed, id, w.Columns) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // In scroll overflow, Escape leaves a maximized column the focus left. | |
| 217 | + | func TestOverviewCancelKeepsScrollFullWidth(t *testing.T) { | |
| 218 | + | m := maximizedOverview() | |
| 219 | + | w := m.Current() | |
| 220 | + | w.Overflow = OverflowScroll | |
| 221 | + | w.FocusID(3) // column 2 stays maximized in scroll overflow | |
| 222 | + | if !w.Columns[1].FullWidth { | |
| 223 | + | t.Fatal("setup: column 2 not maximized") | |
| 224 | + | } | |
| 225 | + | m.ToggleOverview() | |
| 226 | + | m.CancelOverview() | |
| 227 | + | if !w.Columns[1].FullWidth { | |
| 228 | + | t.Fatalf("escape cleared column 2: %+v", w.Columns) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // A selected single-column card follows its anchor when earlier columns go. | |
| 233 | + | func TestOverviewHiddenSelectionFollowsWindowAfterRemoval(t *testing.T) { | |
| 234 | + | m := maximizedOverview() | |
| 235 | + | w := m.Current() | |
| 236 | + | m.ToggleOverview() | |
| 237 | + | m.OverviewMove(0, -1) | |
| 238 | + | m.OverviewMove(1, 0) // select column 3 inside the group | |
| 239 | + | w.RemoveWindow(1) | |
| 240 | + | if p := previewOf(t, m.Layout(), 3); !p.Focused || p.Peek { | |
| 241 | + | t.Fatalf("selection moved: %+v", p) | |
| 242 | + | } | |
| 243 | + | m.ToggleOverview() | |
| 244 | + | if id, _ := w.Focused(); id != 3 || !w.Columns[1].FullWidth { | |
| 245 | + | t.Fatalf("confirm %d %+v", id, w.Columns) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestOverviewHiddenSelectionRemovedFallsBack(t *testing.T) { | |
| 250 | + | m := maximizedOverview() | |
| 251 | + | w := m.Current() | |
| 252 | + | m.ToggleOverview() | |
| 253 | + | m.OverviewMove(0, -1) | |
| 254 | + | m.OverviewMove(1, 0) // select column 3 inside the group | |
| 255 | + | w.RemoveWindow(3) | |
| 256 | + | if p := previewOf(t, m.Layout(), 4); !p.Focused { | |
| 257 | + | t.Fatalf("fallback %+v", p) | |
| 258 | + | } | |
| 259 | + | m.ToggleOverview() | |
| 260 | + | if id, _ := w.Focused(); id != 4 { | |
| 261 | + | t.Fatalf("fallback %d", id) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) { | |
| 266 | + | m := maximizedOverview() | |
| 267 | + | w := m.Current() | |
| 268 | + | s := newConfigures() | |
| 269 | + | before := map[WindowID]ports.ConfigureWindow{} | |
| 270 | + | for _, p := range w.Layout() { | |
| 271 | + | // Hidden clients retain their last unmaximized configure. | |
| 272 | + | if p.Hidden { | |
| 273 | + | r := w.columnRectsFor(true)[int(p.ID)-1] | |
| 274 | + | p.Rect, p.Hidden = r, false | |
| 275 | + | } | |
| 276 | + | v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, area: w.Usable}) | |
| 277 | + | s.mark(v) | |
| 278 | + | before[p.ID] = v | |
| 279 | + | } | |
| 280 | + | m.ToggleOverview() | |
| 281 | + | m.OverviewMove(0, -1) | |
| 282 | + | for _, p := range m.Layout() { | |
| 283 | + | if p.ID < 1 || p.ID > 4 { | |
| 284 | + | continue | |
| 285 | + | } | |
| 286 | + | v, _ := s.next(p, configureTarget{output: "test", area: w.Usable}) | |
| 287 | + | if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height { | |
| 288 | + | t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func TestOverviewScrollFullWidthUnchanged(t *testing.T) { | |
| 294 | + | m := maximizedOverview() | |
| 295 | + | w := m.Current() | |
| 296 | + | w.Overflow = OverflowScroll | |
| 297 | + | m.ToggleOverview() | |
| 298 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{kind: stackColumns}}) { | |
| 299 | + | t.Fatalf("scroll items %v", got) | |
| 300 | + | } | |
| 301 | + | for _, id := range []WindowID{1, 3, 4} { | |
| 302 | + | if p := previewOf(t, m.Layout(), id); p.Hidden || p.Peek { | |
| 303 | + | t.Fatalf("scroll column %d %+v", id, p) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| @@ -0,0 +1,306 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func maximizedOverview() *Monitor { | |||
| 12 | + | m := newMonitor("", "") | |||
| 13 | + | m.SetOutput(600, 400) | |||
| 14 | + | m.SetOverflow(OverflowFixed) | |||
| 15 | + | m.SetMaxColumns(3) | |||
| 16 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 17 | + | m.AddWindow(id) | |||
| 18 | + | } | |||
| 19 | + | m.Current().FocusID(2) | |||
| 20 | + | m.Current().ToggleFullWidth() | |||
| 21 | + | return m | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestOverviewMaximizedGeometryAndNavigation(t *testing.T) { | |||
| 25 | + | m := maximizedOverview() | |||
| 26 | + | w := m.Current() | |||
| 27 | + | w.Columns[2].Windows = append(w.Columns[2].Windows, 5) | |||
| 28 | + | rects := w.hiddenColumnRects() | |||
| 29 | + | m.ToggleOverview() | |||
| 30 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{stackColumn, 2}, {kind: stackColumns}}) { | |||
| 31 | + | t.Fatalf("items %v", got) | |||
| 32 | + | } | |||
| 33 | + | if p := previewOf(t, m.Layout(), 2); p.Hidden || p.Peek || !p.Focused { | |||
| 34 | + | t.Fatalf("maximized front %+v", p) | |||
| 35 | + | } | |||
| 36 | + | ps := m.Layout() | |||
| 37 | + | for _, id := range []WindowID{1, 3, 4, 5} { | |||
| 38 | + | p := previewOf(t, ps, id) | |||
| 39 | + | i := w.columnOf(id) | |||
| 40 | + | r := rects[i] | |||
| 41 | + | if id == 3 || id == 5 { | |||
| 42 | + | r = stackRects(r, 2, w.gap())[map[WindowID]int{3: 0, 5: 1}[id]] | |||
| 43 | + | } | |||
| 44 | + | if p.Hidden || !p.Peek || p.Focused || math.Abs(float64(p.Rect.W)-float64(r.W)*p.Preview) > 1 || math.Abs(float64(p.Rect.H)-float64(r.H)*p.Preview) > 1 { | |||
| 45 | + | t.Fatalf("hidden tile %d: %+v cell %+v", id, p, r) | |||
| 46 | + | } | |||
| 47 | + | for _, other := range []WindowID{1, 3, 4, 5} { | |||
| 48 | + | if id < other && p.Rect.Overlaps(previewOf(t, ps, other).Rect) { | |||
| 49 | + | t.Fatalf("hidden tiles %d/%d overlap", id, other) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | // The hidden columns fill the card as if the maximized column were | |||
| 54 | + | // gone: no hole where it was. | |||
| 55 | + | area := 0 | |||
| 56 | + | for _, id := range []WindowID{1, 3, 4, 5} { | |||
| 57 | + | r := previewOf(t, ps, id).Rect | |||
| 58 | + | area += r.W * r.H | |||
| 59 | + | } | |||
| 60 | + | z := previewOf(t, ps, 1).Preview | |||
| 61 | + | full := float64(w.Usable.W) * float64(w.Usable.H) * z * z | |||
| 62 | + | if float64(area) < 0.8*full { | |||
| 63 | + | t.Fatalf("hidden tiles cover %d of %.0f: hole left by the maximized column", area, full) | |||
| 64 | + | } | |||
| 65 | + | m.OverviewMove(0, -1) | |||
| 66 | + | if m.stackFront(w).kind != stackColumns || m.hiddenColumn(w) != 0 || !previewOf(t, m.Layout(), 1).Focused || w.Focus != 1 || !w.Columns[1].FullWidth { | |||
| 67 | + | t.Fatal("up changed real maximization or failed to select hidden card") | |||
| 68 | + | } | |||
| 69 | + | m.OverviewMove(1, 0) | |||
| 70 | + | if m.hiddenColumn(w) != 2 || !previewOf(t, m.Layout(), 3).Focused || w.Focus != 1 { | |||
| 71 | + | t.Fatal("right did not select column 3 provisionally") | |||
| 72 | + | } | |||
| 73 | + | m.OverviewMove(-1, 0) | |||
| 74 | + | if m.hiddenColumn(w) != 0 { | |||
| 75 | + | t.Fatal("left did not skip maximized slot") | |||
| 76 | + | } | |||
| 77 | + | m.OverviewMove(1, 0) | |||
| 78 | + | m.ToggleOverview() | |||
| 79 | + | if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth || w.Columns[1].FullWidth || !previewOf(t, w.Layout(), 1).Hidden { | |||
| 80 | + | t.Fatalf("confirm focus %d columns %+v", id, w.Columns) | |||
| 81 | + | } | |||
| 82 | + | m.ToggleOverview() | |||
| 83 | + | if m.hiddenColumn(w) != 1 || m.stackFront(w).kind != stackColumn { | |||
| 84 | + | t.Fatal("previously maximized column not selected behind front") | |||
| 85 | + | } | |||
| 86 | + | m.OverviewMove(0, -1) | |||
| 87 | + | if p := previewOf(t, m.Layout(), 2); !p.Focused || p.Peek { | |||
| 88 | + | t.Fatalf("MRU selection %+v", p) | |||
| 89 | + | } | |||
| 90 | + | m.CancelOverview() | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestOverviewMaximizedCancelAndClick(t *testing.T) { | |||
| 94 | + | m := maximizedOverview() | |||
| 95 | + | w := m.Current() | |||
| 96 | + | w.AddFloating(9, 600, 400) | |||
| 97 | + | w.FocusID(2) | |||
| 98 | + | before := slices.Clone(order(w)) | |||
| 99 | + | m.ToggleOverview() | |||
| 100 | + | m.OverviewMove(0, -1) | |||
| 101 | + | m.OverviewMove(0, -1) | |||
| 102 | + | m.CancelOverview() | |||
| 103 | + | if id, _ := w.Focused(); id != 2 || !w.Columns[1].FullWidth || !slices.Equal(order(w), before) { | |||
| 104 | + | t.Fatalf("cancel focus %d order %v", id, order(w)) | |||
| 105 | + | } | |||
| 106 | + | m.ToggleOverview() | |||
| 107 | + | p := previewOf(t, m.Layout(), 3) | |||
| 108 | + | if !p.Peek { | |||
| 109 | + | t.Fatalf("peek %+v", p) | |||
| 110 | + | } | |||
| 111 | + | m.OverviewPick(3) | |||
| 112 | + | if id, _ := w.Focused(); id != 3 || !w.Columns[2].FullWidth { | |||
| 113 | + | t.Fatalf("click focus %d max %+v", id, w.Columns) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestOverviewMaximizedFloatWrapAndNeighbor(t *testing.T) { | |||
| 118 | + | m := maximizedOverview() | |||
| 119 | + | w := m.Current() | |||
| 120 | + | w.AddFloating(9, 600, 400) | |||
| 121 | + | m.Focus(1) | |||
| 122 | + | m.AddWindow(10) | |||
| 123 | + | m.AddWindow(11) | |||
| 124 | + | m.Current().FocusID(10) | |||
| 125 | + | m.Current().ToggleFullWidth() | |||
| 126 | + | m.Focus(0) | |||
| 127 | + | m.ToggleOverview() | |||
| 128 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{stackFloat, 9}, {stackColumn, 2}, {kind: stackColumns}}) { | |||
| 129 | + | t.Fatalf("stack %v", got) | |||
| 130 | + | } | |||
| 131 | + | if p, q := previewOf(t, m.Layout(), 10), previewOf(t, m.Layout(), 11); !p.Peek || !q.Peek || p.Focused || q.Focused { | |||
| 132 | + | t.Fatalf("neighbor %+v %+v", p, q) | |||
| 133 | + | } | |||
| 134 | + | m.OverviewMove(0, -1) | |||
| 135 | + | if m.stackFront(w) != (stackItem{stackColumn, 2}) { | |||
| 136 | + | t.Fatalf("front %v", m.stackFront(w)) | |||
| 137 | + | } | |||
| 138 | + | m.OverviewMove(0, 1) | |||
| 139 | + | if m.stackFront(w) != (stackItem{stackFloat, 9}) { | |||
| 140 | + | t.Fatalf("back %v", m.stackFront(w)) | |||
| 141 | + | } | |||
| 142 | + | m.CancelOverview() | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestOverviewMaximizedLeftAndStash(t *testing.T) { | |||
| 146 | + | m := maximizedOverview() | |||
| 147 | + | w := m.Current() | |||
| 148 | + | w.FocusID(4) | |||
| 149 | + | w.ToggleWindowStash() | |||
| 150 | + | w.FocusID(2) | |||
| 151 | + | w.ToggleFullWidth() | |||
| 152 | + | m.ToggleOverview() | |||
| 153 | + | m.OverviewMove(-1, 0) | |||
| 154 | + | if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns { | |||
| 155 | + | t.Fatalf("left should send card behind: %v", m.stackFront(w)) | |||
| 156 | + | } | |||
| 157 | + | m.OverviewMove(0, 1) | |||
| 158 | + | if m.stackFront(w) != (stackItem{stackColumn, 2}) { | |||
| 159 | + | t.Fatalf("front %v", m.stackFront(w)) | |||
| 160 | + | } | |||
| 161 | + | m.CancelOverview() | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Escape restores the opening column state after a real workspace mutation. | |||
| 165 | + | func TestOverviewCancelRestoresFullWidthAfterMutation(t *testing.T) { | |||
| 166 | + | m := maximizedOverview() | |||
| 167 | + | w := m.Current() | |||
| 168 | + | m.ToggleOverview() | |||
| 169 | + | w.ToggleFullWidth() | |||
| 170 | + | if w.Columns[1].FullWidth { | |||
| 171 | + | t.Fatal("workspace mutation did not toggle maximization") | |||
| 172 | + | } | |||
| 173 | + | m.CancelOverview() | |||
| 174 | + | if w.Focus != 1 || !w.Columns[1].FullWidth { | |||
| 175 | + | t.Fatalf("cancel lost maximization: focus %d columns %+v", w.Focus, w.Columns) | |||
| 176 | + | } | |||
| 177 | + | // The snapshot follows a surviving column if another column disappears. | |||
| 178 | + | m.ToggleOverview() | |||
| 179 | + | w.RemoveWindow(1) | |||
| 180 | + | w.ToggleFullWidth() | |||
| 181 | + | m.CancelOverview() | |||
| 182 | + | if id, _ := w.Focused(); id != 2 || !w.Columns[0].FullWidth { | |||
| 183 | + | t.Fatalf("cancel after removal: focus %d columns %+v", id, w.Columns) | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // Escape maximizes only the column holding the window focused at opening, | |||
| 188 | + | // where the focus returns, even when binds split its column meanwhile. | |||
| 189 | + | func TestOverviewCancelFullWidthAfterSplit(t *testing.T) { | |||
| 190 | + | m := maximizedOverview() | |||
| 191 | + | w := m.Current() | |||
| 192 | + | w.FocusID(3) | |||
| 193 | + | w.ConsumeOrExpel(-1) // column 2 holds [2 3] | |||
| 194 | + | w.FocusID(3) | |||
| 195 | + | w.ToggleFullWidth() | |||
| 196 | + | m.ToggleOverview() | |||
| 197 | + | w.FocusID(3) | |||
| 198 | + | w.ConsumeOrExpel(1) // 3 leaves for another column | |||
| 199 | + | w.FocusID(2) | |||
| 200 | + | w.ToggleFullWidth() // 2's column maximized instead | |||
| 201 | + | m.CancelOverview() | |||
| 202 | + | maxed := 0 | |||
| 203 | + | for _, c := range w.Columns { | |||
| 204 | + | if c.FullWidth { | |||
| 205 | + | maxed++ | |||
| 206 | + | if !slices.Contains(c.Windows, 3) { | |||
| 207 | + | t.Fatalf("wrong column maximized: %+v", w.Columns) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | if id, _ := w.Focused(); maxed != 1 || id != 3 { | |||
| 212 | + | t.Fatalf("%d maximized, focus %d: %+v", maxed, id, w.Columns) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // In scroll overflow, Escape leaves a maximized column the focus left. | |||
| 217 | + | func TestOverviewCancelKeepsScrollFullWidth(t *testing.T) { | |||
| 218 | + | m := maximizedOverview() | |||
| 219 | + | w := m.Current() | |||
| 220 | + | w.Overflow = OverflowScroll | |||
| 221 | + | w.FocusID(3) // column 2 stays maximized in scroll overflow | |||
| 222 | + | if !w.Columns[1].FullWidth { | |||
| 223 | + | t.Fatal("setup: column 2 not maximized") | |||
| 224 | + | } | |||
| 225 | + | m.ToggleOverview() | |||
| 226 | + | m.CancelOverview() | |||
| 227 | + | if !w.Columns[1].FullWidth { | |||
| 228 | + | t.Fatalf("escape cleared column 2: %+v", w.Columns) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // A selected single-column card follows its anchor when earlier columns go. | |||
| 233 | + | func TestOverviewHiddenSelectionFollowsWindowAfterRemoval(t *testing.T) { | |||
| 234 | + | m := maximizedOverview() | |||
| 235 | + | w := m.Current() | |||
| 236 | + | m.ToggleOverview() | |||
| 237 | + | m.OverviewMove(0, -1) | |||
| 238 | + | m.OverviewMove(1, 0) // select column 3 inside the group | |||
| 239 | + | w.RemoveWindow(1) | |||
| 240 | + | if p := previewOf(t, m.Layout(), 3); !p.Focused || p.Peek { | |||
| 241 | + | t.Fatalf("selection moved: %+v", p) | |||
| 242 | + | } | |||
| 243 | + | m.ToggleOverview() | |||
| 244 | + | if id, _ := w.Focused(); id != 3 || !w.Columns[1].FullWidth { | |||
| 245 | + | t.Fatalf("confirm %d %+v", id, w.Columns) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestOverviewHiddenSelectionRemovedFallsBack(t *testing.T) { | |||
| 250 | + | m := maximizedOverview() | |||
| 251 | + | w := m.Current() | |||
| 252 | + | m.ToggleOverview() | |||
| 253 | + | m.OverviewMove(0, -1) | |||
| 254 | + | m.OverviewMove(1, 0) // select column 3 inside the group | |||
| 255 | + | w.RemoveWindow(3) | |||
| 256 | + | if p := previewOf(t, m.Layout(), 4); !p.Focused { | |||
| 257 | + | t.Fatalf("fallback %+v", p) | |||
| 258 | + | } | |||
| 259 | + | m.ToggleOverview() | |||
| 260 | + | if id, _ := w.Focused(); id != 4 { | |||
| 261 | + | t.Fatalf("fallback %d", id) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestOverviewMaximizedPreviewsKeepConfigures(t *testing.T) { | |||
| 266 | + | m := maximizedOverview() | |||
| 267 | + | w := m.Current() | |||
| 268 | + | s := newConfigures() | |||
| 269 | + | before := map[WindowID]ports.ConfigureWindow{} | |||
| 270 | + | for _, p := range w.Layout() { | |||
| 271 | + | // Hidden clients retain their last unmaximized configure. | |||
| 272 | + | if p.Hidden { | |||
| 273 | + | r := w.columnRectsFor(true)[int(p.ID)-1] | |||
| 274 | + | p.Rect, p.Hidden = r, false | |||
| 275 | + | } | |||
| 276 | + | v, _ := s.next(p, configureTarget{output: "test", client: p.Rect, area: w.Usable}) | |||
| 277 | + | s.mark(v) | |||
| 278 | + | before[p.ID] = v | |||
| 279 | + | } | |||
| 280 | + | m.ToggleOverview() | |||
| 281 | + | m.OverviewMove(0, -1) | |||
| 282 | + | for _, p := range m.Layout() { | |||
| 283 | + | if p.ID < 1 || p.ID > 4 { | |||
| 284 | + | continue | |||
| 285 | + | } | |||
| 286 | + | v, _ := s.next(p, configureTarget{output: "test", area: w.Usable}) | |||
| 287 | + | if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height { | |||
| 288 | + | t.Fatalf("%d resized from %+v to %+v", p.ID, before[p.ID], v) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func TestOverviewScrollFullWidthUnchanged(t *testing.T) { | |||
| 294 | + | m := maximizedOverview() | |||
| 295 | + | w := m.Current() | |||
| 296 | + | w.Overflow = OverflowScroll | |||
| 297 | + | m.ToggleOverview() | |||
| 298 | + | if got := w.stack(); !slices.Equal(got, []stackItem{{kind: stackColumns}}) { | |||
| 299 | + | t.Fatalf("scroll items %v", got) | |||
| 300 | + | } | |||
| 301 | + | for _, id := range []WindowID{1, 3, 4} { | |||
| 302 | + | if p := previewOf(t, m.Layout(), id); p.Hidden || p.Peek { | |||
| 303 | + | t.Fatalf("scroll column %d %+v", id, p) | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
A internal/core/overview_regression_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestOverviewStashVerticalSelectsFront(t *testing.T) { | |
| 6 | + | for _, accept := range []bool{false, true} { | |
| 7 | + | m := stackMonitor() | |
| 8 | + | w := m.Current() | |
| 9 | + | w.FocusID(1) | |
| 10 | + | w.ToggleWindowStash() | |
| 11 | + | w.FocusID(9) | |
| 12 | + | m.ToggleOverview() | |
| 13 | + | m.OverviewMove(-1, 0) // float to columns | |
| 14 | + | m.OverviewMove(-1, 0) // first column to stash | |
| 15 | + | if m.cardAt(w) < 0 { | |
| 16 | + | t.Fatal("not in stash") | |
| 17 | + | } | |
| 18 | + | m.OverviewMove(0, 1) | |
| 19 | + | p := previewOf(t, m.Layout(), 9) | |
| 20 | + | if !p.Focused || p.Peek || m.cardAt(w) >= 0 { | |
| 21 | + | t.Fatalf("vertical selection: %+v, stash %d", p, m.cardAt(w)) | |
| 22 | + | } | |
| 23 | + | if e := m.Apply(ActionCloseWindow); e.Close != 9 { | |
| 24 | + | t.Fatalf("close target %d, want 9", e.Close) | |
| 25 | + | } | |
| 26 | + | if accept { | |
| 27 | + | m.ToggleOverview() | |
| 28 | + | if id, _ := w.Focused(); id != 9 || !w.floatFocus { | |
| 29 | + | t.Fatalf("accepted focus %d, stash hidden %v", id, w.stashHidden) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestOverviewSingleCoveringFloat(t *testing.T) { | |
| 36 | + | for _, stash := range []bool{false, true} { | |
| 37 | + | for _, neighbor := range []bool{false, true} { | |
| 38 | + | for _, click := range []bool{false, true} { | |
| 39 | + | m := monitor() | |
| 40 | + | m.SetOutput(300, 200) | |
| 41 | + | w := m.Current() | |
| 42 | + | if stash { | |
| 43 | + | w.AddWindow(1) | |
| 44 | + | w.ToggleWindowStash() | |
| 45 | + | w.ToggleStashVisible() | |
| 46 | + | } | |
| 47 | + | m.AddFloating(9, 300, 200) | |
| 48 | + | if neighbor { | |
| 49 | + | m.Focus(1) | |
| 50 | + | m.AddFloating(8, 300, 200) | |
| 51 | + | m.Focus(0) | |
| 52 | + | } | |
| 53 | + | m.ToggleOverview() | |
| 54 | + | p := previewOf(t, m.Layout(), 9) | |
| 55 | + | if p.Hidden || p.Preview <= 0 || p.Rect.W <= 0 || p.Rect.H <= 0 || !p.Focused { | |
| 56 | + | t.Fatalf("single float preview %+v", p) | |
| 57 | + | } | |
| 58 | + | if neighbor { | |
| 59 | + | q := previewOf(t, m.Layout(), 8) | |
| 60 | + | if q.Hidden || q.Preview <= 0 || q.Focused || !q.Peek { | |
| 61 | + | t.Fatalf("neighbor preview %+v", q) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if click { | |
| 65 | + | id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) | |
| 66 | + | if id != 9 { | |
| 67 | + | t.Fatalf("hit %d, want 9", id) | |
| 68 | + | } | |
| 69 | + | m.OverviewPick(id) | |
| 70 | + | } else { | |
| 71 | + | m.ToggleOverview() | |
| 72 | + | } | |
| 73 | + | if id, _ := w.Focused(); id != 9 || m.ov.open { | |
| 74 | + | t.Fatalf("accepted focus %d, overview %v", id, m.ov.open) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestOverviewCloseTargetsFront(t *testing.T) { | |
| 82 | + | m := stackMonitor() | |
| 83 | + | m.ToggleOverview() | |
| 84 | + | m.OverviewMove(0, -1) | |
| 85 | + | if e := m.Apply(ActionCloseWindow); e.Close != 3 { | |
| 86 | + | t.Fatalf("close target %d, want selected column 3", e.Close) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestOverviewTargetAfterUnmaximizeMutation(t *testing.T) { | |
| 91 | + | for _, add := range []bool{false, true} { | |
| 92 | + | m := maximizedOverview() | |
| 93 | + | w := m.Current() | |
| 94 | + | m.ToggleOverview() | |
| 95 | + | m.OverviewMove(0, -1) | |
| 96 | + | m.OverviewMove(1, 0) | |
| 97 | + | if m.ov.selected != 3 { | |
| 98 | + | t.Fatalf("setup selection %d, want 3", m.ov.selected) | |
| 99 | + | } | |
| 100 | + | if add { | |
| 101 | + | w.AddWindow(7) | |
| 102 | + | } else { | |
| 103 | + | w.RemoveWindow(2) | |
| 104 | + | } | |
| 105 | + | var highlighted WindowID | |
| 106 | + | for _, p := range m.Layout() { | |
| 107 | + | if p.Focused && !p.Hidden { | |
| 108 | + | if highlighted != 0 { | |
| 109 | + | t.Fatal("multiple highlighted previews") | |
| 110 | + | } | |
| 111 | + | highlighted = p.ID | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted { | |
| 115 | + | t.Fatalf("add %v: highlighted %d, target %d, close %d", add, highlighted, m.overviewTarget(), m.Apply(ActionCloseWindow).Close) | |
| 116 | + | } | |
| 117 | + | m.ToggleOverview() | |
| 118 | + | if id, _ := w.Focused(); id != highlighted { | |
| 119 | + | t.Fatalf("accepted %d, want highlighted %d", id, highlighted) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestOverviewWindowMutationsDisabled(t *testing.T) { | |
| 125 | + | actions := []Action{ | |
| 126 | + | ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |
| 127 | + | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |
| 128 | + | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | |
| 129 | + | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |
| 130 | + | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |
| 131 | + | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |
| 132 | + | "move-column-to-workspace 2", "move-window-to-workspace 2", | |
| 133 | + | } | |
| 134 | + | for _, a := range actions { | |
| 135 | + | t.Run(string(a), func(t *testing.T) { | |
| 136 | + | m := stackMonitor() | |
| 137 | + | m.ToggleOverview() | |
| 138 | + | m.OverviewMove(0, -1) | |
| 139 | + | before := m.Layout() | |
| 140 | + | m.Apply(a) | |
| 141 | + | after := m.Layout() | |
| 142 | + | if len(before) != len(after) { | |
| 143 | + | t.Fatalf("layout length changed: %d -> %d", len(before), len(after)) | |
| 144 | + | } | |
| 145 | + | for i := range before { | |
| 146 | + | if before[i] != after[i] { | |
| 147 | + | t.Fatalf("layout changed: %+v -> %+v", before[i], after[i]) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestOverviewStashVerticalSelectsFront(t *testing.T) { | |||
| 6 | + | for _, accept := range []bool{false, true} { | |||
| 7 | + | m := stackMonitor() | |||
| 8 | + | w := m.Current() | |||
| 9 | + | w.FocusID(1) | |||
| 10 | + | w.ToggleWindowStash() | |||
| 11 | + | w.FocusID(9) | |||
| 12 | + | m.ToggleOverview() | |||
| 13 | + | m.OverviewMove(-1, 0) // float to columns | |||
| 14 | + | m.OverviewMove(-1, 0) // first column to stash | |||
| 15 | + | if m.cardAt(w) < 0 { | |||
| 16 | + | t.Fatal("not in stash") | |||
| 17 | + | } | |||
| 18 | + | m.OverviewMove(0, 1) | |||
| 19 | + | p := previewOf(t, m.Layout(), 9) | |||
| 20 | + | if !p.Focused || p.Peek || m.cardAt(w) >= 0 { | |||
| 21 | + | t.Fatalf("vertical selection: %+v, stash %d", p, m.cardAt(w)) | |||
| 22 | + | } | |||
| 23 | + | if e := m.Apply(ActionCloseWindow); e.Close != 9 { | |||
| 24 | + | t.Fatalf("close target %d, want 9", e.Close) | |||
| 25 | + | } | |||
| 26 | + | if accept { | |||
| 27 | + | m.ToggleOverview() | |||
| 28 | + | if id, _ := w.Focused(); id != 9 || !w.floatFocus { | |||
| 29 | + | t.Fatalf("accepted focus %d, stash hidden %v", id, w.stashHidden) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestOverviewSingleCoveringFloat(t *testing.T) { | |||
| 36 | + | for _, stash := range []bool{false, true} { | |||
| 37 | + | for _, neighbor := range []bool{false, true} { | |||
| 38 | + | for _, click := range []bool{false, true} { | |||
| 39 | + | m := monitor() | |||
| 40 | + | m.SetOutput(300, 200) | |||
| 41 | + | w := m.Current() | |||
| 42 | + | if stash { | |||
| 43 | + | w.AddWindow(1) | |||
| 44 | + | w.ToggleWindowStash() | |||
| 45 | + | w.ToggleStashVisible() | |||
| 46 | + | } | |||
| 47 | + | m.AddFloating(9, 300, 200) | |||
| 48 | + | if neighbor { | |||
| 49 | + | m.Focus(1) | |||
| 50 | + | m.AddFloating(8, 300, 200) | |||
| 51 | + | m.Focus(0) | |||
| 52 | + | } | |||
| 53 | + | m.ToggleOverview() | |||
| 54 | + | p := previewOf(t, m.Layout(), 9) | |||
| 55 | + | if p.Hidden || p.Preview <= 0 || p.Rect.W <= 0 || p.Rect.H <= 0 || !p.Focused { | |||
| 56 | + | t.Fatalf("single float preview %+v", p) | |||
| 57 | + | } | |||
| 58 | + | if neighbor { | |||
| 59 | + | q := previewOf(t, m.Layout(), 8) | |||
| 60 | + | if q.Hidden || q.Preview <= 0 || q.Focused || !q.Peek { | |||
| 61 | + | t.Fatalf("neighbor preview %+v", q) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | if click { | |||
| 65 | + | id := m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) | |||
| 66 | + | if id != 9 { | |||
| 67 | + | t.Fatalf("hit %d, want 9", id) | |||
| 68 | + | } | |||
| 69 | + | m.OverviewPick(id) | |||
| 70 | + | } else { | |||
| 71 | + | m.ToggleOverview() | |||
| 72 | + | } | |||
| 73 | + | if id, _ := w.Focused(); id != 9 || m.ov.open { | |||
| 74 | + | t.Fatalf("accepted focus %d, overview %v", id, m.ov.open) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestOverviewCloseTargetsFront(t *testing.T) { | |||
| 82 | + | m := stackMonitor() | |||
| 83 | + | m.ToggleOverview() | |||
| 84 | + | m.OverviewMove(0, -1) | |||
| 85 | + | if e := m.Apply(ActionCloseWindow); e.Close != 3 { | |||
| 86 | + | t.Fatalf("close target %d, want selected column 3", e.Close) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestOverviewTargetAfterUnmaximizeMutation(t *testing.T) { | |||
| 91 | + | for _, add := range []bool{false, true} { | |||
| 92 | + | m := maximizedOverview() | |||
| 93 | + | w := m.Current() | |||
| 94 | + | m.ToggleOverview() | |||
| 95 | + | m.OverviewMove(0, -1) | |||
| 96 | + | m.OverviewMove(1, 0) | |||
| 97 | + | if m.ov.selected != 3 { | |||
| 98 | + | t.Fatalf("setup selection %d, want 3", m.ov.selected) | |||
| 99 | + | } | |||
| 100 | + | if add { | |||
| 101 | + | w.AddWindow(7) | |||
| 102 | + | } else { | |||
| 103 | + | w.RemoveWindow(2) | |||
| 104 | + | } | |||
| 105 | + | var highlighted WindowID | |||
| 106 | + | for _, p := range m.Layout() { | |||
| 107 | + | if p.Focused && !p.Hidden { | |||
| 108 | + | if highlighted != 0 { | |||
| 109 | + | t.Fatal("multiple highlighted previews") | |||
| 110 | + | } | |||
| 111 | + | highlighted = p.ID | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | if highlighted == 0 || m.overviewTarget() != highlighted || m.Apply(ActionCloseWindow).Close != highlighted { | |||
| 115 | + | t.Fatalf("add %v: highlighted %d, target %d, close %d", add, highlighted, m.overviewTarget(), m.Apply(ActionCloseWindow).Close) | |||
| 116 | + | } | |||
| 117 | + | m.ToggleOverview() | |||
| 118 | + | if id, _ := w.Focused(); id != highlighted { | |||
| 119 | + | t.Fatalf("accepted %d, want highlighted %d", id, highlighted) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestOverviewWindowMutationsDisabled(t *testing.T) { | |||
| 125 | + | actions := []Action{ | |||
| 126 | + | ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, | |||
| 127 | + | ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, | |||
| 128 | + | ActionToggleStashVisible, ActionMoveColumnToWorkspaceUp, | |||
| 129 | + | ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, | |||
| 130 | + | ActionMoveWindowToWorkspaceDown, ActionConsumeOrExpelLeft, | |||
| 131 | + | ActionConsumeOrExpelRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, | |||
| 132 | + | "move-column-to-workspace 2", "move-window-to-workspace 2", | |||
| 133 | + | } | |||
| 134 | + | for _, a := range actions { | |||
| 135 | + | t.Run(string(a), func(t *testing.T) { | |||
| 136 | + | m := stackMonitor() | |||
| 137 | + | m.ToggleOverview() | |||
| 138 | + | m.OverviewMove(0, -1) | |||
| 139 | + | before := m.Layout() | |||
| 140 | + | m.Apply(a) | |||
| 141 | + | after := m.Layout() | |||
| 142 | + | if len(before) != len(after) { | |||
| 143 | + | t.Fatalf("layout length changed: %d -> %d", len(before), len(after)) | |||
| 144 | + | } | |||
| 145 | + | for i := range before { | |||
| 146 | + | if before[i] != after[i] { | |||
| 147 | + | t.Fatalf("layout changed: %+v -> %+v", before[i], after[i]) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | } | |||
A internal/core/overview_stack.go +300 -0
| @@ -0,0 +1,300 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "slices" | |
| 4 | + | ||
| 5 | + | type stackKind uint8 | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | stackFloat stackKind = iota | |
| 9 | + | stackColumns | |
| 10 | + | stackColumn | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // A stackItem identifies a covering float, a column group (all columns, or | |
| 14 | + | // all except the maximized column), or the maximized column by one of its | |
| 15 | + | // windows. Only stackColumns has a zero ID. | |
| 16 | + | type stackItem struct { | |
| 17 | + | kind stackKind | |
| 18 | + | id WindowID | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (w *Workspace) overviewMaximized() bool { | |
| 22 | + | return w.Overflow == OverflowFixed && len(w.Columns) > 1 && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && !w.pinned() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (w *Workspace) columnOf(id WindowID) int { | |
| 26 | + | for i, c := range w.Columns { | |
| 27 | + | if slices.Contains(c.Windows, id) { | |
| 28 | + | return i | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | return -1 | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // stack lists what is on screen followed by what it hides, top to bottom. | |
| 35 | + | func (w *Workspace) stack() []stackItem { | |
| 36 | + | if w.pinned() { | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | var items []stackItem | |
| 40 | + | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 41 | + | f := w.Floats[i] | |
| 42 | + | if !f.below && w.coversFloat(f) { | |
| 43 | + | items = append(items, stackItem{stackFloat, f.ID}) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | if w.overviewMaximized() { | |
| 47 | + | items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) | |
| 48 | + | } | |
| 49 | + | if len(w.Columns) > 0 { | |
| 50 | + | items = append(items, stackItem{kind: stackColumns}) | |
| 51 | + | } | |
| 52 | + | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 53 | + | f := w.Floats[i] | |
| 54 | + | if f.below && w.coversFloat(f) { | |
| 55 | + | items = append(items, stackItem{stackFloat, f.ID}) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return items | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // itemOf resolves a clicked tile to the card currently containing it. | |
| 62 | + | func (w *Workspace) itemOf(id WindowID) (stackItem, bool) { | |
| 63 | + | for _, item := range w.stack() { | |
| 64 | + | switch item.kind { | |
| 65 | + | case stackFloat: | |
| 66 | + | if item.id == id { | |
| 67 | + | return item, true | |
| 68 | + | } | |
| 69 | + | case stackColumn: | |
| 70 | + | if i := w.columnOf(id); i >= 0 && i == w.columnOf(item.id) { | |
| 71 | + | return item, true | |
| 72 | + | } | |
| 73 | + | case stackColumns: | |
| 74 | + | if i := w.columnOf(id); i >= 0 && (!w.overviewMaximized() || i != w.Focus) { | |
| 75 | + | return item, true | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | return stackItem{}, false | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // stackFront resolves the provisional item against fresh workspace contents; | |
| 83 | + | // an invalidated selection falls back to the nearest surviving stack index. | |
| 84 | + | func (m *Monitor) stackFront(w *Workspace) stackItem { | |
| 85 | + | items := w.stack() | |
| 86 | + | if len(items) == 0 { | |
| 87 | + | return stackItem{} | |
| 88 | + | } | |
| 89 | + | if m.ov.row == w { | |
| 90 | + | for _, item := range items { | |
| 91 | + | if item == m.ov.front || item.kind == stackColumn && m.ov.front.kind == stackColumn && w.columnOf(item.id) >= 0 && w.columnOf(item.id) == w.columnOf(m.ov.front.id) { | |
| 92 | + | return item | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return items[min(max(m.ov.frontAt, 0), len(items)-1)] | |
| 96 | + | } | |
| 97 | + | return items[0] | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // setFront records a provisional card and its index for removal fallback. | |
| 101 | + | func (m *Monitor) setFront(w *Workspace, item stackItem) { | |
| 102 | + | m.ov.row, m.ov.front = w, item | |
| 103 | + | m.ov.frontAt = slices.Index(w.stack(), item) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // moveStack moves only within the current row. The caller changes workspace | |
| 107 | + | // when the linear stack has no item in that direction. | |
| 108 | + | func (m *Monitor) moveStack(d int) bool { | |
| 109 | + | w := m.Current() | |
| 110 | + | items := w.stack() | |
| 111 | + | if len(items) < 2 { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | at := slices.Index(items, m.stackFront(w)) + d | |
| 115 | + | if at < 0 || at >= len(items) { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | m.setFront(w, items[at]) | |
| 119 | + | m.ov.cardOf, m.ov.card = nil, 0 | |
| 120 | + | return true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // hiddenColumn resolves the provisional selection by window, not column | |
| 124 | + | // index. With no surviving selection, prefer the most recently maximized | |
| 125 | + | // hidden column, then the nearest prior non-maximized focus, then first. | |
| 126 | + | func (m *Monitor) hiddenColumn(w *Workspace) int { | |
| 127 | + | if !w.overviewMaximized() { | |
| 128 | + | return -1 | |
| 129 | + | } | |
| 130 | + | if i := w.columnOf(m.ov.selected); i >= 0 && i != w.Focus && m.ov.row == w { | |
| 131 | + | return i | |
| 132 | + | } | |
| 133 | + | if m.ov.row == w && m.ov.selectedAt >= 0 { | |
| 134 | + | i := min(m.ov.selectedAt, len(w.Columns)-1) | |
| 135 | + | if i == w.Focus { | |
| 136 | + | if i > 0 { | |
| 137 | + | i-- | |
| 138 | + | } else { | |
| 139 | + | i++ | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | return i | |
| 143 | + | } | |
| 144 | + | for _, id := range w.maximized { | |
| 145 | + | if i := w.columnOf(id); i >= 0 && i != w.Focus { | |
| 146 | + | return i | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus { | |
| 150 | + | return i | |
| 151 | + | } | |
| 152 | + | for i := range w.Columns { | |
| 153 | + | if i != w.Focus { | |
| 154 | + | return i | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return -1 | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | |
| 161 | + | func (w *Workspace) selectOverviewColumn(i int) { | |
| 162 | + | w.floatFocus, w.stashFocus = false, false | |
| 163 | + | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | |
| 164 | + | w.unmaximize() | |
| 165 | + | } | |
| 166 | + | w.Focus = i | |
| 167 | + | w.scroll() | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // apply commits the selected front card without changing other stack items. | |
| 171 | + | func (w *Workspace) apply(item stackItem, selected WindowID) { | |
| 172 | + | switch item.kind { | |
| 173 | + | case stackFloat: | |
| 174 | + | w.FocusID(item.id) | |
| 175 | + | case stackColumns: | |
| 176 | + | i := w.columnOf(selected) | |
| 177 | + | if w.overviewMaximized() && i == w.Focus { | |
| 178 | + | i = -1 | |
| 179 | + | } | |
| 180 | + | if i < 0 && len(w.Columns) > 0 { | |
| 181 | + | i = w.Focus | |
| 182 | + | if w.overviewMaximized() { | |
| 183 | + | for next := range w.Columns { | |
| 184 | + | if next != w.Focus { | |
| 185 | + | i = next | |
| 186 | + | break | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | c := w.Columns[i] | |
| 191 | + | selected = c.Windows[c.Focus] | |
| 192 | + | } | |
| 193 | + | if selected != 0 { | |
| 194 | + | if w.overviewMaximized() && i != w.Focus { | |
| 195 | + | w.transferMaximization(i) | |
| 196 | + | } | |
| 197 | + | w.FocusID(selected) | |
| 198 | + | } | |
| 199 | + | case stackColumn: | |
| 200 | + | i := w.columnOf(item.id) | |
| 201 | + | if i < 0 { | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | if w.columnOf(selected) != i { | |
| 205 | + | c := w.Columns[i] | |
| 206 | + | selected = c.Windows[c.Focus] | |
| 207 | + | } | |
| 208 | + | if w.overviewMaximized() && i != w.Focus { | |
| 209 | + | w.transferMaximization(i) | |
| 210 | + | } | |
| 211 | + | w.FocusID(selected) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // previewItem draws a group using screen geometry and a single card at its | |
| 216 | + | // configured size. A prior maximized column retains its full-width buffer. | |
| 217 | + | func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement { | |
| 218 | + | if item.kind == stackColumns { | |
| 219 | + | if !w.overviewMaximized() { | |
| 220 | + | return w.previewRow(y, dim, lit) | |
| 221 | + | } | |
| 222 | + | // The hidden columns fill the screen as if the maximized column, | |
| 223 | + | // shown on its own card, were not there. In particular, an earlier | |
| 224 | + | // maximized buffer scales into its unmaximized cell here. | |
| 225 | + | g := w.gap() | |
| 226 | + | rects := w.hiddenColumnRects() | |
| 227 | + | var tiles []Placement | |
| 228 | + | selected := m.hiddenColumn(w) | |
| 229 | + | for i, c := range w.Columns { | |
| 230 | + | if i == w.Focus { | |
| 231 | + | continue | |
| 232 | + | } | |
| 233 | + | r := rects[i] | |
| 234 | + | r.X -= w.Usable.X | |
| 235 | + | r.Y -= w.Usable.Y | |
| 236 | + | for j, t := range stackRects(r, len(c.Windows), g) { | |
| 237 | + | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus}) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | sel := rects[selected] | |
| 241 | + | sel.X -= w.Usable.X | |
| 242 | + | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel) | |
| 243 | + | } | |
| 244 | + | g := w.gap() | |
| 245 | + | if item.kind == stackFloat { | |
| 246 | + | f := w.Floats[w.floatIndex(item.id)] | |
| 247 | + | r := w.floatRect(f) | |
| 248 | + | r.X -= w.Usable.X | |
| 249 | + | r.Y -= w.Usable.Y | |
| 250 | + | return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | |
| 251 | + | } | |
| 252 | + | i := w.columnOf(item.id) | |
| 253 | + | if i < 0 { | |
| 254 | + | return nil | |
| 255 | + | } | |
| 256 | + | c := w.Columns[i] | |
| 257 | + | r := Rect{W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | |
| 258 | + | r.X = (w.Usable.W - r.W) / 2 | |
| 259 | + | r.Y = (w.Usable.H - r.H) / 2 | |
| 260 | + | tiles := make([]Placement, 0, len(c.Windows)) | |
| 261 | + | for j, t := range stackRects(r, len(c.Windows), g) { | |
| 262 | + | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus}) | |
| 263 | + | } | |
| 264 | + | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // hiddenColumnRects lays out the columns behind the maximized one with the | |
| 268 | + | // fixed layout rules, without the maximized column, so no hole is left | |
| 269 | + | // where it was. The maximized column's own entry is an empty Rect. | |
| 270 | + | func (w *Workspace) hiddenColumnRects() []Rect { | |
| 271 | + | v := *w | |
| 272 | + | v.Columns = slices.Delete(slices.Clone(w.Columns), w.Focus, w.Focus+1) | |
| 273 | + | v.Focus = 0 | |
| 274 | + | return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{}) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // stackRow draws up to two cards on each side of the provisional front. | |
| 278 | + | func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement { | |
| 279 | + | items := w.stack() | |
| 280 | + | if len(items) == 0 { | |
| 281 | + | return w.previewRow(y, dim, lit) | |
| 282 | + | } | |
| 283 | + | if len(items) == 1 { | |
| 284 | + | return m.previewItem(w, items[0], y, dim, lit) | |
| 285 | + | } | |
| 286 | + | at := 0 | |
| 287 | + | if w == m.Current() { | |
| 288 | + | at = slices.Index(items, m.stackFront(w)) | |
| 289 | + | } | |
| 290 | + | cards := make([][]Placement, len(items)) | |
| 291 | + | step := w.peekStep() | |
| 292 | + | for i, item := range items { | |
| 293 | + | cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // fan's behind side advances through the linear stack. | |
| 297 | + | return fan(cards, at, 2, 2, false, | |
| 298 | + | func(d int) (int, int) { return 0, -d * step }, | |
| 299 | + | func(d int) (int, int) { return 0, d * step }, dim, lit) | |
| 300 | + | } | |
| @@ -0,0 +1,300 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "slices" | |||
| 4 | + | ||||
| 5 | + | type stackKind uint8 | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | stackFloat stackKind = iota | |||
| 9 | + | stackColumns | |||
| 10 | + | stackColumn | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // A stackItem identifies a covering float, a column group (all columns, or | |||
| 14 | + | // all except the maximized column), or the maximized column by one of its | |||
| 15 | + | // windows. Only stackColumns has a zero ID. | |||
| 16 | + | type stackItem struct { | |||
| 17 | + | kind stackKind | |||
| 18 | + | id WindowID | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (w *Workspace) overviewMaximized() bool { | |||
| 22 | + | return w.Overflow == OverflowFixed && len(w.Columns) > 1 && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && !w.pinned() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (w *Workspace) columnOf(id WindowID) int { | |||
| 26 | + | for i, c := range w.Columns { | |||
| 27 | + | if slices.Contains(c.Windows, id) { | |||
| 28 | + | return i | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | return -1 | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // stack lists what is on screen followed by what it hides, top to bottom. | |||
| 35 | + | func (w *Workspace) stack() []stackItem { | |||
| 36 | + | if w.pinned() { | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | var items []stackItem | |||
| 40 | + | for i := len(w.Floats) - 1; i >= 0; i-- { | |||
| 41 | + | f := w.Floats[i] | |||
| 42 | + | if !f.below && w.coversFloat(f) { | |||
| 43 | + | items = append(items, stackItem{stackFloat, f.ID}) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | if w.overviewMaximized() { | |||
| 47 | + | items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) | |||
| 48 | + | } | |||
| 49 | + | if len(w.Columns) > 0 { | |||
| 50 | + | items = append(items, stackItem{kind: stackColumns}) | |||
| 51 | + | } | |||
| 52 | + | for i := len(w.Floats) - 1; i >= 0; i-- { | |||
| 53 | + | f := w.Floats[i] | |||
| 54 | + | if f.below && w.coversFloat(f) { | |||
| 55 | + | items = append(items, stackItem{stackFloat, f.ID}) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return items | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // itemOf resolves a clicked tile to the card currently containing it. | |||
| 62 | + | func (w *Workspace) itemOf(id WindowID) (stackItem, bool) { | |||
| 63 | + | for _, item := range w.stack() { | |||
| 64 | + | switch item.kind { | |||
| 65 | + | case stackFloat: | |||
| 66 | + | if item.id == id { | |||
| 67 | + | return item, true | |||
| 68 | + | } | |||
| 69 | + | case stackColumn: | |||
| 70 | + | if i := w.columnOf(id); i >= 0 && i == w.columnOf(item.id) { | |||
| 71 | + | return item, true | |||
| 72 | + | } | |||
| 73 | + | case stackColumns: | |||
| 74 | + | if i := w.columnOf(id); i >= 0 && (!w.overviewMaximized() || i != w.Focus) { | |||
| 75 | + | return item, true | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | return stackItem{}, false | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // stackFront resolves the provisional item against fresh workspace contents; | |||
| 83 | + | // an invalidated selection falls back to the nearest surviving stack index. | |||
| 84 | + | func (m *Monitor) stackFront(w *Workspace) stackItem { | |||
| 85 | + | items := w.stack() | |||
| 86 | + | if len(items) == 0 { | |||
| 87 | + | return stackItem{} | |||
| 88 | + | } | |||
| 89 | + | if m.ov.row == w { | |||
| 90 | + | for _, item := range items { | |||
| 91 | + | if item == m.ov.front || item.kind == stackColumn && m.ov.front.kind == stackColumn && w.columnOf(item.id) >= 0 && w.columnOf(item.id) == w.columnOf(m.ov.front.id) { | |||
| 92 | + | return item | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | return items[min(max(m.ov.frontAt, 0), len(items)-1)] | |||
| 96 | + | } | |||
| 97 | + | return items[0] | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // setFront records a provisional card and its index for removal fallback. | |||
| 101 | + | func (m *Monitor) setFront(w *Workspace, item stackItem) { | |||
| 102 | + | m.ov.row, m.ov.front = w, item | |||
| 103 | + | m.ov.frontAt = slices.Index(w.stack(), item) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // moveStack moves only within the current row. The caller changes workspace | |||
| 107 | + | // when the linear stack has no item in that direction. | |||
| 108 | + | func (m *Monitor) moveStack(d int) bool { | |||
| 109 | + | w := m.Current() | |||
| 110 | + | items := w.stack() | |||
| 111 | + | if len(items) < 2 { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | at := slices.Index(items, m.stackFront(w)) + d | |||
| 115 | + | if at < 0 || at >= len(items) { | |||
| 116 | + | return false | |||
| 117 | + | } | |||
| 118 | + | m.setFront(w, items[at]) | |||
| 119 | + | m.ov.cardOf, m.ov.card = nil, 0 | |||
| 120 | + | return true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // hiddenColumn resolves the provisional selection by window, not column | |||
| 124 | + | // index. With no surviving selection, prefer the most recently maximized | |||
| 125 | + | // hidden column, then the nearest prior non-maximized focus, then first. | |||
| 126 | + | func (m *Monitor) hiddenColumn(w *Workspace) int { | |||
| 127 | + | if !w.overviewMaximized() { | |||
| 128 | + | return -1 | |||
| 129 | + | } | |||
| 130 | + | if i := w.columnOf(m.ov.selected); i >= 0 && i != w.Focus && m.ov.row == w { | |||
| 131 | + | return i | |||
| 132 | + | } | |||
| 133 | + | if m.ov.row == w && m.ov.selectedAt >= 0 { | |||
| 134 | + | i := min(m.ov.selectedAt, len(w.Columns)-1) | |||
| 135 | + | if i == w.Focus { | |||
| 136 | + | if i > 0 { | |||
| 137 | + | i-- | |||
| 138 | + | } else { | |||
| 139 | + | i++ | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | return i | |||
| 143 | + | } | |||
| 144 | + | for _, id := range w.maximized { | |||
| 145 | + | if i := w.columnOf(id); i >= 0 && i != w.Focus { | |||
| 146 | + | return i | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | if i := w.columnOf(m.ov.fromID); w == m.ov.from && i >= 0 && i != w.Focus { | |||
| 150 | + | return i | |||
| 151 | + | } | |||
| 152 | + | for i := range w.Columns { | |||
| 153 | + | if i != w.Focus { | |||
| 154 | + | return i | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return -1 | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // selectOverviewColumn retains the existing ordinary-row live focus semantics. | |||
| 161 | + | func (w *Workspace) selectOverviewColumn(i int) { | |||
| 162 | + | w.floatFocus, w.stashFocus = false, false | |||
| 163 | + | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | |||
| 164 | + | w.unmaximize() | |||
| 165 | + | } | |||
| 166 | + | w.Focus = i | |||
| 167 | + | w.scroll() | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // apply commits the selected front card without changing other stack items. | |||
| 171 | + | func (w *Workspace) apply(item stackItem, selected WindowID) { | |||
| 172 | + | switch item.kind { | |||
| 173 | + | case stackFloat: | |||
| 174 | + | w.FocusID(item.id) | |||
| 175 | + | case stackColumns: | |||
| 176 | + | i := w.columnOf(selected) | |||
| 177 | + | if w.overviewMaximized() && i == w.Focus { | |||
| 178 | + | i = -1 | |||
| 179 | + | } | |||
| 180 | + | if i < 0 && len(w.Columns) > 0 { | |||
| 181 | + | i = w.Focus | |||
| 182 | + | if w.overviewMaximized() { | |||
| 183 | + | for next := range w.Columns { | |||
| 184 | + | if next != w.Focus { | |||
| 185 | + | i = next | |||
| 186 | + | break | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | c := w.Columns[i] | |||
| 191 | + | selected = c.Windows[c.Focus] | |||
| 192 | + | } | |||
| 193 | + | if selected != 0 { | |||
| 194 | + | if w.overviewMaximized() && i != w.Focus { | |||
| 195 | + | w.transferMaximization(i) | |||
| 196 | + | } | |||
| 197 | + | w.FocusID(selected) | |||
| 198 | + | } | |||
| 199 | + | case stackColumn: | |||
| 200 | + | i := w.columnOf(item.id) | |||
| 201 | + | if i < 0 { | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | if w.columnOf(selected) != i { | |||
| 205 | + | c := w.Columns[i] | |||
| 206 | + | selected = c.Windows[c.Focus] | |||
| 207 | + | } | |||
| 208 | + | if w.overviewMaximized() && i != w.Focus { | |||
| 209 | + | w.transferMaximization(i) | |||
| 210 | + | } | |||
| 211 | + | w.FocusID(selected) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // previewItem draws a group using screen geometry and a single card at its | |||
| 216 | + | // configured size. A prior maximized column retains its full-width buffer. | |||
| 217 | + | func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool) []Placement { | |||
| 218 | + | if item.kind == stackColumns { | |||
| 219 | + | if !w.overviewMaximized() { | |||
| 220 | + | return w.previewRow(y, dim, lit) | |||
| 221 | + | } | |||
| 222 | + | // The hidden columns fill the screen as if the maximized column, | |||
| 223 | + | // shown on its own card, were not there. In particular, an earlier | |||
| 224 | + | // maximized buffer scales into its unmaximized cell here. | |||
| 225 | + | g := w.gap() | |||
| 226 | + | rects := w.hiddenColumnRects() | |||
| 227 | + | var tiles []Placement | |||
| 228 | + | selected := m.hiddenColumn(w) | |||
| 229 | + | for i, c := range w.Columns { | |||
| 230 | + | if i == w.Focus { | |||
| 231 | + | continue | |||
| 232 | + | } | |||
| 233 | + | r := rects[i] | |||
| 234 | + | r.X -= w.Usable.X | |||
| 235 | + | r.Y -= w.Usable.Y | |||
| 236 | + | for j, t := range stackRects(r, len(c.Windows), g) { | |||
| 237 | + | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus}) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | sel := rects[selected] | |||
| 241 | + | sel.X -= w.Usable.X | |||
| 242 | + | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel) | |||
| 243 | + | } | |||
| 244 | + | g := w.gap() | |||
| 245 | + | if item.kind == stackFloat { | |||
| 246 | + | f := w.Floats[w.floatIndex(item.id)] | |||
| 247 | + | r := w.floatRect(f) | |||
| 248 | + | r.X -= w.Usable.X | |||
| 249 | + | r.Y -= w.Usable.Y | |||
| 250 | + | return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r) | |||
| 251 | + | } | |||
| 252 | + | i := w.columnOf(item.id) | |||
| 253 | + | if i < 0 { | |||
| 254 | + | return nil | |||
| 255 | + | } | |||
| 256 | + | c := w.Columns[i] | |||
| 257 | + | r := Rect{W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)} | |||
| 258 | + | r.X = (w.Usable.W - r.W) / 2 | |||
| 259 | + | r.Y = (w.Usable.H - r.H) / 2 | |||
| 260 | + | tiles := make([]Placement, 0, len(c.Windows)) | |||
| 261 | + | for j, t := range stackRects(r, len(c.Windows), g) { | |||
| 262 | + | tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus}) | |||
| 263 | + | } | |||
| 264 | + | return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // hiddenColumnRects lays out the columns behind the maximized one with the | |||
| 268 | + | // fixed layout rules, without the maximized column, so no hole is left | |||
| 269 | + | // where it was. The maximized column's own entry is an empty Rect. | |||
| 270 | + | func (w *Workspace) hiddenColumnRects() []Rect { | |||
| 271 | + | v := *w | |||
| 272 | + | v.Columns = slices.Delete(slices.Clone(w.Columns), w.Focus, w.Focus+1) | |||
| 273 | + | v.Focus = 0 | |||
| 274 | + | return slices.Insert(v.columnRectsFor(true), w.Focus, Rect{}) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // stackRow draws up to two cards on each side of the provisional front. | |||
| 278 | + | func (m *Monitor) stackRow(w *Workspace, y int, dim, lit bool) []Placement { | |||
| 279 | + | items := w.stack() | |||
| 280 | + | if len(items) == 0 { | |||
| 281 | + | return w.previewRow(y, dim, lit) | |||
| 282 | + | } | |||
| 283 | + | if len(items) == 1 { | |||
| 284 | + | return m.previewItem(w, items[0], y, dim, lit) | |||
| 285 | + | } | |||
| 286 | + | at := 0 | |||
| 287 | + | if w == m.Current() { | |||
| 288 | + | at = slices.Index(items, m.stackFront(w)) | |||
| 289 | + | } | |||
| 290 | + | cards := make([][]Placement, len(items)) | |||
| 291 | + | step := w.peekStep() | |||
| 292 | + | for i, item := range items { | |||
| 293 | + | cards[i] = m.previewItem(w, item, w.frontRowY(y), dim, lit) | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | // fan's behind side advances through the linear stack. | |||
| 297 | + | return fan(cards, at, 2, 2, false, | |||
| 298 | + | func(d int) (int, int) { return 0, -d * step }, | |||
| 299 | + | func(d int) (int, int) { return 0, d * step }, dim, lit) | |||
| 300 | + | } | |||
A internal/core/overview_stack_test.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func stackMonitor() *Monitor { | |
| 9 | + | m := overviewMonitor() | |
| 10 | + | m.SetBorder(2) | |
| 11 | + | m.AddFloating(9, 300, 200) | |
| 12 | + | return m | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestOverviewCoveringStackNavigation(t *testing.T) { | |
| 16 | + | for _, columns := range []bool{false, true} { | |
| 17 | + | m := stackMonitor() | |
| 18 | + | w := m.Current() | |
| 19 | + | if columns { | |
| 20 | + | w.FocusID(3) | |
| 21 | + | } | |
| 22 | + | before := slices.Clone(order(w)) | |
| 23 | + | m.ToggleOverview() | |
| 24 | + | ps := m.Layout() | |
| 25 | + | f, c := previewOf(t, ps, 9), previewOf(t, ps, 3) | |
| 26 | + | if f.Peek != columns || c.Peek == columns { | |
| 27 | + | t.Fatalf("front float %+v columns %+v", f, c) | |
| 28 | + | } | |
| 29 | + | if !slices.Equal(order(w), before) { | |
| 30 | + | t.Fatal("opening changed float order") | |
| 31 | + | } | |
| 32 | + | if columns { | |
| 33 | + | m.OverviewMove(0, -1) | |
| 34 | + | } else { | |
| 35 | + | m.OverviewMove(-1, 0) | |
| 36 | + | } | |
| 37 | + | if columns { | |
| 38 | + | if p := previewOf(t, m.Layout(), 9); p.Peek || !p.Focused { | |
| 39 | + | t.Fatalf("float %+v", p) | |
| 40 | + | } | |
| 41 | + | } else { | |
| 42 | + | if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused { | |
| 43 | + | t.Fatalf("column %+v", p) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | m.ToggleOverview() | |
| 47 | + | if columns { | |
| 48 | + | wantOrder(t, w, 1, 2, 3, 9) | |
| 49 | + | } else { | |
| 50 | + | wantOrder(t, w, 9, 1, 2, 3) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestOverviewStackLeftWithoutStashRotatesBackward(t *testing.T) { | |
| 56 | + | m := stackMonitor() | |
| 57 | + | w := m.Current() | |
| 58 | + | m.ToggleOverview() | |
| 59 | + | m.OverviewMove(-1, 0) | |
| 60 | + | if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused { | |
| 61 | + | t.Fatalf("left did not rotate to columns: %+v", p) | |
| 62 | + | } | |
| 63 | + | m.CancelOverview() | |
| 64 | + | if id, _ := w.Focused(); id != 9 { | |
| 65 | + | t.Fatalf("cancel focus %d", id) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestOverviewStackEscapePreservesFocusAndOrder(t *testing.T) { | |
| 70 | + | for _, columns := range []bool{false, true} { | |
| 71 | + | m := stackMonitor() | |
| 72 | + | w := m.Current() | |
| 73 | + | if columns { | |
| 74 | + | w.FocusID(3) | |
| 75 | + | } | |
| 76 | + | before := slices.Clone(order(w)) | |
| 77 | + | focused, _ := w.Focused() | |
| 78 | + | m.ToggleOverview() | |
| 79 | + | m.OverviewMove(1, 0) | |
| 80 | + | m.CancelOverview() | |
| 81 | + | if id, _ := w.Focused(); id != focused || !slices.Equal(order(w), before) { | |
| 82 | + | t.Fatalf("columns %v: focus %d want %d, order %v want %v", columns, id, focused, order(w), before) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | // When the focused float is not last (a dialog sits over it), Escape | |
| 86 | + | // must restore its position too; FocusID alone would raise it. | |
| 87 | + | m := stackMonitor() | |
| 88 | + | w := m.Current() | |
| 89 | + | w.AddFloating(10, 20, 20) | |
| 90 | + | w.FocusID(9) | |
| 91 | + | before := slices.Clone(order(w)) | |
| 92 | + | m.ToggleOverview() | |
| 93 | + | m.OverviewMove(1, 0) | |
| 94 | + | m.CancelOverview() | |
| 95 | + | if id, _ := w.Focused(); id != 9 || !slices.Equal(order(w), before) { | |
| 96 | + | t.Fatalf("float focus %d order %v want %v", id, order(w), before) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestOverviewStackClickAndHitPriority(t *testing.T) { | |
| 101 | + | for _, frontColumn := range []bool{false, true} { | |
| 102 | + | m := stackMonitor() | |
| 103 | + | w := m.Current() | |
| 104 | + | if frontColumn { | |
| 105 | + | w.FocusID(3) | |
| 106 | + | } | |
| 107 | + | m.ToggleOverview() | |
| 108 | + | ps := m.Layout() | |
| 109 | + | front, back := previewOf(t, ps, 9), previewOf(t, ps, 3) | |
| 110 | + | if frontColumn { | |
| 111 | + | front, back = back, front | |
| 112 | + | } | |
| 113 | + | // The overlap with the peeking column tile must hit the front. | |
| 114 | + | x, y := back.Rect.X+back.Rect.W/2, max(front.Rect.Y, back.Rect.Y)+back.Rect.H/2 | |
| 115 | + | if frontColumn { | |
| 116 | + | x = front.Rect.X + front.Rect.W/2 | |
| 117 | + | } | |
| 118 | + | if got := m.overviewAt(float64(x), float64(y)); got != front.ID { | |
| 119 | + | t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect) | |
| 120 | + | } | |
| 121 | + | // The up/right exposed corner belongs to the peeking card. | |
| 122 | + | x, y = back.Rect.X+back.Rect.W-1, back.Rect.Y+1 | |
| 123 | + | if got := m.overviewAt(float64(x), float64(y)); got != back.ID { | |
| 124 | + | t.Fatalf("peek hit %d want %d", got, back.ID) | |
| 125 | + | } | |
| 126 | + | m.OverviewPick(back.ID) | |
| 127 | + | if id, _ := w.Focused(); id != back.ID || m.ov.open { | |
| 128 | + | t.Fatalf("picked %d, focus %d", back.ID, id) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestOverviewStackStashDialogNeighbor(t *testing.T) { | |
| 134 | + | m := stackMonitor() | |
| 135 | + | w := m.Current() | |
| 136 | + | w.AddFloating(10, 20, 20) | |
| 137 | + | w.FocusID(9) | |
| 138 | + | w.AddWindow(11) | |
| 139 | + | w.FocusID(11) | |
| 140 | + | w.ToggleWindowStash() | |
| 141 | + | w.FocusID(9) | |
| 142 | + | m.ToggleOverview() | |
| 143 | + | if p := previewOf(t, m.Layout(), 10); !p.Hidden { | |
| 144 | + | t.Fatalf("dialog %+v", p) | |
| 145 | + | } | |
| 146 | + | m.OverviewMove(-1, 0) // single-window float sends behind before stash | |
| 147 | + | if m.stackFront(w).kind != stackColumns || m.cardAt(w) >= 0 { | |
| 148 | + | t.Fatal("float did not send behind") | |
| 149 | + | } | |
| 150 | + | m.OverviewMove(-1, 0) | |
| 151 | + | m.OverviewMove(-1, 0) | |
| 152 | + | m.OverviewMove(-1, 0) | |
| 153 | + | if m.cardAt(w) < 0 { | |
| 154 | + | t.Fatal("left from first column did not enter stash") | |
| 155 | + | } | |
| 156 | + | m.OverviewMove(1, 0) | |
| 157 | + | if m.cardAt(w) >= 0 { | |
| 158 | + | t.Fatal("stash exit did not return to card") | |
| 159 | + | } | |
| 160 | + | m.CancelOverview() | |
| 161 | + | m = stackMonitor() | |
| 162 | + | m.Focus(1) | |
| 163 | + | m.AddFloating(12, 300, 200) | |
| 164 | + | m.Focus(0) | |
| 165 | + | m.ToggleOverview() | |
| 166 | + | if p, q := previewOf(t, m.Layout(), 12), previewOf(t, m.Layout(), 4); p.Hidden || q.Hidden || !p.Peek || !q.Peek || p.Focused || q.Focused { | |
| 167 | + | t.Fatalf("neighbor %+v %+v", p, q) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestOverviewMultipleCoveringCards(t *testing.T) { | |
| 172 | + | m := stackMonitor() | |
| 173 | + | w := m.Current() | |
| 174 | + | w.AddFloating(8, 300, 200) | |
| 175 | + | w.AddFloating(7, 300, 200) | |
| 176 | + | w.AddFloating(6, 300, 200) | |
| 177 | + | m.ToggleOverview() | |
| 178 | + | if p := previewOf(t, m.Layout(), 6); p.Peek { | |
| 179 | + | t.Fatalf("front %+v", p) | |
| 180 | + | } | |
| 181 | + | if p := previewOf(t, m.Layout(), 7); !p.Peek { | |
| 182 | + | t.Fatalf("peek %+v", p) | |
| 183 | + | } | |
| 184 | + | if p := previewOf(t, m.Layout(), 9); !p.Hidden { | |
| 185 | + | t.Fatalf("far card %+v", p) | |
| 186 | + | } | |
| 187 | + | m.OverviewMove(0, -1) | |
| 188 | + | if p := previewOf(t, m.Layout(), 7); p.Peek || !p.Focused { | |
| 189 | + | t.Fatalf("up %+v", p) | |
| 190 | + | } | |
| 191 | + | if p := previewOf(t, m.Layout(), 6); !p.Peek || p.Rect.Y <= previewOf(t, m.Layout(), 7).Rect.Y { | |
| 192 | + | t.Fatalf("passed card %+v", p) | |
| 193 | + | } | |
| 194 | + | m.OverviewMove(0, 1) | |
| 195 | + | if p := previewOf(t, m.Layout(), 6); p.Peek { | |
| 196 | + | t.Fatal("down did not return to screen front") | |
| 197 | + | } | |
| 198 | + | m.CancelOverview() | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestOverviewFloatWithoutColumns(t *testing.T) { | |
| 202 | + | m := monitor() | |
| 203 | + | m.SetOutput(300, 200) | |
| 204 | + | m.SetBorder(2) | |
| 205 | + | w := m.Current() | |
| 206 | + | w.AddFloating(9, 300, 200) | |
| 207 | + | w.AddFloating(8, 300, 200) | |
| 208 | + | m.ToggleOverview() | |
| 209 | + | if p := previewOf(t, m.Layout(), 8); p.Peek || p.Hidden { | |
| 210 | + | t.Fatalf("front float %+v", p) | |
| 211 | + | } | |
| 212 | + | m.OverviewMove(-1, 0) | |
| 213 | + | if p := previewOf(t, m.Layout(), 9); p.Peek || p.Hidden { | |
| 214 | + | t.Fatalf("back float %+v", p) | |
| 215 | + | } | |
| 216 | + | m.ToggleOverview() | |
| 217 | + | if id, _ := w.Focused(); id != 9 { | |
| 218 | + | t.Fatalf("focus %d", id) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestOverviewStackRowChangeResetsRotation(t *testing.T) { | |
| 223 | + | m := stackMonitor() | |
| 224 | + | m.ToggleOverview() | |
| 225 | + | m.OverviewMove(0, -1) | |
| 226 | + | if m.stackFront(m.Current()).kind != stackColumns { | |
| 227 | + | t.Fatal("stack did not move to columns") | |
| 228 | + | } | |
| 229 | + | m.OverviewMove(0, -1) // at stack end: workspace above, if present | |
| 230 | + | m.OverviewMove(0, 1) | |
| 231 | + | if m.stackFront(m.Current()) != (stackItem{stackFloat, 9}) { | |
| 232 | + | t.Fatal("row change retained cursor") | |
| 233 | + | } | |
| 234 | + | m.CancelOverview() | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestOverviewStackInvalidatedOnRemoval(t *testing.T) { | |
| 238 | + | m := stackMonitor() | |
| 239 | + | w := m.Current() | |
| 240 | + | m.ToggleOverview() | |
| 241 | + | m.OverviewMove(1, 0) | |
| 242 | + | w.RemoveWindow(9) | |
| 243 | + | m.ToggleOverview() | |
| 244 | + | if id, _ := w.Focused(); id != 3 { | |
| 245 | + | t.Fatalf("removed selection focused %d", id) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // Stash navigation returns to the current front card without changing its cursor. | |
| 250 | + | func TestOverviewStashRightBringsColumnsFront(t *testing.T) { | |
| 251 | + | m := stackMonitor() | |
| 252 | + | w := m.Current() | |
| 253 | + | w.FocusID(1) | |
| 254 | + | w.ToggleWindowStash() | |
| 255 | + | w.FocusID(9) | |
| 256 | + | m.ToggleOverview() | |
| 257 | + | m.OverviewMove(-1, 0) // float to columns | |
| 258 | + | m.OverviewMove(-1, 0) // first column to stash | |
| 259 | + | if m.cardAt(w) < 0 { | |
| 260 | + | t.Fatal("not in stash") | |
| 261 | + | } | |
| 262 | + | m.OverviewMove(1, 0) | |
| 263 | + | if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns { | |
| 264 | + | t.Fatal("not returned to front column card") | |
| 265 | + | } | |
| 266 | + | m.ToggleOverview() | |
| 267 | + | if id, _ := w.Focused(); id != 2 { | |
| 268 | + | t.Fatalf("focus %d", id) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Stack previews are not floats: the overview draws no float veil. | |
| 273 | + | func TestOverviewStackNoFloatVeil(t *testing.T) { | |
| 274 | + | m := stackMonitor() | |
| 275 | + | m.ToggleOverview() | |
| 276 | + | if d := floatDim(m.Layout(), Rect{W: 300, H: 200}, 0.3); d != 0 { | |
| 277 | + | t.Fatalf("veil %v in the overview", d) | |
| 278 | + | } | |
| 279 | + | } | |
| @@ -0,0 +1,279 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func stackMonitor() *Monitor { | |||
| 9 | + | m := overviewMonitor() | |||
| 10 | + | m.SetBorder(2) | |||
| 11 | + | m.AddFloating(9, 300, 200) | |||
| 12 | + | return m | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestOverviewCoveringStackNavigation(t *testing.T) { | |||
| 16 | + | for _, columns := range []bool{false, true} { | |||
| 17 | + | m := stackMonitor() | |||
| 18 | + | w := m.Current() | |||
| 19 | + | if columns { | |||
| 20 | + | w.FocusID(3) | |||
| 21 | + | } | |||
| 22 | + | before := slices.Clone(order(w)) | |||
| 23 | + | m.ToggleOverview() | |||
| 24 | + | ps := m.Layout() | |||
| 25 | + | f, c := previewOf(t, ps, 9), previewOf(t, ps, 3) | |||
| 26 | + | if f.Peek != columns || c.Peek == columns { | |||
| 27 | + | t.Fatalf("front float %+v columns %+v", f, c) | |||
| 28 | + | } | |||
| 29 | + | if !slices.Equal(order(w), before) { | |||
| 30 | + | t.Fatal("opening changed float order") | |||
| 31 | + | } | |||
| 32 | + | if columns { | |||
| 33 | + | m.OverviewMove(0, -1) | |||
| 34 | + | } else { | |||
| 35 | + | m.OverviewMove(-1, 0) | |||
| 36 | + | } | |||
| 37 | + | if columns { | |||
| 38 | + | if p := previewOf(t, m.Layout(), 9); p.Peek || !p.Focused { | |||
| 39 | + | t.Fatalf("float %+v", p) | |||
| 40 | + | } | |||
| 41 | + | } else { | |||
| 42 | + | if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused { | |||
| 43 | + | t.Fatalf("column %+v", p) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | m.ToggleOverview() | |||
| 47 | + | if columns { | |||
| 48 | + | wantOrder(t, w, 1, 2, 3, 9) | |||
| 49 | + | } else { | |||
| 50 | + | wantOrder(t, w, 9, 1, 2, 3) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestOverviewStackLeftWithoutStashRotatesBackward(t *testing.T) { | |||
| 56 | + | m := stackMonitor() | |||
| 57 | + | w := m.Current() | |||
| 58 | + | m.ToggleOverview() | |||
| 59 | + | m.OverviewMove(-1, 0) | |||
| 60 | + | if p := previewOf(t, m.Layout(), 3); p.Peek || !p.Focused { | |||
| 61 | + | t.Fatalf("left did not rotate to columns: %+v", p) | |||
| 62 | + | } | |||
| 63 | + | m.CancelOverview() | |||
| 64 | + | if id, _ := w.Focused(); id != 9 { | |||
| 65 | + | t.Fatalf("cancel focus %d", id) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestOverviewStackEscapePreservesFocusAndOrder(t *testing.T) { | |||
| 70 | + | for _, columns := range []bool{false, true} { | |||
| 71 | + | m := stackMonitor() | |||
| 72 | + | w := m.Current() | |||
| 73 | + | if columns { | |||
| 74 | + | w.FocusID(3) | |||
| 75 | + | } | |||
| 76 | + | before := slices.Clone(order(w)) | |||
| 77 | + | focused, _ := w.Focused() | |||
| 78 | + | m.ToggleOverview() | |||
| 79 | + | m.OverviewMove(1, 0) | |||
| 80 | + | m.CancelOverview() | |||
| 81 | + | if id, _ := w.Focused(); id != focused || !slices.Equal(order(w), before) { | |||
| 82 | + | t.Fatalf("columns %v: focus %d want %d, order %v want %v", columns, id, focused, order(w), before) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | // When the focused float is not last (a dialog sits over it), Escape | |||
| 86 | + | // must restore its position too; FocusID alone would raise it. | |||
| 87 | + | m := stackMonitor() | |||
| 88 | + | w := m.Current() | |||
| 89 | + | w.AddFloating(10, 20, 20) | |||
| 90 | + | w.FocusID(9) | |||
| 91 | + | before := slices.Clone(order(w)) | |||
| 92 | + | m.ToggleOverview() | |||
| 93 | + | m.OverviewMove(1, 0) | |||
| 94 | + | m.CancelOverview() | |||
| 95 | + | if id, _ := w.Focused(); id != 9 || !slices.Equal(order(w), before) { | |||
| 96 | + | t.Fatalf("float focus %d order %v want %v", id, order(w), before) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestOverviewStackClickAndHitPriority(t *testing.T) { | |||
| 101 | + | for _, frontColumn := range []bool{false, true} { | |||
| 102 | + | m := stackMonitor() | |||
| 103 | + | w := m.Current() | |||
| 104 | + | if frontColumn { | |||
| 105 | + | w.FocusID(3) | |||
| 106 | + | } | |||
| 107 | + | m.ToggleOverview() | |||
| 108 | + | ps := m.Layout() | |||
| 109 | + | front, back := previewOf(t, ps, 9), previewOf(t, ps, 3) | |||
| 110 | + | if frontColumn { | |||
| 111 | + | front, back = back, front | |||
| 112 | + | } | |||
| 113 | + | // The overlap with the peeking column tile must hit the front. | |||
| 114 | + | x, y := back.Rect.X+back.Rect.W/2, max(front.Rect.Y, back.Rect.Y)+back.Rect.H/2 | |||
| 115 | + | if frontColumn { | |||
| 116 | + | x = front.Rect.X + front.Rect.W/2 | |||
| 117 | + | } | |||
| 118 | + | if got := m.overviewAt(float64(x), float64(y)); got != front.ID { | |||
| 119 | + | t.Fatalf("overlap hit %d want %d (front %+v back %+v)", got, front.ID, front.Rect, back.Rect) | |||
| 120 | + | } | |||
| 121 | + | // The up/right exposed corner belongs to the peeking card. | |||
| 122 | + | x, y = back.Rect.X+back.Rect.W-1, back.Rect.Y+1 | |||
| 123 | + | if got := m.overviewAt(float64(x), float64(y)); got != back.ID { | |||
| 124 | + | t.Fatalf("peek hit %d want %d", got, back.ID) | |||
| 125 | + | } | |||
| 126 | + | m.OverviewPick(back.ID) | |||
| 127 | + | if id, _ := w.Focused(); id != back.ID || m.ov.open { | |||
| 128 | + | t.Fatalf("picked %d, focus %d", back.ID, id) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestOverviewStackStashDialogNeighbor(t *testing.T) { | |||
| 134 | + | m := stackMonitor() | |||
| 135 | + | w := m.Current() | |||
| 136 | + | w.AddFloating(10, 20, 20) | |||
| 137 | + | w.FocusID(9) | |||
| 138 | + | w.AddWindow(11) | |||
| 139 | + | w.FocusID(11) | |||
| 140 | + | w.ToggleWindowStash() | |||
| 141 | + | w.FocusID(9) | |||
| 142 | + | m.ToggleOverview() | |||
| 143 | + | if p := previewOf(t, m.Layout(), 10); !p.Hidden { | |||
| 144 | + | t.Fatalf("dialog %+v", p) | |||
| 145 | + | } | |||
| 146 | + | m.OverviewMove(-1, 0) // single-window float sends behind before stash | |||
| 147 | + | if m.stackFront(w).kind != stackColumns || m.cardAt(w) >= 0 { | |||
| 148 | + | t.Fatal("float did not send behind") | |||
| 149 | + | } | |||
| 150 | + | m.OverviewMove(-1, 0) | |||
| 151 | + | m.OverviewMove(-1, 0) | |||
| 152 | + | m.OverviewMove(-1, 0) | |||
| 153 | + | if m.cardAt(w) < 0 { | |||
| 154 | + | t.Fatal("left from first column did not enter stash") | |||
| 155 | + | } | |||
| 156 | + | m.OverviewMove(1, 0) | |||
| 157 | + | if m.cardAt(w) >= 0 { | |||
| 158 | + | t.Fatal("stash exit did not return to card") | |||
| 159 | + | } | |||
| 160 | + | m.CancelOverview() | |||
| 161 | + | m = stackMonitor() | |||
| 162 | + | m.Focus(1) | |||
| 163 | + | m.AddFloating(12, 300, 200) | |||
| 164 | + | m.Focus(0) | |||
| 165 | + | m.ToggleOverview() | |||
| 166 | + | if p, q := previewOf(t, m.Layout(), 12), previewOf(t, m.Layout(), 4); p.Hidden || q.Hidden || !p.Peek || !q.Peek || p.Focused || q.Focused { | |||
| 167 | + | t.Fatalf("neighbor %+v %+v", p, q) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestOverviewMultipleCoveringCards(t *testing.T) { | |||
| 172 | + | m := stackMonitor() | |||
| 173 | + | w := m.Current() | |||
| 174 | + | w.AddFloating(8, 300, 200) | |||
| 175 | + | w.AddFloating(7, 300, 200) | |||
| 176 | + | w.AddFloating(6, 300, 200) | |||
| 177 | + | m.ToggleOverview() | |||
| 178 | + | if p := previewOf(t, m.Layout(), 6); p.Peek { | |||
| 179 | + | t.Fatalf("front %+v", p) | |||
| 180 | + | } | |||
| 181 | + | if p := previewOf(t, m.Layout(), 7); !p.Peek { | |||
| 182 | + | t.Fatalf("peek %+v", p) | |||
| 183 | + | } | |||
| 184 | + | if p := previewOf(t, m.Layout(), 9); !p.Hidden { | |||
| 185 | + | t.Fatalf("far card %+v", p) | |||
| 186 | + | } | |||
| 187 | + | m.OverviewMove(0, -1) | |||
| 188 | + | if p := previewOf(t, m.Layout(), 7); p.Peek || !p.Focused { | |||
| 189 | + | t.Fatalf("up %+v", p) | |||
| 190 | + | } | |||
| 191 | + | if p := previewOf(t, m.Layout(), 6); !p.Peek || p.Rect.Y <= previewOf(t, m.Layout(), 7).Rect.Y { | |||
| 192 | + | t.Fatalf("passed card %+v", p) | |||
| 193 | + | } | |||
| 194 | + | m.OverviewMove(0, 1) | |||
| 195 | + | if p := previewOf(t, m.Layout(), 6); p.Peek { | |||
| 196 | + | t.Fatal("down did not return to screen front") | |||
| 197 | + | } | |||
| 198 | + | m.CancelOverview() | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestOverviewFloatWithoutColumns(t *testing.T) { | |||
| 202 | + | m := monitor() | |||
| 203 | + | m.SetOutput(300, 200) | |||
| 204 | + | m.SetBorder(2) | |||
| 205 | + | w := m.Current() | |||
| 206 | + | w.AddFloating(9, 300, 200) | |||
| 207 | + | w.AddFloating(8, 300, 200) | |||
| 208 | + | m.ToggleOverview() | |||
| 209 | + | if p := previewOf(t, m.Layout(), 8); p.Peek || p.Hidden { | |||
| 210 | + | t.Fatalf("front float %+v", p) | |||
| 211 | + | } | |||
| 212 | + | m.OverviewMove(-1, 0) | |||
| 213 | + | if p := previewOf(t, m.Layout(), 9); p.Peek || p.Hidden { | |||
| 214 | + | t.Fatalf("back float %+v", p) | |||
| 215 | + | } | |||
| 216 | + | m.ToggleOverview() | |||
| 217 | + | if id, _ := w.Focused(); id != 9 { | |||
| 218 | + | t.Fatalf("focus %d", id) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestOverviewStackRowChangeResetsRotation(t *testing.T) { | |||
| 223 | + | m := stackMonitor() | |||
| 224 | + | m.ToggleOverview() | |||
| 225 | + | m.OverviewMove(0, -1) | |||
| 226 | + | if m.stackFront(m.Current()).kind != stackColumns { | |||
| 227 | + | t.Fatal("stack did not move to columns") | |||
| 228 | + | } | |||
| 229 | + | m.OverviewMove(0, -1) // at stack end: workspace above, if present | |||
| 230 | + | m.OverviewMove(0, 1) | |||
| 231 | + | if m.stackFront(m.Current()) != (stackItem{stackFloat, 9}) { | |||
| 232 | + | t.Fatal("row change retained cursor") | |||
| 233 | + | } | |||
| 234 | + | m.CancelOverview() | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func TestOverviewStackInvalidatedOnRemoval(t *testing.T) { | |||
| 238 | + | m := stackMonitor() | |||
| 239 | + | w := m.Current() | |||
| 240 | + | m.ToggleOverview() | |||
| 241 | + | m.OverviewMove(1, 0) | |||
| 242 | + | w.RemoveWindow(9) | |||
| 243 | + | m.ToggleOverview() | |||
| 244 | + | if id, _ := w.Focused(); id != 3 { | |||
| 245 | + | t.Fatalf("removed selection focused %d", id) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // Stash navigation returns to the current front card without changing its cursor. | |||
| 250 | + | func TestOverviewStashRightBringsColumnsFront(t *testing.T) { | |||
| 251 | + | m := stackMonitor() | |||
| 252 | + | w := m.Current() | |||
| 253 | + | w.FocusID(1) | |||
| 254 | + | w.ToggleWindowStash() | |||
| 255 | + | w.FocusID(9) | |||
| 256 | + | m.ToggleOverview() | |||
| 257 | + | m.OverviewMove(-1, 0) // float to columns | |||
| 258 | + | m.OverviewMove(-1, 0) // first column to stash | |||
| 259 | + | if m.cardAt(w) < 0 { | |||
| 260 | + | t.Fatal("not in stash") | |||
| 261 | + | } | |||
| 262 | + | m.OverviewMove(1, 0) | |||
| 263 | + | if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns { | |||
| 264 | + | t.Fatal("not returned to front column card") | |||
| 265 | + | } | |||
| 266 | + | m.ToggleOverview() | |||
| 267 | + | if id, _ := w.Focused(); id != 2 { | |||
| 268 | + | t.Fatalf("focus %d", id) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // Stack previews are not floats: the overview draws no float veil. | |||
| 273 | + | func TestOverviewStackNoFloatVeil(t *testing.T) { | |||
| 274 | + | m := stackMonitor() | |||
| 275 | + | m.ToggleOverview() | |||
| 276 | + | if d := floatDim(m.Layout(), Rect{W: 300, H: 200}, 0.3); d != 0 { | |||
| 277 | + | t.Fatalf("veil %v in the overview", d) | |||
| 278 | + | } | |||
| 279 | + | } | |||
M internal/core/overview_test.go +110 -20
| @@ -100,13 +100,13 @@ func TestOverviewNavigation(t *testing.T) { | |||
| 100 | 100 | t.Fatalf("entered empty workspace %d", m.Active) | |
| 101 | 101 | } | |
| 102 | 102 | m.CancelOverview() | |
| 103 | - | if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.overview { | |
| 103 | + | if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.ov.open { | |
| 104 | 104 | t.Fatalf("cancel: %d on %d", id, m.Active) | |
| 105 | 105 | } | |
| 106 | 106 | m.ToggleOverview() | |
| 107 | 107 | m.OverviewMove(0, 1) | |
| 108 | 108 | m.ToggleOverview() | |
| 109 | - | if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.overview { | |
| 109 | + | if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.ov.open { | |
| 110 | 110 | t.Fatalf("confirm: %d on %d", id, m.Active) | |
| 111 | 111 | } | |
| 112 | 112 | } | |
| @@ -121,7 +121,7 @@ func TestOverviewPick(t *testing.T) { | |||
| 121 | 121 | t.Fatalf("hit %d", id) | |
| 122 | 122 | } | |
| 123 | 123 | m.OverviewPick(id) | |
| 124 | - | if f, _ := m.Focused(); f != 4 || m.overview { | |
| 124 | + | if f, _ := m.Focused(); f != 4 || m.ov.open { | |
| 125 | 125 | t.Fatalf("pick: %d", f) | |
| 126 | 126 | } | |
| 127 | 127 | } | |
| @@ -170,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) { | |||
| 170 | 170 | } | |
| 171 | 171 | m.OverviewMove(-1, 0) // nothing else on its row | |
| 172 | 172 | m.ToggleOverview() | |
| 173 | - | if f, _ := m.Focused(); f != 2 || m.overview || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen { | |
| 173 | + | if f, _ := m.Focused(); f != 2 || m.ov.open || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen { | |
| 174 | 174 | t.Fatalf("confirm: %d on %d", f, m.Active) | |
| 175 | 175 | } | |
| 176 | 176 | }) | |
| @@ -193,7 +193,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | |||
| 193 | 193 | } | |
| 194 | 194 | m.ToggleOverview() | |
| 195 | 195 | m.OverviewPick(2) | |
| 196 | - | if f, _ := m.Focused(); f != 2 || m.overview { | |
| 196 | + | if f, _ := m.Focused(); f != 2 || m.ov.open { | |
| 197 | 197 | t.Fatalf("pick: %d", f) | |
| 198 | 198 | } | |
| 199 | 199 | } | |
| @@ -204,7 +204,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |||
| 204 | 204 | m := gameMonitor(OverflowFixed, false) | |
| 205 | 205 | m.Focus(1) | |
| 206 | 206 | m.ToggleOverview() | |
| 207 | - | if !m.overview { | |
| 207 | + | if !m.ov.open { | |
| 208 | 208 | t.Fatal("overview did not open") | |
| 209 | 209 | } | |
| 210 | 210 | ps := m.Layout() | |
| @@ -213,7 +213,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |||
| 213 | 213 | t.Fatalf("game %+v, above %+v", game, above) | |
| 214 | 214 | } | |
| 215 | 215 | m.CancelOverview() | |
| 216 | - | if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.overview || !previewOf(t, m.Layout(), 2).Fullscreen { | |
| 216 | + | if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.ov.open || !previewOf(t, m.Layout(), 2).Fullscreen { | |
| 217 | 217 | t.Fatalf("cancel: %d on %d", f, m.Active) | |
| 218 | 218 | } | |
| 219 | 219 | } | |
| @@ -230,7 +230,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) { | |||
| 230 | 230 | m.ToggleFullscreen() // 2 to its own workspace | |
| 231 | 231 | m.ToggleOverview() | |
| 232 | 232 | game := previewOf(t, m.Layout(), 2) | |
| 233 | - | if !m.overview || !game.Focused || game.Peek || !game.Fullscreen { | |
| 233 | + | if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen { | |
| 234 | 234 | t.Fatalf("game %+v", game) | |
| 235 | 235 | } | |
| 236 | 236 | m.OverviewMove(-1, 0) | |
| @@ -330,6 +330,30 @@ func TestOverviewPileLayout(t *testing.T) { | |||
| 330 | 330 | } | |
| 331 | 331 | } | |
| 332 | 332 | ||
| 333 | + | // A stash pile does not shift a row that fits beside it: the row stays | |
| 334 | + | // centred like the same workspace without a stash. | |
| 335 | + | func TestOverviewPileKeepsRowCentred(t *testing.T) { | |
| 336 | + | m := newMonitor("", "") | |
| 337 | + | m.SetOutput(3000, 1200) | |
| 338 | + | m.SetOverflow(OverflowFixed) | |
| 339 | + | m.SetMaxColumns(3) | |
| 340 | + | for id := WindowID(1); id <= 3; id++ { | |
| 341 | + | m.AddWindow(id) | |
| 342 | + | } | |
| 343 | + | m.ToggleOverview() | |
| 344 | + | want := previewOf(t, m.Layout(), 1).Rect | |
| 345 | + | m.CancelOverview() | |
| 346 | + | w := m.Current() | |
| 347 | + | w.AddWindow(4) | |
| 348 | + | w.FocusID(4) | |
| 349 | + | w.ToggleWindowStash() | |
| 350 | + | w.FocusID(1) | |
| 351 | + | m.ToggleOverview() | |
| 352 | + | if got := previewOf(t, m.Layout(), 1).Rect; got != want { | |
| 353 | + | t.Fatalf("row with a stash at %+v, without at %+v", got, want) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 333 | 357 | // h from column 1 enters the pile on the stash's selection, h/l browse | |
| 334 | 358 | // it, l past its end returns to column 1; Return shows the stash on the | |
| 335 | 359 | // chosen window. | |
| @@ -355,7 +379,7 @@ func TestOverviewPileNavigation(t *testing.T) { | |||
| 355 | 379 | } | |
| 356 | 380 | m.OverviewMove(1, 0) | |
| 357 | 381 | m.ToggleOverview() | |
| 358 | - | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.overview { | |
| 382 | + | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open { | |
| 359 | 383 | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | |
| 360 | 384 | } | |
| 361 | 385 | // Back in on the stash's selection (6), then l past the last entry | |
| @@ -420,24 +444,90 @@ func TestOverviewPileOnly(t *testing.T) { | |||
| 420 | 444 | } | |
| 421 | 445 | } | |
| 422 | 446 | ||
| 423 | - | // Fixed overflow's spiral is laid out as a row: no preview overlaps. | |
| 424 | - | func TestOverviewFixedOverflowRow(t *testing.T) { | |
| 447 | + | // Fixed overflow previews use the actual buffer geometry, including the | |
| 448 | + | // spiral and window stacks, rather than laying full-height columns in a row. | |
| 449 | + | func TestOverviewFixedOverflowGeometry(t *testing.T) { | |
| 450 | + | for _, expanded := range []bool{false, true} { | |
| 451 | + | t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) { | |
| 452 | + | m := newMonitor("", "") | |
| 453 | + | m.SetOutput(600, 400) | |
| 454 | + | m.SetOverflow(OverflowFixed) | |
| 455 | + | m.SetMaxColumns(3) | |
| 456 | + | for id := WindowID(1); id <= 5; id++ { | |
| 457 | + | m.AddWindow(id) | |
| 458 | + | } | |
| 459 | + | w := m.Current() | |
| 460 | + | if expanded { | |
| 461 | + | w.Columns[2].Expanded = true | |
| 462 | + | } | |
| 463 | + | w.Columns[3].Windows = append(w.Columns[3].Windows, 6) | |
| 464 | + | w.Columns[3].Focus = 1 | |
| 465 | + | w.FocusID(6) | |
| 466 | + | screen := w.Layout() | |
| 467 | + | m.ToggleOverview() | |
| 468 | + | if _, span, _ := w.previewTiles(); span != w.Usable.W { | |
| 469 | + | t.Fatalf("span %d, usable %d", span, w.Usable.W) | |
| 470 | + | } | |
| 471 | + | ps := m.Layout() | |
| 472 | + | z := previewOf(t, ps, 6).Preview | |
| 473 | + | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |
| 474 | + | anchor, previewAnchor := previewOf(t, screen, 1).Rect, previewOf(t, ps, 1).Rect | |
| 475 | + | for _, id := range []WindowID{1, 2, 3, 4, 5, 6} { | |
| 476 | + | r, p := previewOf(t, screen, id).Rect, previewOf(t, ps, id) | |
| 477 | + | if p.Hidden || p.Preview != z || math.Abs(float64(p.Rect.W-scale(r.W))) > 1 || math.Abs(float64(p.Rect.H-scale(r.H))) > 1 || math.Abs(float64(p.Rect.X-previewAnchor.X-scale(r.X-anchor.X))) > 1 || math.Abs(float64(p.Rect.Y-previewAnchor.Y-scale(r.Y-anchor.Y))) > 1 { | |
| 478 | + | t.Fatalf("%d: screen %+v preview %+v anchor %+v %+v", id, r, p, anchor, previewAnchor) | |
| 479 | + | } | |
| 480 | + | if p.Focused != (id == 6) { | |
| 481 | + | t.Fatalf("%d focus: %+v", id, p) | |
| 482 | + | } | |
| 483 | + | for _, other := range []WindowID{1, 2, 3, 4, 5, 6} { | |
| 484 | + | if other > id && p.Rect.Overlaps(previewOf(t, ps, other).Rect) { | |
| 485 | + | t.Fatalf("%d overlaps %d", id, other) | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | if p.Rect.W > 0 && p.Rect.H > 0 && m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) != id { | |
| 489 | + | t.Fatalf("%d not hit at %+v", id, p.Rect) | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | if len(overviewOutline(ps, 2)) != 4 { | |
| 493 | + | t.Fatal("missing selection outline") | |
| 494 | + | } | |
| 495 | + | m.OverviewMove(-1, 0) | |
| 496 | + | if id, _ := m.Focused(); id != 3 { | |
| 497 | + | t.Fatalf("left selected %d", id) | |
| 498 | + | } | |
| 499 | + | m.OverviewMove(1, 0) | |
| 500 | + | if id, _ := m.Focused(); id != 6 { | |
| 501 | + | t.Fatalf("right selected %d", id) | |
| 502 | + | } | |
| 503 | + | }) | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func TestOverviewFixedFullWidthShowsHiddenColumnsCard(t *testing.T) { | |
| 425 | 508 | m := newMonitor("", "") | |
| 426 | - | m.SetOutput(300, 200) | |
| 509 | + | m.SetOutput(600, 400) | |
| 427 | 510 | m.SetOverflow(OverflowFixed) | |
| 428 | - | m.SetMaxColumns(2) | |
| 429 | - | for id := WindowID(1); id <= 4; id++ { | |
| 511 | + | m.SetMaxColumns(3) | |
| 512 | + | for id := WindowID(1); id <= 5; id++ { | |
| 430 | 513 | m.AddWindow(id) | |
| 431 | 514 | } | |
| 515 | + | w := m.Current() | |
| 516 | + | w.ToggleFullWidth() | |
| 432 | 517 | m.ToggleOverview() | |
| 433 | - | ps := m.Layout() | |
| 434 | - | for a := WindowID(1); a <= 4; a++ { | |
| 435 | - | for b := a + 1; b <= 4; b++ { | |
| 436 | - | if ra, rb := previewOf(t, ps, a).Rect, previewOf(t, ps, b).Rect; ra.Overlaps(rb) { | |
| 437 | - | t.Fatalf("%d %+v overlaps %d %+v", a, ra, b, rb) | |
| 438 | - | } | |
| 518 | + | if p := previewOf(t, m.Layout(), 5); p.Hidden || p.Peek || !p.Focused { | |
| 519 | + | t.Fatalf("front %+v", p) | |
| 520 | + | } | |
| 521 | + | for _, id := range []WindowID{1, 2} { | |
| 522 | + | if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek { | |
| 523 | + | t.Fatalf("peek %d %+v", id, p) | |
| 439 | 524 | } | |
| 440 | 525 | } | |
| 526 | + | m.OverviewMove(0, -1) | |
| 527 | + | m.ToggleOverview() | |
| 528 | + | if id, _ := w.Focused(); id != 1 || !w.Columns[0].FullWidth || w.Columns[4].FullWidth { | |
| 529 | + | t.Fatalf("transfer %d %+v", id, w.Columns) | |
| 530 | + | } | |
| 441 | 531 | } | |
| 442 | 532 | ||
| 443 | 533 | // The selected card follows its window: removing an earlier stash entry | |
| @@ -100,13 +100,13 @@ func TestOverviewNavigation(t *testing.T) { | |||||
| 100 | t.Fatalf("entered empty workspace %d", m.Active) | 100 | t.Fatalf("entered empty workspace %d", m.Active) | ||
| 101 | } | 101 | } | ||
| 102 | m.CancelOverview() | 102 | m.CancelOverview() | ||
| 103 | - | if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.overview { | 103 | + | if id, _ := m.Focused(); id != 3 || m.Active != 0 || m.ov.open { |
| 104 | t.Fatalf("cancel: %d on %d", id, m.Active) | 104 | t.Fatalf("cancel: %d on %d", id, m.Active) | ||
| 105 | } | 105 | } | ||
| 106 | m.ToggleOverview() | 106 | m.ToggleOverview() | ||
| 107 | m.OverviewMove(0, 1) | 107 | m.OverviewMove(0, 1) | ||
| 108 | m.ToggleOverview() | 108 | m.ToggleOverview() | ||
| 109 | - | if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.overview { | 109 | + | if id, _ := m.Focused(); id != 4 || m.Active != 1 || m.ov.open { |
| 110 | t.Fatalf("confirm: %d on %d", id, m.Active) | 110 | t.Fatalf("confirm: %d on %d", id, m.Active) | ||
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| @@ -121,7 +121,7 @@ func TestOverviewPick(t *testing.T) { | |||||
| 121 | t.Fatalf("hit %d", id) | 121 | t.Fatalf("hit %d", id) | ||
| 122 | } | 122 | } | ||
| 123 | m.OverviewPick(id) | 123 | m.OverviewPick(id) | ||
| 124 | - | if f, _ := m.Focused(); f != 4 || m.overview { | 124 | + | if f, _ := m.Focused(); f != 4 || m.ov.open { |
| 125 | t.Fatalf("pick: %d", f) | 125 | t.Fatalf("pick: %d", f) | ||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| @@ -170,7 +170,7 @@ func TestOverviewFullscreenFloat(t *testing.T) { | |||||
| 170 | } | 170 | } | ||
| 171 | m.OverviewMove(-1, 0) // nothing else on its row | 171 | m.OverviewMove(-1, 0) // nothing else on its row | ||
| 172 | m.ToggleOverview() | 172 | m.ToggleOverview() | ||
| 173 | - | if f, _ := m.Focused(); f != 2 || m.overview || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen { | 173 | + | if f, _ := m.Focused(); f != 2 || m.ov.open || m.Active != 1 || !previewOf(t, m.Layout(), 2).Fullscreen { |
| 174 | t.Fatalf("confirm: %d on %d", f, m.Active) | 174 | t.Fatalf("confirm: %d on %d", f, m.Active) | ||
| 175 | } | 175 | } | ||
| 176 | }) | 176 | }) | ||
| @@ -193,7 +193,7 @@ func TestOverviewFullscreenFloatPickAndCancel(t *testing.T) { | |||||
| 193 | } | 193 | } | ||
| 194 | m.ToggleOverview() | 194 | m.ToggleOverview() | ||
| 195 | m.OverviewPick(2) | 195 | m.OverviewPick(2) | ||
| 196 | - | if f, _ := m.Focused(); f != 2 || m.overview { | 196 | + | if f, _ := m.Focused(); f != 2 || m.ov.open { |
| 197 | t.Fatalf("pick: %d", f) | 197 | t.Fatalf("pick: %d", f) | ||
| 198 | } | 198 | } | ||
| 199 | } | 199 | } | ||
| @@ -204,7 +204,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |||||
| 204 | m := gameMonitor(OverflowFixed, false) | 204 | m := gameMonitor(OverflowFixed, false) | ||
| 205 | m.Focus(1) | 205 | m.Focus(1) | ||
| 206 | m.ToggleOverview() | 206 | m.ToggleOverview() | ||
| 207 | - | if !m.overview { | 207 | + | if !m.ov.open { |
| 208 | t.Fatal("overview did not open") | 208 | t.Fatal("overview did not open") | ||
| 209 | } | 209 | } | ||
| 210 | ps := m.Layout() | 210 | ps := m.Layout() | ||
| @@ -213,7 +213,7 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) { | |||||
| 213 | t.Fatalf("game %+v, above %+v", game, above) | 213 | t.Fatalf("game %+v, above %+v", game, above) | ||
| 214 | } | 214 | } | ||
| 215 | m.CancelOverview() | 215 | m.CancelOverview() | ||
| 216 | - | if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.overview || !previewOf(t, m.Layout(), 2).Fullscreen { | 216 | + | if f, _ := m.Focused(); f != 2 || m.Active != 1 || m.ov.open || !previewOf(t, m.Layout(), 2).Fullscreen { |
| 217 | t.Fatalf("cancel: %d on %d", f, m.Active) | 217 | t.Fatalf("cancel: %d on %d", f, m.Active) | ||
| 218 | } | 218 | } | ||
| 219 | } | 219 | } | ||
| @@ -230,7 +230,7 @@ func TestOverviewOpensOverFullscreenTile(t *testing.T) { | |||||
| 230 | m.ToggleFullscreen() // 2 to its own workspace | 230 | m.ToggleFullscreen() // 2 to its own workspace | ||
| 231 | m.ToggleOverview() | 231 | m.ToggleOverview() | ||
| 232 | game := previewOf(t, m.Layout(), 2) | 232 | game := previewOf(t, m.Layout(), 2) | ||
| 233 | - | if !m.overview || !game.Focused || game.Peek || !game.Fullscreen { | 233 | + | if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen { |
| 234 | t.Fatalf("game %+v", game) | 234 | t.Fatalf("game %+v", game) | ||
| 235 | } | 235 | } | ||
| 236 | m.OverviewMove(-1, 0) | 236 | m.OverviewMove(-1, 0) | ||
| @@ -330,6 +330,30 @@ func TestOverviewPileLayout(t *testing.T) { | |||||
| 330 | } | 330 | } | ||
| 331 | } | 331 | } | ||
| 332 | 332 | ||||
| 333 | + | // A stash pile does not shift a row that fits beside it: the row stays | |||
| 334 | + | // centred like the same workspace without a stash. | |||
| 335 | + | func TestOverviewPileKeepsRowCentred(t *testing.T) { | |||
| 336 | + | m := newMonitor("", "") | |||
| 337 | + | m.SetOutput(3000, 1200) | |||
| 338 | + | m.SetOverflow(OverflowFixed) | |||
| 339 | + | m.SetMaxColumns(3) | |||
| 340 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 341 | + | m.AddWindow(id) | |||
| 342 | + | } | |||
| 343 | + | m.ToggleOverview() | |||
| 344 | + | want := previewOf(t, m.Layout(), 1).Rect | |||
| 345 | + | m.CancelOverview() | |||
| 346 | + | w := m.Current() | |||
| 347 | + | w.AddWindow(4) | |||
| 348 | + | w.FocusID(4) | |||
| 349 | + | w.ToggleWindowStash() | |||
| 350 | + | w.FocusID(1) | |||
| 351 | + | m.ToggleOverview() | |||
| 352 | + | if got := previewOf(t, m.Layout(), 1).Rect; got != want { | |||
| 353 | + | t.Fatalf("row with a stash at %+v, without at %+v", got, want) | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | ||||
| 333 | // h from column 1 enters the pile on the stash's selection, h/l browse | 357 | // h from column 1 enters the pile on the stash's selection, h/l browse | ||
| 334 | // it, l past its end returns to column 1; Return shows the stash on the | 358 | // it, l past its end returns to column 1; Return shows the stash on the | ||
| 335 | // chosen window. | 359 | // chosen window. | ||
| @@ -355,7 +379,7 @@ func TestOverviewPileNavigation(t *testing.T) { | |||||
| 355 | } | 379 | } | ||
| 356 | m.OverviewMove(1, 0) | 380 | m.OverviewMove(1, 0) | ||
| 357 | m.ToggleOverview() | 381 | m.ToggleOverview() | ||
| 358 | - | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.overview { | 382 | + | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open { |
| 359 | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | 383 | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | ||
| 360 | } | 384 | } | ||
| 361 | // Back in on the stash's selection (6), then l past the last entry | 385 | // Back in on the stash's selection (6), then l past the last entry | ||
| @@ -420,24 +444,90 @@ func TestOverviewPileOnly(t *testing.T) { | |||||
| 420 | } | 444 | } | ||
| 421 | } | 445 | } | ||
| 422 | 446 | ||||
| 423 | - | // Fixed overflow's spiral is laid out as a row: no preview overlaps. | 447 | + | // Fixed overflow previews use the actual buffer geometry, including the |
| 424 | - | func TestOverviewFixedOverflowRow(t *testing.T) { | 448 | + | // spiral and window stacks, rather than laying full-height columns in a row. |
| 449 | + | func TestOverviewFixedOverflowGeometry(t *testing.T) { | |||
| 450 | + | for _, expanded := range []bool{false, true} { | |||
| 451 | + | t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) { | |||
| 452 | + | m := newMonitor("", "") | |||
| 453 | + | m.SetOutput(600, 400) | |||
| 454 | + | m.SetOverflow(OverflowFixed) | |||
| 455 | + | m.SetMaxColumns(3) | |||
| 456 | + | for id := WindowID(1); id <= 5; id++ { | |||
| 457 | + | m.AddWindow(id) | |||
| 458 | + | } | |||
| 459 | + | w := m.Current() | |||
| 460 | + | if expanded { | |||
| 461 | + | w.Columns[2].Expanded = true | |||
| 462 | + | } | |||
| 463 | + | w.Columns[3].Windows = append(w.Columns[3].Windows, 6) | |||
| 464 | + | w.Columns[3].Focus = 1 | |||
| 465 | + | w.FocusID(6) | |||
| 466 | + | screen := w.Layout() | |||
| 467 | + | m.ToggleOverview() | |||
| 468 | + | if _, span, _ := w.previewTiles(); span != w.Usable.W { | |||
| 469 | + | t.Fatalf("span %d, usable %d", span, w.Usable.W) | |||
| 470 | + | } | |||
| 471 | + | ps := m.Layout() | |||
| 472 | + | z := previewOf(t, ps, 6).Preview | |||
| 473 | + | scale := func(v int) int { return int(math.Round(float64(v) * z)) } | |||
| 474 | + | anchor, previewAnchor := previewOf(t, screen, 1).Rect, previewOf(t, ps, 1).Rect | |||
| 475 | + | for _, id := range []WindowID{1, 2, 3, 4, 5, 6} { | |||
| 476 | + | r, p := previewOf(t, screen, id).Rect, previewOf(t, ps, id) | |||
| 477 | + | if p.Hidden || p.Preview != z || math.Abs(float64(p.Rect.W-scale(r.W))) > 1 || math.Abs(float64(p.Rect.H-scale(r.H))) > 1 || math.Abs(float64(p.Rect.X-previewAnchor.X-scale(r.X-anchor.X))) > 1 || math.Abs(float64(p.Rect.Y-previewAnchor.Y-scale(r.Y-anchor.Y))) > 1 { | |||
| 478 | + | t.Fatalf("%d: screen %+v preview %+v anchor %+v %+v", id, r, p, anchor, previewAnchor) | |||
| 479 | + | } | |||
| 480 | + | if p.Focused != (id == 6) { | |||
| 481 | + | t.Fatalf("%d focus: %+v", id, p) | |||
| 482 | + | } | |||
| 483 | + | for _, other := range []WindowID{1, 2, 3, 4, 5, 6} { | |||
| 484 | + | if other > id && p.Rect.Overlaps(previewOf(t, ps, other).Rect) { | |||
| 485 | + | t.Fatalf("%d overlaps %d", id, other) | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | if p.Rect.W > 0 && p.Rect.H > 0 && m.overviewAt(float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2)) != id { | |||
| 489 | + | t.Fatalf("%d not hit at %+v", id, p.Rect) | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | if len(overviewOutline(ps, 2)) != 4 { | |||
| 493 | + | t.Fatal("missing selection outline") | |||
| 494 | + | } | |||
| 495 | + | m.OverviewMove(-1, 0) | |||
| 496 | + | if id, _ := m.Focused(); id != 3 { | |||
| 497 | + | t.Fatalf("left selected %d", id) | |||
| 498 | + | } | |||
| 499 | + | m.OverviewMove(1, 0) | |||
| 500 | + | if id, _ := m.Focused(); id != 6 { | |||
| 501 | + | t.Fatalf("right selected %d", id) | |||
| 502 | + | } | |||
| 503 | + | }) | |||
| 504 | + | } | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func TestOverviewFixedFullWidthShowsHiddenColumnsCard(t *testing.T) { | |||
| 425 | m := newMonitor("", "") | 508 | m := newMonitor("", "") | ||
| 426 | - | m.SetOutput(300, 200) | 509 | + | m.SetOutput(600, 400) |
| 427 | m.SetOverflow(OverflowFixed) | 510 | m.SetOverflow(OverflowFixed) | ||
| 428 | - | m.SetMaxColumns(2) | 511 | + | m.SetMaxColumns(3) |
| 429 | - | for id := WindowID(1); id <= 4; id++ { | 512 | + | for id := WindowID(1); id <= 5; id++ { |
| 430 | m.AddWindow(id) | 513 | m.AddWindow(id) | ||
| 431 | } | 514 | } | ||
| 515 | + | w := m.Current() | |||
| 516 | + | w.ToggleFullWidth() | |||
| 432 | m.ToggleOverview() | 517 | m.ToggleOverview() | ||
| 433 | - | ps := m.Layout() | 518 | + | if p := previewOf(t, m.Layout(), 5); p.Hidden || p.Peek || !p.Focused { |
| 434 | - | for a := WindowID(1); a <= 4; a++ { | 519 | + | t.Fatalf("front %+v", p) |
| 435 | - | for b := a + 1; b <= 4; b++ { | 520 | + | } |
| 436 | - | if ra, rb := previewOf(t, ps, a).Rect, previewOf(t, ps, b).Rect; ra.Overlaps(rb) { | 521 | + | for _, id := range []WindowID{1, 2} { |
| 437 | - | t.Fatalf("%d %+v overlaps %d %+v", a, ra, b, rb) | 522 | + | if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek { |
| 438 | - | } | 523 | + | t.Fatalf("peek %d %+v", id, p) |
| 439 | } | 524 | } | ||
| 440 | } | 525 | } | ||
| 526 | + | m.OverviewMove(0, -1) | |||
| 527 | + | m.ToggleOverview() | |||
| 528 | + | if id, _ := w.Focused(); id != 1 || !w.Columns[0].FullWidth || w.Columns[4].FullWidth { | |||
| 529 | + | t.Fatalf("transfer %d %+v", id, w.Columns) | |||
| 530 | + | } | |||
| 441 | } | 531 | } | ||
| 442 | 532 | ||||
| 443 | // The selected card follows its window: removing an earlier stash entry | 533 | // The selected card follows its window: removing an earlier stash entry | ||
M internal/core/stash.go +5 -1
| @@ -84,6 +84,7 @@ func (w *Workspace) unstash(i int) { | |||
| 84 | 84 | // index. It takes the focus. | |
| 85 | 85 | func (w *Workspace) restore(id WindowID, back *origPlace) { | |
| 86 | 86 | w.floatFocus, w.stashFocus = false, false | |
| 87 | + | w.raiseColumns() | |
| 87 | 88 | if back == nil { | |
| 88 | 89 | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 89 | 90 | return | |
| @@ -106,7 +107,10 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 106 | 107 | // Its column is gone: it comes back expanded, unless another column | |
| 107 | 108 | // was expanded meanwhile (as leaveFullscreen). | |
| 108 | 109 | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | |
| 109 | - | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded}) | |
| 110 | + | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |
| 111 | + | if back.fullWidth { | |
| 112 | + | w.maximize(w.Focus) | |
| 113 | + | } | |
| 110 | 114 | } | |
| 111 | 115 | ||
| 112 | 116 | // rehome is a stashed window moved to another workspace: its former column | |
| @@ -84,6 +84,7 @@ func (w *Workspace) unstash(i int) { | |||||
| 84 | // index. It takes the focus. | 84 | // index. It takes the focus. | ||
| 85 | func (w *Workspace) restore(id WindowID, back *origPlace) { | 85 | func (w *Workspace) restore(id WindowID, back *origPlace) { | ||
| 86 | w.floatFocus, w.stashFocus = false, false | 86 | w.floatFocus, w.stashFocus = false, false | ||
| 87 | + | w.raiseColumns() | |||
| 87 | if back == nil { | 88 | if back == nil { | ||
| 88 | w.addColumn(Column{Windows: []WindowID{id}}) | 89 | w.addColumn(Column{Windows: []WindowID{id}}) | ||
| 89 | return | 90 | return | ||
| @@ -106,7 +107,10 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||||
| 106 | // Its column is gone: it comes back expanded, unless another column | 107 | // Its column is gone: it comes back expanded, unless another column | ||
| 107 | // was expanded meanwhile (as leaveFullscreen). | 108 | // was expanded meanwhile (as leaveFullscreen). | ||
| 108 | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | 109 | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | ||
| 109 | - | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded}) | 110 | + | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) |
| 111 | + | if back.fullWidth { | |||
| 112 | + | w.maximize(w.Focus) | |||
| 113 | + | } | |||
| 110 | } | 114 | } | ||
| 111 | 115 | ||||
| 112 | // rehome is a stashed window moved to another workspace: its former column | 116 | // rehome is a stashed window moved to another workspace: its former column | ||
M internal/ports/messages.go +3 -2
| @@ -1045,8 +1045,9 @@ type SceneWindow struct { | |||
| 1045 | 1045 | // Inset sides carry a separator line inside Rect: the client is drawn | |
| 1046 | 1046 | // inside it. | |
| 1047 | 1047 | Inset Sides | |
| 1048 | - | // Floating windows are drawn over the tiles and their lines. | |
| 1049 | - | Floating bool | |
| 1048 | + | // Floating windows draw at their placed size. Below marks a native | |
| 1049 | + | // covering float behind the column group. | |
| 1050 | + | Floating, Below bool | |
| 1050 | 1051 | // Dim darkens the window, border included, with black at this | |
| 1051 | 1052 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |
| 1052 | 1053 | Dim float64 | |
| @@ -1045,8 +1045,9 @@ type SceneWindow struct { | |||||
| 1045 | // Inset sides carry a separator line inside Rect: the client is drawn | 1045 | // Inset sides carry a separator line inside Rect: the client is drawn | ||
| 1046 | // inside it. | 1046 | // inside it. | ||
| 1047 | Inset Sides | 1047 | Inset Sides | ||
| 1048 | - | // Floating windows are drawn over the tiles and their lines. | 1048 | + | // Floating windows draw at their placed size. Below marks a native |
| 1049 | - | Floating bool | 1049 | + | // covering float behind the column group. |
| 1050 | + | Floating, Below bool | |||
| 1050 | // Dim darkens the window, border included, with black at this | 1051 | // Dim darkens the window, border included, with black at this | ||
| 1051 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | 1052 | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | ||
| 1052 | Dim float64 | 1053 | Dim float64 | ||