feat(core): stash windows in a per-workspace strip on Cmd+S (#49)
* feat(core): stash Cmd+V windows in a per-workspace strip toggle-window-floating now moves the focused tile to the end of its workspace's stash instead of stacking centred floats. The selected stashed window is centred at 80%; its neighbors peek in from the sides (stash.peek, 3% by default) and are dimmed (stash.dim, 0.5). While the stash has the focus, focus-column-left/right and swipes move through it and stop at its ends; toggle-floating-visible hides or shows only the stash, native dialogs stay as they are. - Stash order, selection and hidden state survive fullscreen round trips, workspace and monitor moves; closing selects the right neighbor, else the left one, else the tiles. - Peeks are clickable, their popups wait until selected. - ports.SceneWindow.Dim veils one window; DRM keeps dimmed windows off the overlay plane. - HDR dim alpha is corrected so reference white darkens as on SDR, for floating.dim too. - state.json gains floating, stash_index, stash_count and hidden. * fix(core): keep stash focus and pending fullscreen, keep dimmed windows off scanout - A shown stash with no tiles has the focus: a named workspace handing its stash over, or a stashed window returning from fullscreen while the user is elsewhere, no longer leaves a workspace without a focused window. - Showing the stash applies a fullscreen request made while it was hidden, even when an off-screen tile is fullscreen. - DRM direct scanout refuses a dimmed fullscreen window: its veil is composed. - Test the per-window veil blend and its damage replay on the GPU. * test(core): cover a stash-only origin after a fixed-overflow fullscreen round trip * test(core): wait for the state after the key in stash focus tests * feat(config): add stash.width for the selected stashed window's width * fix(config): limit stash.width to 10-90 so neighbors keep room to peek in * feat(config): replace stash.peek with stash.gap between stashed windows Neighbors now sit stash.gap (2% by default) beside the selected stashed window and show up to the screen edges, instead of peeking a fixed share in from the edges. The space between windows no longer grows with wide screens; stash.width alone sets how much of the neighbors shows. * fix(core): hide the stash when a tile behind it is clicked * feat(config): bind the stash to cmd+s and cmd+shift+s * refactor(core): rename the stash actions to toggle-window-stash and toggle-stash-visible
- commit
- 2df03cd650327afb9e0cbdfb59d89a5466d1a471
- author
- bnema · · 2026-09-28 18:11 UTC
- committer
- GitHub · · 2026-09-28 18:11 UTC
- parent
- 4ef9a1d
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
29 files · +1241 -294
M docs/config.md +17 -6
| @@ -22,7 +22,10 @@ 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` | Separator lines between tiles, in logical pixels; `0` hides them | | |
| 25 | - | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when floats are visible; opacity 0 to 1 | | |
| 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 | | |
| 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 | | |
| 28 | + | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | |
| 26 | 29 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows | | |
| 27 | 30 | | `layout.gaps` | `0` | Space between windows | | |
| 28 | 31 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | |
| @@ -67,7 +70,7 @@ HDR-aware Wayland clients, including games through Vulkan WSI, browsers, and vid | |||
| 67 | 70 | Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app. | |
| 68 | 71 | ||
| 69 | 72 | - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged. | |
| 70 | - | - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. | |
| 73 | + | - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter. | |
| 71 | 74 | - Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped). | |
| 72 | 75 | - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL. | |
| 73 | 76 | ||
| @@ -90,8 +93,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 90 | 93 | | `spawn-terminal` | Run `terminal` | | |
| 91 | 94 | | `close-window` | Close the focused window | | |
| 92 | 95 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | |
| 93 | - | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 94 | - | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | | |
| 96 | + | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | |
| 97 | + | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | | |
| 95 | 98 | | `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 | | |
| 96 | 99 | | `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 | | |
| 97 | 100 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| @@ -107,6 +110,14 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 107 | 110 | | `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) | | |
| 108 | 111 | | `quit` | Exit NeferWL | | |
| 109 | 112 | ||
| 113 | + | ### Stash | |
| 114 | + | ||
| 115 | + | Each workspace has a stash: a horizontal strip of windows set aside with `toggle-window-stash`, in the order they arrived. The selected window is centred over the tiles, `stash.width` of the usable width (80% by default) and 80% of its height. Its left and right neighbors sit `stash.gap` beside it and show up to the screen edges, dimmed (`stash.dim`); a click on one selects it. The others wait off screen. | |
| 116 | + | ||
| 117 | + | 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). | |
| 118 | + | ||
| 119 | + | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | |
| 120 | + | ||
| 110 | 121 | ### Default binds | |
| 111 | 122 | ||
| 112 | 123 | | Keys | Action | | |
| @@ -129,8 +140,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 129 | 140 | | `cmd+r` | `cycle-column-width` | | |
| 130 | 141 | | `cmd+f` | `maximize-column` | | |
| 131 | 142 | | `cmd+shift+f` | `toggle-fullscreen` | | |
| 132 | - | | `cmd+v` | `toggle-window-floating` | | |
| 133 | - | | `cmd+shift+v` | `toggle-floating-visible` | | |
| 143 | + | | `cmd+s` | `toggle-window-stash` | | |
| 144 | + | | `cmd+shift+s` | `toggle-stash-visible` | | |
| 134 | 145 | | `cmd+q` | `close-window` | | |
| 135 | 146 | | `ctrl+alt+backspace` | `quit` | | |
| 136 | 147 | | `ctrl+cmd+space` | `spawn fuzzel` | | |
| @@ -22,7 +22,10 @@ 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` | Separator lines between tiles, in logical pixels; `0` hides them | | 24 | | `border.width` | `2` | Separator lines between tiles, in logical pixels; `0` hides them | | ||
| 25 | - | | `floating.dim` | `0.3` | Black veil over tiles and lower layers when floats are visible; opacity 0 to 1 | | 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 | |
| 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 | | |||
| 28 | + | | `stash.dim` | `0.5` | Black veil over the stash neighbors, borders included; opacity 0 to 1 | | |||
| 26 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows | | 29 | | `border.active` / `border.inactive` | `#808080` / `#111111` | Separator colors around the focused / other windows | | ||
| 27 | | `layout.gaps` | `0` | Space between windows | | 30 | | `layout.gaps` | `0` | Space between windows | | ||
| 28 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | 31 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | ||
| @@ -67,7 +70,7 @@ HDR-aware Wayland clients, including games through Vulkan WSI, browsers, and vid | |||||
| 67 | Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app. | 70 | Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app. | ||
| 68 | 71 | ||||
| 69 | - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged. | 72 | - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged. | ||
| 70 | - | - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. | 73 | + | - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter. |
| 71 | - Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped). | 74 | - Screenshots and capture of HDR outputs are converted to 8-bit sRGB, clipping highlights at SDR white (only the capture is clipped). | ||
| 72 | - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL. | 75 | - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL. | ||
| 73 | 76 | ||||
| @@ -90,8 +93,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 90 | | `spawn-terminal` | Run `terminal` | | 93 | | `spawn-terminal` | Run `terminal` | | ||
| 91 | | `close-window` | Close the focused window | | 94 | | `close-window` | Close the focused window | | ||
| 92 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | 95 | | `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; activating one of them leaves fullscreen | | ||
| 93 | - | | `toggle-window-floating` | Float a tiled window at 80% of usable area; toggle back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | | 96 | + | | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first | |
| 94 | - | | `toggle-floating-visible` | Hide all floats on this workspace when a float has the focus, or show hidden ones again; a new float shows them too. Does nothing under a fullscreen window | | 97 | + | | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window | |
| 95 | | `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 | | 98 | | `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 | | ||
| 96 | | `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 | | 99 | | `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 | | ||
| 97 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | 100 | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | ||
| @@ -107,6 +110,14 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 107 | | `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) | | 110 | | `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) | | ||
| 108 | | `quit` | Exit NeferWL | | 111 | | `quit` | Exit NeferWL | | ||
| 109 | 112 | ||||
| 113 | + | ### Stash | |||
| 114 | + | ||||
| 115 | + | Each workspace has a stash: a horizontal strip of windows set aside with `toggle-window-stash`, in the order they arrived. The selected window is centred over the tiles, `stash.width` of the usable width (80% by default) and 80% of its height. Its left and right neighbors sit `stash.gap` beside it and show up to the screen edges, dimmed (`stash.dim`); a click on one selects it. The others wait off screen. | |||
| 116 | + | ||||
| 117 | + | 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). | |||
| 118 | + | ||||
| 119 | + | Native floating windows (dialogs, file pickers) are not in the stash: they stay centred above it. | |||
| 120 | + | ||||
| 110 | ### Default binds | 121 | ### Default binds | ||
| 111 | 122 | ||||
| 112 | | Keys | Action | | 123 | | Keys | Action | | ||
| @@ -129,8 +140,8 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 129 | | `cmd+r` | `cycle-column-width` | | 140 | | `cmd+r` | `cycle-column-width` | | ||
| 130 | | `cmd+f` | `maximize-column` | | 141 | | `cmd+f` | `maximize-column` | | ||
| 131 | | `cmd+shift+f` | `toggle-fullscreen` | | 142 | | `cmd+shift+f` | `toggle-fullscreen` | | ||
| 132 | - | | `cmd+v` | `toggle-window-floating` | | 143 | + | | `cmd+s` | `toggle-window-stash` | |
| 133 | - | | `cmd+shift+v` | `toggle-floating-visible` | | 144 | + | | `cmd+shift+s` | `toggle-stash-visible` | |
| 134 | | `cmd+q` | `close-window` | | 145 | | `cmd+q` | `close-window` | | ||
| 135 | | `ctrl+alt+backspace` | `quit` | | 146 | | `ctrl+alt+backspace` | `quit` | | ||
| 136 | | `ctrl+cmd+space` | `spawn fuzzel` | | 147 | | `ctrl+cmd+space` | `spawn fuzzel` | | ||
M docs/desktop.md +8 -2
| @@ -22,7 +22,7 @@ grim -g "$(slurp)" "$file" && wl-copy < "$file" | |||
| 22 | 22 | ``` | |
| 23 | 23 | ||
| 24 | 24 | ```text | |
| 25 | - | bind.cmd+shift+s = spawn screenshot-area | |
| 25 | + | bind.cmd+shift+p = spawn screenshot-area | |
| 26 | 26 | ``` | |
| 27 | 27 | ||
| 28 | 28 | Make the script executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. It can use any shell, including fish (`#!/usr/bin/env fish`). | |
| @@ -96,7 +96,8 @@ While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland so | |||
| 96 | 96 | ||
| 97 | 97 | - every output, with its active numbered workspace, workspace count and the name of the workspace on screen; | |
| 98 | 98 | - the focused output and window; | |
| 99 | - | - every window, with its app ID, PID, output and workspace. | |
| 99 | + | - every window, with its app ID, PID, output, workspace and whether it is on screen (`visible`); | |
| 100 | + | - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden. | |
| 100 | 101 | ||
| 101 | 102 | ```sh | |
| 102 | 103 | neferwl state # the whole state as JSON | |
| @@ -104,3 +105,8 @@ neferwl state output-of "$PID" # the output of that process's window, or i | |||
| 104 | 105 | ``` | |
| 105 | 106 | ||
| 106 | 107 | `output-of` finds application windows only, because a bar has no single output. | |
| 108 | + | ||
| 109 | + | ```sh | |
| 110 | + | # Windows waiting in hidden stashes | |
| 111 | + | neferwl state | jq '[.windows[] | select(.hidden)] | length' | |
| 112 | + | ``` | |
| @@ -22,7 +22,7 @@ grim -g "$(slurp)" "$file" && wl-copy < "$file" | |||||
| 22 | ``` | 22 | ``` | ||
| 23 | 23 | ||||
| 24 | ```text | 24 | ```text | ||
| 25 | - | bind.cmd+shift+s = spawn screenshot-area | 25 | + | bind.cmd+shift+p = spawn screenshot-area |
| 26 | ``` | 26 | ``` | ||
| 27 | 27 | ||||
| 28 | Make the script executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. It can use any shell, including fish (`#!/usr/bin/env fish`). | 28 | Make the script executable (`chmod +x`) and put it in a directory of your `PATH`, such as `~/.local/bin`. It can use any shell, including fish (`#!/usr/bin/env fish`). | ||
| @@ -96,7 +96,8 @@ While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland so | |||||
| 96 | 96 | ||||
| 97 | - every output, with its active numbered workspace, workspace count and the name of the workspace on screen; | 97 | - every output, with its active numbered workspace, workspace count and the name of the workspace on screen; | ||
| 98 | - the focused output and window; | 98 | - the focused output and window; | ||
| 99 | - | - every window, with its app ID, PID, output and workspace. | 99 | + | - every window, with its app ID, PID, output, workspace and whether it is on screen (`visible`); |
| 100 | + | - `floating: true` for floating windows. Stashed windows also have their 1-based place in their workspace's stash (`stash_index` of `stash_count`, both `0` outside it) and `hidden: true` while the stash is hidden. | |||
| 100 | 101 | ||||
| 101 | ```sh | 102 | ```sh | ||
| 102 | neferwl state # the whole state as JSON | 103 | neferwl state # the whole state as JSON | ||
| @@ -104,3 +105,8 @@ neferwl state output-of "$PID" # the output of that process's window, or i | |||||
| 104 | ``` | 105 | ``` | ||
| 105 | 106 | ||||
| 106 | `output-of` finds application windows only, because a bar has no single output. | 107 | `output-of` finds application windows only, because a bar has no single output. | ||
| 108 | + | ||||
| 109 | + | ```sh | |||
| 110 | + | # Windows waiting in hidden stashes | |||
| 111 | + | neferwl state | jq '[.windows[] | select(.hidden)] | length' | |||
| 112 | + | ``` | |||
M examples/config +2 -2
| @@ -118,8 +118,8 @@ bind.cmd+bracketright = consume-or-expel-window-right | |||
| 118 | 118 | bind.cmd+r = cycle-column-width | |
| 119 | 119 | bind.cmd+f = maximize-column | |
| 120 | 120 | bind.cmd+shift+f = toggle-fullscreen | |
| 121 | - | bind.cmd+v = toggle-window-floating | |
| 122 | - | bind.cmd+shift+v = toggle-floating-visible | |
| 121 | + | bind.cmd+s = toggle-window-stash | |
| 122 | + | bind.cmd+shift+s = toggle-stash-visible | |
| 123 | 123 | bind.cmd+q = close-window | |
| 124 | 124 | bind.ctrl+alt+backspace = quit | |
| 125 | 125 | bind.ctrl+cmd+space = spawn fuzzel | |
| @@ -118,8 +118,8 @@ bind.cmd+bracketright = consume-or-expel-window-right | |||||
| 118 | bind.cmd+r = cycle-column-width | 118 | bind.cmd+r = cycle-column-width | ||
| 119 | bind.cmd+f = maximize-column | 119 | bind.cmd+f = maximize-column | ||
| 120 | bind.cmd+shift+f = toggle-fullscreen | 120 | bind.cmd+shift+f = toggle-fullscreen | ||
| 121 | - | bind.cmd+v = toggle-window-floating | 121 | + | bind.cmd+s = toggle-window-stash |
| 122 | - | bind.cmd+shift+v = toggle-floating-visible | 122 | + | bind.cmd+shift+s = toggle-stash-visible |
| 123 | bind.cmd+q = close-window | 123 | bind.cmd+q = close-window | ||
| 124 | bind.ctrl+alt+backspace = quit | 124 | bind.ctrl+alt+backspace = quit | ||
| 125 | bind.ctrl+cmd+space = spawn fuzzel | 125 | bind.ctrl+cmd+space = spawn fuzzel | ||
M internal/adapters/config/config.go +24 -3
| @@ -32,8 +32,8 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 32 | 32 | {"cmd+r", "cycle-column-width"}, | |
| 33 | 33 | {"cmd+f", "maximize-column"}, | |
| 34 | 34 | {"cmd+shift+f", "toggle-fullscreen"}, | |
| 35 | - | {"cmd+v", "toggle-window-floating"}, | |
| 36 | - | {"cmd+shift+v", "toggle-floating-visible"}, | |
| 35 | + | {"cmd+s", "toggle-window-stash"}, | |
| 36 | + | {"cmd+shift+s", "toggle-stash-visible"}, | |
| 37 | 37 | {"cmd+q", "close-window"}, | |
| 38 | 38 | {"ctrl+alt+backspace", "quit"}, | |
| 39 | 39 | {"ctrl+cmd+space", "spawn fuzzel"}, | |
| @@ -46,7 +46,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 46 | 46 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | |
| 47 | 47 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 48 | 48 | } | |
| 49 | - | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-floating": true, "toggle-floating-visible": true, "close-window": true, "quit": true} | |
| 49 | + | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "close-window": true, "quit": true} | |
| 50 | 50 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | |
| 51 | 51 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 52 | 52 | ||
| @@ -90,6 +90,9 @@ func Defaults() ports.Config { | |||
| 90 | 90 | c.Xwayland = "xwayland-satellite" | |
| 91 | 91 | c.Background.Color = "#111111" | |
| 92 | 92 | c.Floating.Dim = 0.3 | |
| 93 | + | c.Stash.Width = 80 | |
| 94 | + | c.Stash.Gap = 2 | |
| 95 | + | c.Stash.Dim = 0.5 | |
| 93 | 96 | c.Border.Width = 2 | |
| 94 | 97 | c.Border.Active = "#808080" | |
| 95 | 98 | c.Border.Inactive = "#111111" | |
| @@ -459,6 +462,24 @@ func set(c *ports.Config, key, v string) error { | |||
| 459 | 462 | return fmt.Errorf("must be between 0 and 1") | |
| 460 | 463 | } | |
| 461 | 464 | c.Floating.Dim = dim | |
| 465 | + | case "stash.width": | |
| 466 | + | n, err := strconv.Atoi(strings.TrimSuffix(v, "%")) | |
| 467 | + | if err != nil || n < 10 || n > 90 { | |
| 468 | + | return fmt.Errorf("must be between 10 and 90") | |
| 469 | + | } | |
| 470 | + | c.Stash.Width = n | |
| 471 | + | case "stash.gap": | |
| 472 | + | n, err := strconv.Atoi(v) | |
| 473 | + | if err != nil || n < 0 || n > 10 { | |
| 474 | + | return fmt.Errorf("must be between 0 and 10") | |
| 475 | + | } | |
| 476 | + | c.Stash.Gap = n | |
| 477 | + | case "stash.dim": | |
| 478 | + | dim, err := strconv.ParseFloat(v, 64) | |
| 479 | + | if err != nil || !(dim >= 0 && dim <= 1) { | |
| 480 | + | return fmt.Errorf("must be between 0 and 1") | |
| 481 | + | } | |
| 482 | + | c.Stash.Dim = dim | |
| 462 | 483 | case "border.width": | |
| 463 | 484 | n, err := strconv.Atoi(v) | |
| 464 | 485 | if err != nil || n < 0 || n > 32 { | |
| @@ -32,8 +32,8 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 32 | {"cmd+r", "cycle-column-width"}, | 32 | {"cmd+r", "cycle-column-width"}, | ||
| 33 | {"cmd+f", "maximize-column"}, | 33 | {"cmd+f", "maximize-column"}, | ||
| 34 | {"cmd+shift+f", "toggle-fullscreen"}, | 34 | {"cmd+shift+f", "toggle-fullscreen"}, | ||
| 35 | - | {"cmd+v", "toggle-window-floating"}, | 35 | + | {"cmd+s", "toggle-window-stash"}, |
| 36 | - | {"cmd+shift+v", "toggle-floating-visible"}, | 36 | + | {"cmd+shift+s", "toggle-stash-visible"}, |
| 37 | {"cmd+q", "close-window"}, | 37 | {"cmd+q", "close-window"}, | ||
| 38 | {"ctrl+alt+backspace", "quit"}, | 38 | {"ctrl+alt+backspace", "quit"}, | ||
| 39 | {"ctrl+cmd+space", "spawn fuzzel"}, | 39 | {"ctrl+cmd+space", "spawn fuzzel"}, | ||
| @@ -46,7 +46,7 @@ var defaultBinds = []struct{ combo, action string }{ | |||||
| 46 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | 46 | {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"}, | ||
| 47 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | 47 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | ||
| 48 | } | 48 | } | ||
| 49 | - | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-floating": true, "toggle-floating-visible": true, "close-window": true, "quit": true} | 49 | + | var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-column-to-workspace-up": true, "move-column-to-workspace-down": true, "move-window-to-workspace-up": true, "move-window-to-workspace-down": true, "none": true, "consume-or-expel-window-left": true, "consume-or-expel-window-right": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "maximize-column": true, "toggle-fullscreen": true, "toggle-window-stash": true, "toggle-stash-visible": true, "close-window": true, "quit": true} |
| 50 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | 50 | var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true} | ||
| 51 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 51 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | ||
| 52 | 52 | ||||
| @@ -90,6 +90,9 @@ func Defaults() ports.Config { | |||||
| 90 | c.Xwayland = "xwayland-satellite" | 90 | c.Xwayland = "xwayland-satellite" | ||
| 91 | c.Background.Color = "#111111" | 91 | c.Background.Color = "#111111" | ||
| 92 | c.Floating.Dim = 0.3 | 92 | c.Floating.Dim = 0.3 | ||
| 93 | + | c.Stash.Width = 80 | |||
| 94 | + | c.Stash.Gap = 2 | |||
| 95 | + | c.Stash.Dim = 0.5 | |||
| 93 | c.Border.Width = 2 | 96 | c.Border.Width = 2 | ||
| 94 | c.Border.Active = "#808080" | 97 | c.Border.Active = "#808080" | ||
| 95 | c.Border.Inactive = "#111111" | 98 | c.Border.Inactive = "#111111" | ||
| @@ -459,6 +462,24 @@ func set(c *ports.Config, key, v string) error { | |||||
| 459 | return fmt.Errorf("must be between 0 and 1") | 462 | return fmt.Errorf("must be between 0 and 1") | ||
| 460 | } | 463 | } | ||
| 461 | c.Floating.Dim = dim | 464 | c.Floating.Dim = dim | ||
| 465 | + | case "stash.width": | |||
| 466 | + | n, err := strconv.Atoi(strings.TrimSuffix(v, "%")) | |||
| 467 | + | if err != nil || n < 10 || n > 90 { | |||
| 468 | + | return fmt.Errorf("must be between 10 and 90") | |||
| 469 | + | } | |||
| 470 | + | c.Stash.Width = n | |||
| 471 | + | case "stash.gap": | |||
| 472 | + | n, err := strconv.Atoi(v) | |||
| 473 | + | if err != nil || n < 0 || n > 10 { | |||
| 474 | + | return fmt.Errorf("must be between 0 and 10") | |||
| 475 | + | } | |||
| 476 | + | c.Stash.Gap = n | |||
| 477 | + | case "stash.dim": | |||
| 478 | + | dim, err := strconv.ParseFloat(v, 64) | |||
| 479 | + | if err != nil || !(dim >= 0 && dim <= 1) { | |||
| 480 | + | return fmt.Errorf("must be between 0 and 1") | |||
| 481 | + | } | |||
| 482 | + | c.Stash.Dim = dim | |||
| 462 | case "border.width": | 483 | case "border.width": | ||
| 463 | n, err := strconv.Atoi(v) | 484 | n, err := strconv.Atoi(v) | ||
| 464 | if err != nil || n < 0 || n > 32 { | 485 | if err != nil || n < 0 || n > 32 { | ||
M internal/adapters/config/config_test.go +27 -1
| @@ -25,7 +25,7 @@ func TestDefaultsAndLoad(t *testing.T) { | |||
| 25 | 25 | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | |
| 26 | 26 | t.Fatalf("defaults: %+v", d) | |
| 27 | 27 | } | |
| 28 | - | if d.Binds["Cmd+v"] != "toggle-window-floating" || d.Binds["Cmd+Shift+v"] != "toggle-floating-visible" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove { | |
| 28 | + | if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove { | |
| 29 | 29 | t.Fatal(d.Binds) | |
| 30 | 30 | } | |
| 31 | 31 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | |
| @@ -402,6 +402,32 @@ func TestTerminalResolutionAndAutoOpen(t *testing.T) { | |||
| 402 | 402 | } | |
| 403 | 403 | } | |
| 404 | 404 | ||
| 405 | + | func TestStash(t *testing.T) { | |
| 406 | + | if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 { | |
| 407 | + | t.Fatalf("defaults %+v", d.Stash) | |
| 408 | + | } | |
| 409 | + | for _, tc := range []struct { | |
| 410 | + | line string | |
| 411 | + | width int | |
| 412 | + | gap int | |
| 413 | + | dim float64 | |
| 414 | + | warning bool | |
| 415 | + | }{ | |
| 416 | + | {"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false}, | |
| 417 | + | {"stash.width = 9", 80, 2, 0.5, true}, {"stash.width = 91", 80, 2, 0.5, true}, {"stash.width = 0", 80, 2, 0.5, true}, {"stash.width = 1/2", 80, 2, 0.5, true}, | |
| 418 | + | {"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false}, | |
| 419 | + | {"stash.gap = 11", 80, 2, 0.5, true}, {"stash.gap = -1", 80, 2, 0.5, true}, {"stash.gap = 2.5", 80, 2, 0.5, true}, | |
| 420 | + | {"stash.dim = 0.8", 80, 2, 0.8, false}, {"stash.dim = 1.5", 80, 2, 0.5, true}, {"stash.dim = NaN", 80, 2, 0.5, true}, | |
| 421 | + | } { | |
| 422 | + | t.Run(tc.line, func(t *testing.T) { | |
| 423 | + | c, warnings := parseString(t, tc.line) | |
| 424 | + | if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning { | |
| 425 | + | t.Fatalf("stash %+v, warnings %v", c.Stash, warnings) | |
| 426 | + | } | |
| 427 | + | }) | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 405 | 431 | func TestFloatingDim(t *testing.T) { | |
| 406 | 432 | for _, tc := range []struct { | |
| 407 | 433 | value string | |
| @@ -25,7 +25,7 @@ func TestDefaultsAndLoad(t *testing.T) { | |||||
| 25 | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | 25 | if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 55 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 { | ||
| 26 | t.Fatalf("defaults: %+v", d) | 26 | t.Fatalf("defaults: %+v", d) | ||
| 27 | } | 27 | } | ||
| 28 | - | if d.Binds["Cmd+v"] != "toggle-window-floating" || d.Binds["Cmd+Shift+v"] != "toggle-floating-visible" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove { | 28 | + | if d.Binds["Cmd+s"] != "toggle-window-stash" || d.Binds["Cmd+Shift+s"] != "toggle-stash-visible" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove { |
| 29 | t.Fatal(d.Binds) | 29 | t.Fatal(d.Binds) | ||
| 30 | } | 30 | } | ||
| 31 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | 31 | if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" { | ||
| @@ -402,6 +402,32 @@ func TestTerminalResolutionAndAutoOpen(t *testing.T) { | |||||
| 402 | } | 402 | } | ||
| 403 | } | 403 | } | ||
| 404 | 404 | ||||
| 405 | + | func TestStash(t *testing.T) { | |||
| 406 | + | if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 { | |||
| 407 | + | t.Fatalf("defaults %+v", d.Stash) | |||
| 408 | + | } | |||
| 409 | + | for _, tc := range []struct { | |||
| 410 | + | line string | |||
| 411 | + | width int | |||
| 412 | + | gap int | |||
| 413 | + | dim float64 | |||
| 414 | + | warning bool | |||
| 415 | + | }{ | |||
| 416 | + | {"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false}, | |||
| 417 | + | {"stash.width = 9", 80, 2, 0.5, true}, {"stash.width = 91", 80, 2, 0.5, true}, {"stash.width = 0", 80, 2, 0.5, true}, {"stash.width = 1/2", 80, 2, 0.5, true}, | |||
| 418 | + | {"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false}, | |||
| 419 | + | {"stash.gap = 11", 80, 2, 0.5, true}, {"stash.gap = -1", 80, 2, 0.5, true}, {"stash.gap = 2.5", 80, 2, 0.5, true}, | |||
| 420 | + | {"stash.dim = 0.8", 80, 2, 0.8, false}, {"stash.dim = 1.5", 80, 2, 0.5, true}, {"stash.dim = NaN", 80, 2, 0.5, true}, | |||
| 421 | + | } { | |||
| 422 | + | t.Run(tc.line, func(t *testing.T) { | |||
| 423 | + | c, warnings := parseString(t, tc.line) | |||
| 424 | + | if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning { | |||
| 425 | + | t.Fatalf("stash %+v, warnings %v", c.Stash, warnings) | |||
| 426 | + | } | |||
| 427 | + | }) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 405 | func TestFloatingDim(t *testing.T) { | 431 | func TestFloatingDim(t *testing.T) { | ||
| 406 | for _, tc := range []struct { | 432 | for _, tc := range []struct { | ||
| 407 | value string | 433 | value string | ||
M internal/adapters/drm/overlay.go +3 -2
| @@ -74,8 +74,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 74 | 74 | } | |
| 75 | 75 | c := surfaces[w.ID] | |
| 76 | 76 | // A viewport crop or PQ content is composed: the plane would show | |
| 77 | - | // the whole buffer, and raw PQ values on this SDR output. | |
| 78 | - | if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | |
| 77 | + | // the whole buffer, and raw PQ values on this SDR output. A dimmed | |
| 78 | + | // window (a peeking stashed one) needs the veil drawn over it. | |
| 79 | + | if w.Dim <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | |
| 79 | 80 | pick = w | |
| 80 | 81 | continue | |
| 81 | 82 | } | |
| @@ -74,8 +74,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 74 | } | 74 | } | ||
| 75 | c := surfaces[w.ID] | 75 | c := surfaces[w.ID] | ||
| 76 | // A viewport crop or PQ content is composed: the plane would show | 76 | // A viewport crop or PQ content is composed: the plane would show | ||
| 77 | - | // the whole buffer, and raw PQ values on this SDR output. | 77 | + | // the whole buffer, and raw PQ values on this SDR output. A dimmed |
| 78 | - | if c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | 78 | + | // window (a peeking stashed one) needs the veil drawn over it. |
| 79 | + | if w.Dim <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() { | |||
| 79 | pick = w | 80 | pick = w | ||
| 80 | continue | 81 | continue | ||
| 81 | } | 82 | } | ||
M internal/adapters/drm/overlay_test.go +6 -0
| @@ -78,6 +78,12 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 78 | 78 | if _, _, reason := overlayCandidate(s, c); reason != "dim" { | |
| 79 | 79 | t.Fatalf("dim reason %q", reason) | |
| 80 | 80 | } | |
| 81 | + | // A dimmed window (a peeking stashed one) needs its veil composed. | |
| 82 | + | s, _ = overlayScene() | |
| 83 | + | s.Windows[len(s.Windows)-1].Dim = 0.5 | |
| 84 | + | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 85 | + | t.Fatalf("dimmed window reason %q", reason) | |
| 86 | + | } | |
| 81 | 87 | // A viewport crop is composed: the plane would show the whole buffer. | |
| 82 | 88 | s, c = overlayScene() | |
| 83 | 89 | cropped := c[2] | |
| @@ -78,6 +78,12 @@ func TestOverlayCandidate(t *testing.T) { | |||||
| 78 | if _, _, reason := overlayCandidate(s, c); reason != "dim" { | 78 | if _, _, reason := overlayCandidate(s, c); reason != "dim" { | ||
| 79 | t.Fatalf("dim reason %q", reason) | 79 | t.Fatalf("dim reason %q", reason) | ||
| 80 | } | 80 | } | ||
| 81 | + | // A dimmed window (a peeking stashed one) needs its veil composed. | |||
| 82 | + | s, _ = overlayScene() | |||
| 83 | + | s.Windows[len(s.Windows)-1].Dim = 0.5 | |||
| 84 | + | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |||
| 85 | + | t.Fatalf("dimmed window reason %q", reason) | |||
| 86 | + | } | |||
| 81 | // A viewport crop is composed: the plane would show the whole buffer. | 87 | // A viewport crop is composed: the plane would show the whole buffer. | ||
| 82 | s, c = overlayScene() | 88 | s, c = overlayScene() | ||
| 83 | cropped := c[2] | 89 | cropped := c[2] | ||
M internal/adapters/drm/scanout.go +4 -0
| @@ -78,6 +78,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 78 | 78 | if full == nil { | |
| 79 | 79 | return ports.SurfaceContent{}, "no_fullscreen" | |
| 80 | 80 | } | |
| 81 | + | if full.Dim > 0 { | |
| 82 | + | // Its veil is composed over it: the plane would show it bright. | |
| 83 | + | return ports.SurfaceContent{}, "dim" | |
| 84 | + | } | |
| 81 | 85 | // Core leaves the layers a fullscreen window hides out of the scene: | |
| 82 | 86 | // one above the windows here is drawn. | |
| 83 | 87 | for _, l := range s.Layers { | |
| @@ -78,6 +78,10 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 78 | if full == nil { | 78 | if full == nil { | ||
| 79 | return ports.SurfaceContent{}, "no_fullscreen" | 79 | return ports.SurfaceContent{}, "no_fullscreen" | ||
| 80 | } | 80 | } | ||
| 81 | + | if full.Dim > 0 { | |||
| 82 | + | // Its veil is composed over it: the plane would show it bright. | |||
| 83 | + | return ports.SurfaceContent{}, "dim" | |||
| 84 | + | } | |||
| 81 | // Core leaves the layers a fullscreen window hides out of the scene: | 85 | // Core leaves the layers a fullscreen window hides out of the scene: | ||
| 82 | // one above the windows here is drawn. | 86 | // one above the windows here is drawn. | ||
| 83 | for _, l := range s.Layers { | 87 | for _, l := range s.Layers { | ||
M internal/adapters/drm/scanout_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 25 | 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | |
| 26 | 26 | }, nil, ""}, | |
| 27 | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | |
| 28 | + | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | |
| 28 | 29 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | |
| 29 | 30 | {"float above", func(s *ports.Scene) { | |
| 30 | 31 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Rect: ports.Rect{W: 10, H: 10}}) | |
| @@ -25,6 +25,7 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | 25 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Hidden: true}) | ||
| 26 | }, nil, ""}, | 26 | }, nil, ""}, | ||
| 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | 27 | {"no window", func(s *ports.Scene) { s.Windows = nil }, nil, "no_fullscreen"}, | ||
| 28 | + | {"dimmed", func(s *ports.Scene) { s.Windows[0].Dim = 0.5 }, nil, "dim"}, | |||
| 28 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | 29 | {"tiled", func(s *ports.Scene) { s.Windows[0].Fullscreen = false }, nil, "other_windows"}, | ||
| 29 | {"float above", func(s *ports.Scene) { | 30 | {"float above", func(s *ports.Scene) { | ||
| 30 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Rect: ports.Rect{W: 10, H: 10}}) | 31 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 2, Rect: ports.Rect{W: 10, H: 10}}) | ||
M internal/adapters/statefile/statefile.go +9 -1
| @@ -56,6 +56,14 @@ type Window struct { | |||
| 56 | 56 | Output string `json:"output"` | |
| 57 | 57 | Workspace int `json:"workspace"` | |
| 58 | 58 | Visible bool `json:"visible"` | |
| 59 | + | // Floating is set for native floats (dialogs) and stashed windows. | |
| 60 | + | Floating bool `json:"floating"` | |
| 61 | + | // StashIndex is the 1-based place of a stashed window in its | |
| 62 | + | // workspace's stash of StashCount windows; both are 0 outside it. | |
| 63 | + | StashIndex int `json:"stash_index"` | |
| 64 | + | StashCount int `json:"stash_count"` | |
| 65 | + | // Hidden is set for a stashed window while the stash is hidden. | |
| 66 | + | Hidden bool `json:"hidden"` | |
| 59 | 67 | } | |
| 60 | 68 | ||
| 61 | 69 | func fromPorts(st ports.State) State { | |
| @@ -74,7 +82,7 @@ func fromPorts(st ports.State) State { | |||
| 74 | 82 | } | |
| 75 | 83 | ||
| 76 | 84 | func window(w ports.WindowState) Window { | |
| 77 | - | return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible} | |
| 85 | + | return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Hidden: w.Hidden} | |
| 78 | 86 | } | |
| 79 | 87 | ||
| 80 | 88 | // Run writes every state it receives to path until ctx ends, then removes | |
| @@ -56,6 +56,14 @@ type Window struct { | |||||
| 56 | Output string `json:"output"` | 56 | Output string `json:"output"` | ||
| 57 | Workspace int `json:"workspace"` | 57 | Workspace int `json:"workspace"` | ||
| 58 | Visible bool `json:"visible"` | 58 | Visible bool `json:"visible"` | ||
| 59 | + | // Floating is set for native floats (dialogs) and stashed windows. | |||
| 60 | + | Floating bool `json:"floating"` | |||
| 61 | + | // StashIndex is the 1-based place of a stashed window in its | |||
| 62 | + | // workspace's stash of StashCount windows; both are 0 outside it. | |||
| 63 | + | StashIndex int `json:"stash_index"` | |||
| 64 | + | StashCount int `json:"stash_count"` | |||
| 65 | + | // Hidden is set for a stashed window while the stash is hidden. | |||
| 66 | + | Hidden bool `json:"hidden"` | |||
| 59 | } | 67 | } | ||
| 60 | 68 | ||||
| 61 | func fromPorts(st ports.State) State { | 69 | func fromPorts(st ports.State) State { | ||
| @@ -74,7 +82,7 @@ func fromPorts(st ports.State) State { | |||||
| 74 | } | 82 | } | ||
| 75 | 83 | ||||
| 76 | func window(w ports.WindowState) Window { | 84 | func window(w ports.WindowState) Window { | ||
| 77 | - | return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible} | 85 | + | return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible, Floating: w.Floating, StashIndex: w.StashIndex, StashCount: w.StashCount, Hidden: w.Hidden} |
| 78 | } | 86 | } | ||
| 79 | 87 | ||||
| 80 | // Run writes every state it receives to path until ctx ends, then removes | 88 | // Run writes every state it receives to path until ctx ends, then removes | ||
M internal/adapters/statefile/statefile_test.go +19 -0
| @@ -2,10 +2,12 @@ package statefile | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "reflect" | |
| 10 | + | "strings" | |
| 9 | 11 | "testing" | |
| 10 | 12 | "time" | |
| 11 | 13 | ||
| @@ -61,6 +63,23 @@ func TestRunWritesAndRemoves(t *testing.T) { | |||
| 61 | 63 | } | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 66 | + | // Stash fields reach the file with their script-facing names. | |
| 67 | + | func TestWindowStashFields(t *testing.T) { | |
| 68 | + | got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true}) | |
| 69 | + | if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden { | |
| 70 | + | t.Fatalf("%+v", got) | |
| 71 | + | } | |
| 72 | + | data, err := json.Marshal(got) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"hidden":true`} { | |
| 77 | + | if !strings.Contains(string(data), key) { | |
| 78 | + | t.Fatalf("%s missing %s", data, key) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 64 | 83 | func TestPath(t *testing.T) { | |
| 65 | 84 | if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/neferwl/wayland-1.json" { | |
| 66 | 85 | t.Fatal(p, err) | |
| @@ -2,10 +2,12 @@ package statefile | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "reflect" | 9 | "reflect" | ||
| 10 | + | "strings" | |||
| 9 | "testing" | 11 | "testing" | ||
| 10 | "time" | 12 | "time" | ||
| 11 | 13 | ||||
| @@ -61,6 +63,23 @@ func TestRunWritesAndRemoves(t *testing.T) { | |||||
| 61 | } | 63 | } | ||
| 62 | } | 64 | } | ||
| 63 | 65 | ||||
| 66 | + | // Stash fields reach the file with their script-facing names. | |||
| 67 | + | func TestWindowStashFields(t *testing.T) { | |||
| 68 | + | got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true}) | |||
| 69 | + | if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden { | |||
| 70 | + | t.Fatalf("%+v", got) | |||
| 71 | + | } | |||
| 72 | + | data, err := json.Marshal(got) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"hidden":true`} { | |||
| 77 | + | if !strings.Contains(string(data), key) { | |||
| 78 | + | t.Fatalf("%s missing %s", data, key) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 64 | func TestPath(t *testing.T) { | 83 | func TestPath(t *testing.T) { | ||
| 65 | if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/neferwl/wayland-1.json" { | 84 | if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/neferwl/wayland-1.json" { | ||
| 66 | t.Fatal(p, err) | 85 | t.Fatal(p, err) | ||
M internal/adapters/vulkan/compose.go +14 -1
| @@ -303,10 +303,23 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw { | |||
| 303 | 303 | // leaves the destination at (1-alpha) of its previous value. | |
| 304 | 304 | func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw { | |
| 305 | 305 | dr := r.fillDraw(rect, [3]uint8{}) | |
| 306 | - | dr.pc.color[3] = float32(max(0, min(1, alpha))) | |
| 306 | + | dr.pc.color[3] = float32(dimAlpha(alpha, r.hdrNits > 0)) | |
| 307 | 307 | return dr | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | + | // dimAlpha is the blend alpha of a dim. SDR blends sRGB-encoded values: | |
| 311 | + | // white becomes (1-alpha) encoded. HDR blends linear light, where the same | |
| 312 | + | // alpha looks far weaker; it gets the alpha that turns reference white into | |
| 313 | + | // the linear light of that SDR result. Brighter HDR highlights stay a | |
| 314 | + | // little brighter than an exact perceptual match. | |
| 315 | + | func dimAlpha(alpha float64, hdr bool) float64 { | |
| 316 | + | alpha = max(0, min(1, alpha)) | |
| 317 | + | if hdr { | |
| 318 | + | alpha = 1 - srgbToLinear(1-alpha) | |
| 319 | + | } | |
| 320 | + | return alpha | |
| 321 | + | } | |
| 322 | + | ||
| 310 | 323 | // contentDraw draws the visible rect of a w×h buffer mapped onto full | |
| 311 | 324 | // (target pixels) through the inverse of its buffer transform t. exact: the | |
| 312 | 325 | // buffer is drawn at its size. | |
| @@ -303,10 +303,23 @@ func (r *Renderer) fillDraw(rect image.Rectangle, c [3]uint8) draw { | |||||
| 303 | // leaves the destination at (1-alpha) of its previous value. | 303 | // leaves the destination at (1-alpha) of its previous value. | ||
| 304 | func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw { | 304 | func (r *Renderer) dimDraw(rect image.Rectangle, alpha float64) draw { | ||
| 305 | dr := r.fillDraw(rect, [3]uint8{}) | 305 | dr := r.fillDraw(rect, [3]uint8{}) | ||
| 306 | - | dr.pc.color[3] = float32(max(0, min(1, alpha))) | 306 | + | dr.pc.color[3] = float32(dimAlpha(alpha, r.hdrNits > 0)) |
| 307 | return dr | 307 | return dr | ||
| 308 | } | 308 | } | ||
| 309 | 309 | ||||
| 310 | + | // dimAlpha is the blend alpha of a dim. SDR blends sRGB-encoded values: | |||
| 311 | + | // white becomes (1-alpha) encoded. HDR blends linear light, where the same | |||
| 312 | + | // alpha looks far weaker; it gets the alpha that turns reference white into | |||
| 313 | + | // the linear light of that SDR result. Brighter HDR highlights stay a | |||
| 314 | + | // little brighter than an exact perceptual match. | |||
| 315 | + | func dimAlpha(alpha float64, hdr bool) float64 { | |||
| 316 | + | alpha = max(0, min(1, alpha)) | |||
| 317 | + | if hdr { | |||
| 318 | + | alpha = 1 - srgbToLinear(1-alpha) | |||
| 319 | + | } | |||
| 320 | + | return alpha | |||
| 321 | + | } | |||
| 322 | + | ||||
| 310 | // contentDraw draws the visible rect of a w×h buffer mapped onto full | 323 | // contentDraw draws the visible rect of a w×h buffer mapped onto full | ||
| 311 | // (target pixels) through the inverse of its buffer transform t. exact: the | 324 | // (target pixels) through the inverse of its buffer transform t. exact: the | ||
| 312 | // buffer is drawn at its size. | 325 | // buffer is drawn at its size. | ||
M internal/adapters/vulkan/scene.go +12 -5
| @@ -53,10 +53,13 @@ func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | |||
| 53 | 53 | w.draws = append(w.draws, w.r.fillDraw(rect, c)) | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | - | // dim paints a translucent black quad over the whole output. Damage clipping | |
| 57 | - | // later limits it to the region being repainted, just like other fills. | |
| 58 | - | func (w *sceneWalk) dim() { | |
| 59 | - | w.draws = append(w.draws, w.r.dimDraw(w.bounds, w.s.Dim)) | |
| 56 | + | // dim paints a translucent black quad over rect (physical), clipped to the | |
| 57 | + | // output. Damage clipping later limits it to the region being repainted, | |
| 58 | + | // just like other fills. | |
| 59 | + | func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) { | |
| 60 | + | if rect = rect.Intersect(w.bounds); !rect.Empty() { | |
| 61 | + | w.draws = append(w.draws, w.r.dimDraw(rect, alpha)) | |
| 62 | + | } | |
| 60 | 63 | } | |
| 61 | 64 | ||
| 62 | 65 | // layers draws the layer surfaces below the windows, or above them. | |
| @@ -85,7 +88,7 @@ func (w *sceneWalk) windows() { | |||
| 85 | 88 | w.separators(0) | |
| 86 | 89 | tileLines = true | |
| 87 | 90 | if w.s.Dim > 0 { | |
| 88 | - | w.dim() | |
| 91 | + | w.dim(w.bounds, w.s.Dim) | |
| 89 | 92 | } | |
| 90 | 93 | } | |
| 91 | 94 | // Content sits inside the border; core sized the client to match. | |
| @@ -105,6 +108,10 @@ func (w *sceneWalk) windows() { | |||
| 105 | 108 | if win.Floating { | |
| 106 | 109 | w.separators(win.ID) | |
| 107 | 110 | } | |
| 111 | + | if win.Dim > 0 { | |
| 112 | + | // A stashed window peeking in: dimmed with its border. | |
| 113 | + | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | |
| 114 | + | } | |
| 108 | 115 | } | |
| 109 | 116 | if !tileLines { | |
| 110 | 117 | w.separators(0) | |
| @@ -53,10 +53,13 @@ func (w *sceneWalk) fill(rect image.Rectangle, c [3]uint8) { | |||||
| 53 | w.draws = append(w.draws, w.r.fillDraw(rect, c)) | 53 | w.draws = append(w.draws, w.r.fillDraw(rect, c)) | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | // dim paints a translucent black quad over the whole output. Damage clipping | 56 | + | // dim paints a translucent black quad over rect (physical), clipped to the |
| 57 | - | // later limits it to the region being repainted, just like other fills. | 57 | + | // output. Damage clipping later limits it to the region being repainted, |
| 58 | - | func (w *sceneWalk) dim() { | 58 | + | // just like other fills. |
| 59 | - | w.draws = append(w.draws, w.r.dimDraw(w.bounds, w.s.Dim)) | 59 | + | func (w *sceneWalk) dim(rect image.Rectangle, alpha float64) { |
| 60 | + | if rect = rect.Intersect(w.bounds); !rect.Empty() { | |||
| 61 | + | w.draws = append(w.draws, w.r.dimDraw(rect, alpha)) | |||
| 62 | + | } | |||
| 60 | } | 63 | } | ||
| 61 | 64 | ||||
| 62 | // layers draws the layer surfaces below the windows, or above them. | 65 | // layers draws the layer surfaces below the windows, or above them. | ||
| @@ -85,7 +88,7 @@ func (w *sceneWalk) windows() { | |||||
| 85 | w.separators(0) | 88 | w.separators(0) | ||
| 86 | tileLines = true | 89 | tileLines = true | ||
| 87 | if w.s.Dim > 0 { | 90 | if w.s.Dim > 0 { | ||
| 88 | - | w.dim() | 91 | + | w.dim(w.bounds, w.s.Dim) |
| 89 | } | 92 | } | ||
| 90 | } | 93 | } | ||
| 91 | // Content sits inside the border; core sized the client to match. | 94 | // Content sits inside the border; core sized the client to match. | ||
| @@ -105,6 +108,10 @@ func (w *sceneWalk) windows() { | |||||
| 105 | if win.Floating { | 108 | if win.Floating { | ||
| 106 | w.separators(win.ID) | 109 | w.separators(win.ID) | ||
| 107 | } | 110 | } | ||
| 111 | + | if win.Dim > 0 { | |||
| 112 | + | // A stashed window peeking in: dimmed with its border. | |||
| 113 | + | w.dim(w.physRect(win.Rect.X, win.Rect.Y, win.Rect.W, win.Rect.H), win.Dim) | |||
| 114 | + | } | |||
| 108 | } | 115 | } | ||
| 109 | if !tileLines { | 116 | if !tileLines { | ||
| 110 | w.separators(0) | 117 | w.separators(0) | ||
M internal/adapters/vulkan/scene_dim_test.go +91 -0
| @@ -3,6 +3,7 @@ package vulkan | |||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "image/color" | |
| 6 | + | "math" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -63,6 +64,47 @@ func TestSceneDimOrder(t *testing.T) { | |||
| 63 | 64 | } | |
| 64 | 65 | } | |
| 65 | 66 | ||
| 67 | + | // A dimmed window gets a veil over its own rect, border included, right | |
| 68 | + | // after it: windows drawn later stay bright. | |
| 69 | + | func TestSceneWindowDim(t *testing.T) { | |
| 70 | + | r := &Renderer{width: 80, height: 60} | |
| 71 | + | s := ports.Scene{Border: ports.Border{Width: 1, Inactive: "#ffffff"}, | |
| 72 | + | Windows: []ports.SceneWindow{ | |
| 73 | + | {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{X: -10, W: 20, H: 20}}, | |
| 74 | + | {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 20, W: 20, H: 20}}, | |
| 75 | + | }, | |
| 76 | + | Separators: []ports.Separator{{Window: 1, Rect: ports.Rect{X: -10, W: 20, H: 1}}}, | |
| 77 | + | } | |
| 78 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |
| 79 | + | // Window 1, its border, its veil (clipped to the output), window 2. | |
| 80 | + | if len(ds) != 4 { | |
| 81 | + | t.Fatalf("draws = %d, want 4", len(ds)) | |
| 82 | + | } | |
| 83 | + | if v := ds[2].pc; v.rect != [4]int32{0, 0, 10, 20} || v.color != [4]float32{0, 0, 0, 0.5} { | |
| 84 | + | t.Fatalf("veil = %+v", v) | |
| 85 | + | } | |
| 86 | + | if ds[3].pc.rect[0] != 21 { | |
| 87 | + | t.Fatalf("window 2 not last: %+v", ds[3].pc) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // HDR blends linear light: the dim alpha is raised so reference white | |
| 92 | + | // lands where SDR's encoded dim puts it. | |
| 93 | + | func TestDimAlpha(t *testing.T) { | |
| 94 | + | if got := dimAlpha(0.5, false); got != 0.5 { | |
| 95 | + | t.Fatalf("sdr %v", got) | |
| 96 | + | } | |
| 97 | + | for _, a := range []float64{0, 0.3, 0.5, 1} { | |
| 98 | + | got := dimAlpha(a, true) | |
| 99 | + | if want := 1 - srgbToLinear(1-a); math.Abs(got-want) > 1e-12 || got < a { | |
| 100 | + | t.Fatalf("hdr %v: %v want %v", a, got, want) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if dimAlpha(2, true) != 1 || dimAlpha(-1, true) != 0 { | |
| 104 | + | t.Fatal("not clamped") | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 66 | 108 | func TestSceneDimPartialDamage(t *testing.T) { | |
| 67 | 109 | r := &Renderer{width: 80, height: 60} | |
| 68 | 110 | s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{ | |
| @@ -148,3 +190,52 @@ func TestRendererDimBlendAndDamage(t *testing.T) { | |||
| 148 | 190 | t.Fatalf("damaged tile beneath veil = %v, want dark red", got) | |
| 149 | 191 | } | |
| 150 | 192 | } | |
| 193 | + | ||
| 194 | + | // A per-window veil darkens that window and its border only, also when | |
| 195 | + | // its content alone is redrawn on a retained target. | |
| 196 | + | func TestRendererWindowDimBlendAndDamage(t *testing.T) { | |
| 197 | + | r, err := New(64, 48) | |
| 198 | + | if err != nil { | |
| 199 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 200 | + | } | |
| 201 | + | defer r.Close() | |
| 202 | + | s := ports.Scene{Seq: 20, Background: "#ffffff", Border: ports.Border{Width: 1, Inactive: "#ffffff"}, | |
| 203 | + | Windows: []ports.SceneWindow{ | |
| 204 | + | {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{W: 20, H: 20}}, | |
| 205 | + | {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 30, W: 20, H: 20}}, | |
| 206 | + | }, | |
| 207 | + | Separators: []ports.Separator{ | |
| 208 | + | {Window: 1, Rect: ports.Rect{W: 20, H: 1}}, | |
| 209 | + | {Window: 2, Rect: ports.Rect{X: 30, W: 20, H: 1}}, | |
| 210 | + | }, | |
| 211 | + | } | |
| 212 | + | peek := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255}) | |
| 213 | + | peek.Seq = 1 | |
| 214 | + | sel := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255}) | |
| 215 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: peek, 2: sel} | |
| 216 | + | grey, white := color.RGBA{128, 128, 128, 255}, color.RGBA{255, 255, 255, 255} | |
| 217 | + | if err := render(r, s, contents); err != nil { | |
| 218 | + | t.Fatal(err) | |
| 219 | + | } | |
| 220 | + | for _, tc := range []struct { | |
| 221 | + | x, y int | |
| 222 | + | want color.RGBA | |
| 223 | + | }{{5, 5, grey}, {5, 0, grey}, {35, 5, white}, {35, 0, white}, {25, 30, white}} { | |
| 224 | + | if got := r.Pixels().RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) { | |
| 225 | + | t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | // Damage inside the peek replays its veil: red becomes dark red. | |
| 229 | + | if _, err := peek.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((3*18+3)*4)); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | peek.Seq, peek.Version = 2, 2 | |
| 233 | + | peek.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 3, Y: 3, W: 1, H: 1}}}} | |
| 234 | + | contents[1] = peek | |
| 235 | + | if err := render(r, s, contents); err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | if got := r.Pixels().RGBAAt(4, 4); !near(got, color.RGBA{128, 0, 0, 255}, 2) { | |
| 239 | + | t.Fatalf("damaged peek = %v, want dark red", got) | |
| 240 | + | } | |
| 241 | + | } | |
| @@ -3,6 +3,7 @@ package vulkan | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "image" | 4 | "image" | ||
| 5 | "image/color" | 5 | "image/color" | ||
| 6 | + | "math" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/neferwl/internal/ports" | 9 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -63,6 +64,47 @@ func TestSceneDimOrder(t *testing.T) { | |||||
| 63 | } | 64 | } | ||
| 64 | } | 65 | } | ||
| 65 | 66 | ||||
| 67 | + | // A dimmed window gets a veil over its own rect, border included, right | |||
| 68 | + | // after it: windows drawn later stay bright. | |||
| 69 | + | func TestSceneWindowDim(t *testing.T) { | |||
| 70 | + | r := &Renderer{width: 80, height: 60} | |||
| 71 | + | s := ports.Scene{Border: ports.Border{Width: 1, Inactive: "#ffffff"}, | |||
| 72 | + | Windows: []ports.SceneWindow{ | |||
| 73 | + | {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{X: -10, W: 20, H: 20}}, | |||
| 74 | + | {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 20, W: 20, H: 20}}, | |||
| 75 | + | }, | |||
| 76 | + | Separators: []ports.Separator{{Window: 1, Rect: ports.Rect{X: -10, W: 20, H: 1}}}, | |||
| 77 | + | } | |||
| 78 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 80, 60))) | |||
| 79 | + | // Window 1, its border, its veil (clipped to the output), window 2. | |||
| 80 | + | if len(ds) != 4 { | |||
| 81 | + | t.Fatalf("draws = %d, want 4", len(ds)) | |||
| 82 | + | } | |||
| 83 | + | if v := ds[2].pc; v.rect != [4]int32{0, 0, 10, 20} || v.color != [4]float32{0, 0, 0, 0.5} { | |||
| 84 | + | t.Fatalf("veil = %+v", v) | |||
| 85 | + | } | |||
| 86 | + | if ds[3].pc.rect[0] != 21 { | |||
| 87 | + | t.Fatalf("window 2 not last: %+v", ds[3].pc) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // HDR blends linear light: the dim alpha is raised so reference white | |||
| 92 | + | // lands where SDR's encoded dim puts it. | |||
| 93 | + | func TestDimAlpha(t *testing.T) { | |||
| 94 | + | if got := dimAlpha(0.5, false); got != 0.5 { | |||
| 95 | + | t.Fatalf("sdr %v", got) | |||
| 96 | + | } | |||
| 97 | + | for _, a := range []float64{0, 0.3, 0.5, 1} { | |||
| 98 | + | got := dimAlpha(a, true) | |||
| 99 | + | if want := 1 - srgbToLinear(1-a); math.Abs(got-want) > 1e-12 || got < a { | |||
| 100 | + | t.Fatalf("hdr %v: %v want %v", a, got, want) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if dimAlpha(2, true) != 1 || dimAlpha(-1, true) != 0 { | |||
| 104 | + | t.Fatal("not clamped") | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 66 | func TestSceneDimPartialDamage(t *testing.T) { | 108 | func TestSceneDimPartialDamage(t *testing.T) { | ||
| 67 | r := &Renderer{width: 80, height: 60} | 109 | r := &Renderer{width: 80, height: 60} | ||
| 68 | s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{ | 110 | s := ports.Scene{Seq: 8, Dim: 0.5, Windows: []ports.SceneWindow{ | ||
| @@ -148,3 +190,52 @@ func TestRendererDimBlendAndDamage(t *testing.T) { | |||||
| 148 | t.Fatalf("damaged tile beneath veil = %v, want dark red", got) | 190 | t.Fatalf("damaged tile beneath veil = %v, want dark red", got) | ||
| 149 | } | 191 | } | ||
| 150 | } | 192 | } | ||
| 193 | + | ||||
| 194 | + | // A per-window veil darkens that window and its border only, also when | |||
| 195 | + | // its content alone is redrawn on a retained target. | |||
| 196 | + | func TestRendererWindowDimBlendAndDamage(t *testing.T) { | |||
| 197 | + | r, err := New(64, 48) | |||
| 198 | + | if err != nil { | |||
| 199 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 200 | + | } | |||
| 201 | + | defer r.Close() | |||
| 202 | + | s := ports.Scene{Seq: 20, Background: "#ffffff", Border: ports.Border{Width: 1, Inactive: "#ffffff"}, | |||
| 203 | + | Windows: []ports.SceneWindow{ | |||
| 204 | + | {ID: 1, Floating: true, Dim: 0.5, Inset: ports.SideAll, Rect: ports.Rect{W: 20, H: 20}}, | |||
| 205 | + | {ID: 2, Floating: true, Inset: ports.SideAll, Rect: ports.Rect{X: 30, W: 20, H: 20}}, | |||
| 206 | + | }, | |||
| 207 | + | Separators: []ports.Separator{ | |||
| 208 | + | {Window: 1, Rect: ports.Rect{W: 20, H: 1}}, | |||
| 209 | + | {Window: 2, Rect: ports.Rect{X: 30, W: 20, H: 1}}, | |||
| 210 | + | }, | |||
| 211 | + | } | |||
| 212 | + | peek := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255}) | |||
| 213 | + | peek.Seq = 1 | |||
| 214 | + | sel := solidContent(t, 18, 18, color.RGBA{255, 255, 255, 255}) | |||
| 215 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: peek, 2: sel} | |||
| 216 | + | grey, white := color.RGBA{128, 128, 128, 255}, color.RGBA{255, 255, 255, 255} | |||
| 217 | + | if err := render(r, s, contents); err != nil { | |||
| 218 | + | t.Fatal(err) | |||
| 219 | + | } | |||
| 220 | + | for _, tc := range []struct { | |||
| 221 | + | x, y int | |||
| 222 | + | want color.RGBA | |||
| 223 | + | }{{5, 5, grey}, {5, 0, grey}, {35, 5, white}, {35, 0, white}, {25, 30, white}} { | |||
| 224 | + | if got := r.Pixels().RGBAAt(tc.x, tc.y); !near(got, tc.want, 2) { | |||
| 225 | + | t.Errorf("pixel (%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want) | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | // Damage inside the peek replays its veil: red becomes dark red. | |||
| 229 | + | if _, err := peek.SHM.File.WriteAt([]byte{0, 0, 255, 255}, int64((3*18+3)*4)); err != nil { | |||
| 230 | + | t.Fatal(err) | |||
| 231 | + | } | |||
| 232 | + | peek.Seq, peek.Version = 2, 2 | |||
| 233 | + | peek.DamageHistory = []ports.SeqDamage{{Seq: 1, Full: true}, {Seq: 2, Rects: []ports.Rect{{X: 3, Y: 3, W: 1, H: 1}}}} | |||
| 234 | + | contents[1] = peek | |||
| 235 | + | if err := render(r, s, contents); err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | if got := r.Pixels().RGBAAt(4, 4); !near(got, color.RGBA{128, 0, 0, 255}, 2) { | |||
| 239 | + | t.Fatalf("damaged peek = %v, want dark red", got) | |||
| 240 | + | } | |||
| 241 | + | } | |||
M internal/core/action.go +27 -25
| @@ -10,20 +10,20 @@ import ( | |||
| 10 | 10 | type Action string | |
| 11 | 11 | ||
| 12 | 12 | const ( | |
| 13 | - | ActionSpawnTerminal Action = "spawn-terminal" | |
| 14 | - | ActionFocusColumnLeft Action = "focus-column-left" | |
| 15 | - | ActionFocusColumnRight Action = "focus-column-right" | |
| 16 | - | ActionFocusWindowUp Action = "focus-window-up" | |
| 17 | - | ActionFocusWindowDown Action = "focus-window-down" | |
| 18 | - | ActionMoveColumnLeft Action = "move-column-left" | |
| 19 | - | ActionMoveColumnRight Action = "move-column-right" | |
| 20 | - | ActionCycleColumnWidth Action = "cycle-column-width" | |
| 21 | - | ActionMaximizeColumn Action = "maximize-column" | |
| 22 | - | ActionToggleFullscreen Action = "toggle-fullscreen" | |
| 23 | - | ActionToggleWindowFloating Action = "toggle-window-floating" | |
| 24 | - | ActionToggleFloatingVisible Action = "toggle-floating-visible" | |
| 25 | - | ActionCloseWindow Action = "close-window" | |
| 26 | - | ActionQuit Action = "quit" | |
| 13 | + | ActionSpawnTerminal Action = "spawn-terminal" | |
| 14 | + | ActionFocusColumnLeft Action = "focus-column-left" | |
| 15 | + | ActionFocusColumnRight Action = "focus-column-right" | |
| 16 | + | ActionFocusWindowUp Action = "focus-window-up" | |
| 17 | + | ActionFocusWindowDown Action = "focus-window-down" | |
| 18 | + | ActionMoveColumnLeft Action = "move-column-left" | |
| 19 | + | ActionMoveColumnRight Action = "move-column-right" | |
| 20 | + | ActionCycleColumnWidth Action = "cycle-column-width" | |
| 21 | + | ActionMaximizeColumn Action = "maximize-column" | |
| 22 | + | ActionToggleFullscreen Action = "toggle-fullscreen" | |
| 23 | + | ActionToggleWindowStash Action = "toggle-window-stash" | |
| 24 | + | ActionToggleStashVisible Action = "toggle-stash-visible" | |
| 25 | + | ActionCloseWindow Action = "close-window" | |
| 26 | + | ActionQuit Action = "quit" | |
| 27 | 27 | // Workspaces stack vertically; up/down stop at the ends. | |
| 28 | 28 | ActionFocusWorkspaceUp Action = "focus-workspace-up" | |
| 29 | 29 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| @@ -154,9 +154,11 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 154 | 154 | } else { | |
| 155 | 155 | dir = 1 | |
| 156 | 156 | } | |
| 157 | - | // The first move from a float leaves it for the columns (FocusColumn), | |
| 158 | - | // unless a covering fullscreen window pins the focus. | |
| 159 | - | edge := w.columnToward(dir) < 0 && (!w.floatFocus || w.pinned()) | |
| 157 | + | // The first move from a float stays on this monitor (FocusColumn): | |
| 158 | + | // a native float leaves for the stash or the columns, and the stash | |
| 159 | + | // keeps the focus at its ends. A covering fullscreen window pins | |
| 160 | + | // the focus: the move goes to the neighbor monitor. | |
| 161 | + | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned()) | |
| 160 | 162 | if i := c.neighbor(dir); edge && i >= 0 { | |
| 161 | 163 | c.focusScreen = i | |
| 162 | 164 | return Effect{} | |
| @@ -245,11 +247,11 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 245 | 247 | case ActionToggleFullscreen: | |
| 246 | 248 | m.ToggleFullscreen() | |
| 247 | 249 | return Effect{} | |
| 248 | - | case ActionToggleWindowFloating: | |
| 249 | - | m.Current().ToggleWindowFloating() | |
| 250 | + | case ActionToggleWindowStash: | |
| 251 | + | m.Current().ToggleWindowStash() | |
| 250 | 252 | return Effect{} | |
| 251 | - | case ActionToggleFloatingVisible: | |
| 252 | - | m.Current().ToggleFloatingVisible() | |
| 253 | + | case ActionToggleStashVisible: | |
| 254 | + | m.Current().ToggleStashVisible() | |
| 253 | 255 | return Effect{} | |
| 254 | 256 | case ActionMaximizeColumn: | |
| 255 | 257 | // A client that made itself fullscreen (Wine at monitor size) goes | |
| @@ -309,10 +311,10 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 309 | 311 | // In place only: binds go through Monitor.Apply, which gives a | |
| 310 | 312 | // fixed-overflow fullscreen its own workspace. | |
| 311 | 313 | w.ToggleFullscreen() | |
| 312 | - | case ActionToggleWindowFloating: | |
| 313 | - | w.ToggleWindowFloating() | |
| 314 | - | case ActionToggleFloatingVisible: | |
| 315 | - | w.ToggleFloatingVisible() | |
| 314 | + | case ActionToggleWindowStash: | |
| 315 | + | w.ToggleWindowStash() | |
| 316 | + | case ActionToggleStashVisible: | |
| 317 | + | w.ToggleStashVisible() | |
| 316 | 318 | case ActionCloseWindow: | |
| 317 | 319 | id, _ := w.Focused() | |
| 318 | 320 | return Effect{Close: id} | |
| @@ -10,20 +10,20 @@ import ( | |||||
| 10 | type Action string | 10 | type Action string | ||
| 11 | 11 | ||||
| 12 | const ( | 12 | const ( | ||
| 13 | - | ActionSpawnTerminal Action = "spawn-terminal" | 13 | + | ActionSpawnTerminal Action = "spawn-terminal" |
| 14 | - | ActionFocusColumnLeft Action = "focus-column-left" | 14 | + | ActionFocusColumnLeft Action = "focus-column-left" |
| 15 | - | ActionFocusColumnRight Action = "focus-column-right" | 15 | + | ActionFocusColumnRight Action = "focus-column-right" |
| 16 | - | ActionFocusWindowUp Action = "focus-window-up" | 16 | + | ActionFocusWindowUp Action = "focus-window-up" |
| 17 | - | ActionFocusWindowDown Action = "focus-window-down" | 17 | + | ActionFocusWindowDown Action = "focus-window-down" |
| 18 | - | ActionMoveColumnLeft Action = "move-column-left" | 18 | + | ActionMoveColumnLeft Action = "move-column-left" |
| 19 | - | ActionMoveColumnRight Action = "move-column-right" | 19 | + | ActionMoveColumnRight Action = "move-column-right" |
| 20 | - | ActionCycleColumnWidth Action = "cycle-column-width" | 20 | + | ActionCycleColumnWidth Action = "cycle-column-width" |
| 21 | - | ActionMaximizeColumn Action = "maximize-column" | 21 | + | ActionMaximizeColumn Action = "maximize-column" |
| 22 | - | ActionToggleFullscreen Action = "toggle-fullscreen" | 22 | + | ActionToggleFullscreen Action = "toggle-fullscreen" |
| 23 | - | ActionToggleWindowFloating Action = "toggle-window-floating" | 23 | + | ActionToggleWindowStash Action = "toggle-window-stash" |
| 24 | - | ActionToggleFloatingVisible Action = "toggle-floating-visible" | 24 | + | ActionToggleStashVisible Action = "toggle-stash-visible" |
| 25 | - | ActionCloseWindow Action = "close-window" | 25 | + | ActionCloseWindow Action = "close-window" |
| 26 | - | ActionQuit Action = "quit" | 26 | + | ActionQuit Action = "quit" |
| 27 | // Workspaces stack vertically; up/down stop at the ends. | 27 | // Workspaces stack vertically; up/down stop at the ends. | ||
| 28 | ActionFocusWorkspaceUp Action = "focus-workspace-up" | 28 | ActionFocusWorkspaceUp Action = "focus-workspace-up" | ||
| 29 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | 29 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | ||
| @@ -154,9 +154,11 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 154 | } else { | 154 | } else { | ||
| 155 | dir = 1 | 155 | dir = 1 | ||
| 156 | } | 156 | } | ||
| 157 | - | // The first move from a float leaves it for the columns (FocusColumn), | 157 | + | // The first move from a float stays on this monitor (FocusColumn): |
| 158 | - | // unless a covering fullscreen window pins the focus. | 158 | + | // a native float leaves for the stash or the columns, and the stash |
| 159 | - | edge := w.columnToward(dir) < 0 && (!w.floatFocus || w.pinned()) | 159 | + | // keeps the focus at its ends. A covering fullscreen window pins |
| 160 | + | // the focus: the move goes to the neighbor monitor. | |||
| 161 | + | edge := w.columnToward(dir) < 0 && (!w.onFloat() || w.pinned()) | |||
| 160 | if i := c.neighbor(dir); edge && i >= 0 { | 162 | if i := c.neighbor(dir); edge && i >= 0 { | ||
| 161 | c.focusScreen = i | 163 | c.focusScreen = i | ||
| 162 | return Effect{} | 164 | return Effect{} | ||
| @@ -245,11 +247,11 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 245 | case ActionToggleFullscreen: | 247 | case ActionToggleFullscreen: | ||
| 246 | m.ToggleFullscreen() | 248 | m.ToggleFullscreen() | ||
| 247 | return Effect{} | 249 | return Effect{} | ||
| 248 | - | case ActionToggleWindowFloating: | 250 | + | case ActionToggleWindowStash: |
| 249 | - | m.Current().ToggleWindowFloating() | 251 | + | m.Current().ToggleWindowStash() |
| 250 | return Effect{} | 252 | return Effect{} | ||
| 251 | - | case ActionToggleFloatingVisible: | 253 | + | case ActionToggleStashVisible: |
| 252 | - | m.Current().ToggleFloatingVisible() | 254 | + | m.Current().ToggleStashVisible() |
| 253 | return Effect{} | 255 | return Effect{} | ||
| 254 | case ActionMaximizeColumn: | 256 | case ActionMaximizeColumn: | ||
| 255 | // A client that made itself fullscreen (Wine at monitor size) goes | 257 | // A client that made itself fullscreen (Wine at monitor size) goes | ||
| @@ -309,10 +311,10 @@ func (w *Workspace) Apply(a Action) Effect { | |||||
| 309 | // In place only: binds go through Monitor.Apply, which gives a | 311 | // In place only: binds go through Monitor.Apply, which gives a | ||
| 310 | // fixed-overflow fullscreen its own workspace. | 312 | // fixed-overflow fullscreen its own workspace. | ||
| 311 | w.ToggleFullscreen() | 313 | w.ToggleFullscreen() | ||
| 312 | - | case ActionToggleWindowFloating: | 314 | + | case ActionToggleWindowStash: |
| 313 | - | w.ToggleWindowFloating() | 315 | + | w.ToggleWindowStash() |
| 314 | - | case ActionToggleFloatingVisible: | 316 | + | case ActionToggleStashVisible: |
| 315 | - | w.ToggleFloatingVisible() | 317 | + | w.ToggleStashVisible() |
| 316 | case ActionCloseWindow: | 318 | case ActionCloseWindow: | ||
| 317 | id, _ := w.Focused() | 319 | id, _ := w.Focused() | ||
| 318 | return Effect{Close: id} | 320 | return Effect{Close: id} | ||
M internal/core/core.go +17 -4
| @@ -200,7 +200,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 200 | 200 | continue | |
| 201 | 201 | } | |
| 202 | 202 | switch Action(a) { | |
| 203 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowFloating, ActionToggleFloatingVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| 203 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | |
| 204 | 204 | default: | |
| 205 | 205 | return fmt.Errorf("invalid action %q", a) | |
| 206 | 206 | } | |
| @@ -470,7 +470,11 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 470 | 470 | alive[p.ID] = true | |
| 471 | 471 | // Only the focused output has an activated window. | |
| 472 | 472 | focused := p.Focused && i == c.focusScreen | |
| 473 | - | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset}) | |
| 473 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset} | |
| 474 | + | if p.Peek { | |
| 475 | + | sw.Dim = c.cfg.Stash.Dim | |
| 476 | + | } | |
| 477 | + | scene.Windows = append(scene.Windows, sw) | |
| 474 | 478 | floating := p.Floating | |
| 475 | 479 | if p.Hidden { | |
| 476 | 480 | // A hidden window keeps its size, state and output; it is | |
| @@ -668,7 +672,16 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 668 | 672 | full := false | |
| 669 | 673 | for _, p := range sc.mon.Layout() { | |
| 670 | 674 | r := c.clientRect(p) | |
| 671 | - | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | |
| 675 | + | // A peek is clickable wherever it shows, border included: it may | |
| 676 | + | // be narrower than its border. The point is clamped to its client. | |
| 677 | + | box, lx, ly := r, lx, ly | |
| 678 | + | if p.Peek { | |
| 679 | + | box = p.Rect | |
| 680 | + | } | |
| 681 | + | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(box.X) && lx < float64(box.X+box.W) && ly >= float64(box.Y) && ly < float64(box.Y+box.H) { | |
| 682 | + | if p.Peek { | |
| 683 | + | 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)) | |
| 684 | + | } | |
| 672 | 685 | // Floating windows come last in the layout and are on top, | |
| 673 | 686 | // even of a fullscreen window; nothing else is. | |
| 674 | 687 | if full && !p.Floating { | |
| @@ -945,7 +958,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 945 | 958 | // A click focuses the window and its output. | |
| 946 | 959 | s, w := c.screenOf(id) | |
| 947 | 960 | if v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | |
| 948 | - | w.FocusID(id) | |
| 961 | + | w.Click(id) | |
| 949 | 962 | c.focusScreen = c.screenIndex(s.name()) | |
| 950 | 963 | if err := c.publish(ctx); err != nil { | |
| 951 | 964 | return nil | |
| @@ -200,7 +200,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 200 | continue | 200 | continue | ||
| 201 | } | 201 | } | ||
| 202 | switch Action(a) { | 202 | switch Action(a) { | ||
| 203 | - | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowFloating, ActionToggleFloatingVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: | 203 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionMaximizeColumn, ActionToggleFullscreen, ActionToggleWindowStash, ActionToggleStashVisible, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveColumnToWorkspaceUp, ActionMoveColumnToWorkspaceDown, ActionMoveWindowToWorkspaceUp, ActionMoveWindowToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight: |
| 204 | default: | 204 | default: | ||
| 205 | return fmt.Errorf("invalid action %q", a) | 205 | return fmt.Errorf("invalid action %q", a) | ||
| 206 | } | 206 | } | ||
| @@ -470,7 +470,11 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 470 | alive[p.ID] = true | 470 | alive[p.ID] = true | ||
| 471 | // Only the focused output has an activated window. | 471 | // Only the focused output has an activated window. | ||
| 472 | focused := p.Focused && i == c.focusScreen | 472 | focused := p.Focused && i == c.focusScreen | ||
| 473 | - | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset}) | 473 | + | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Inset: p.Inset} |
| 474 | + | if p.Peek { | |||
| 475 | + | sw.Dim = c.cfg.Stash.Dim | |||
| 476 | + | } | |||
| 477 | + | scene.Windows = append(scene.Windows, sw) | |||
| 474 | floating := p.Floating | 478 | floating := p.Floating | ||
| 475 | if p.Hidden { | 479 | if p.Hidden { | ||
| 476 | // A hidden window keeps its size, state and output; it is | 480 | // A hidden window keeps its size, state and output; it is | ||
| @@ -668,7 +672,16 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 668 | full := false | 672 | full := false | ||
| 669 | for _, p := range sc.mon.Layout() { | 673 | for _, p := range sc.mon.Layout() { | ||
| 670 | r := c.clientRect(p) | 674 | r := c.clientRect(p) | ||
| 671 | - | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | 675 | + | // A peek is clickable wherever it shows, border included: it may |
| 676 | + | // be narrower than its border. The point is clamped to its client. | |||
| 677 | + | box, lx, ly := r, lx, ly | |||
| 678 | + | if p.Peek { | |||
| 679 | + | box = p.Rect | |||
| 680 | + | } | |||
| 681 | + | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(box.X) && lx < float64(box.X+box.W) && ly >= float64(box.Y) && ly < float64(box.Y+box.H) { | |||
| 682 | + | if p.Peek { | |||
| 683 | + | 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)) | |||
| 684 | + | } | |||
| 672 | // Floating windows come last in the layout and are on top, | 685 | // Floating windows come last in the layout and are on top, | ||
| 673 | // even of a fullscreen window; nothing else is. | 686 | // even of a fullscreen window; nothing else is. | ||
| 674 | if full && !p.Floating { | 687 | if full && !p.Floating { | ||
| @@ -945,7 +958,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 945 | // A click focuses the window and its output. | 958 | // A click focuses the window and its output. | ||
| 946 | s, w := c.screenOf(id) | 959 | s, w := c.screenOf(id) | ||
| 947 | if v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | 960 | if v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) { | ||
| 948 | - | w.FocusID(id) | 961 | + | w.Click(id) |
| 949 | c.focusScreen = c.screenIndex(s.name()) | 962 | c.focusScreen = c.screenIndex(s.name()) | ||
| 950 | if err := c.publish(ctx); err != nil { | 963 | if err := c.publish(ctx); err != nil { | ||
| 951 | return nil | 964 | return nil | ||
M internal/core/expel.go +2 -2
| @@ -20,7 +20,7 @@ func (w *Workspace) full() bool { | |||
| 20 | 20 | // returns false when the window must leave for the neighbor monitor: a full | |
| 21 | 21 | // workspace with the window stacked in the edge column. | |
| 22 | 22 | func (w *Workspace) ConsumeOrExpel(dir int) bool { | |
| 23 | - | if len(w.Columns) == 0 || w.floatFocus || (dir != -1 && dir != 1) { | |
| 23 | + | if len(w.Columns) == 0 || w.onFloat() || (dir != -1 && dir != 1) { | |
| 24 | 24 | return true | |
| 25 | 25 | } | |
| 26 | 26 | // A fullscreen window hides the others (fixed) or its column (scroll): | |
| @@ -82,7 +82,7 @@ func (w *Workspace) dropSlotOf(id WindowID) { | |||
| 82 | 82 | // workspace; its slot, if any, is released. | |
| 83 | 83 | func (w *Workspace) takeWindow() (WindowID, bool) { | |
| 84 | 84 | id, ok := w.Focused() | |
| 85 | - | if !ok || w.floatFocus { | |
| 85 | + | if !ok || w.onFloat() { | |
| 86 | 86 | return 0, false | |
| 87 | 87 | } | |
| 88 | 88 | w.dropSlotOf(id) | |
| @@ -20,7 +20,7 @@ func (w *Workspace) full() bool { | |||||
| 20 | // returns false when the window must leave for the neighbor monitor: a full | 20 | // returns false when the window must leave for the neighbor monitor: a full | ||
| 21 | // workspace with the window stacked in the edge column. | 21 | // workspace with the window stacked in the edge column. | ||
| 22 | func (w *Workspace) ConsumeOrExpel(dir int) bool { | 22 | func (w *Workspace) ConsumeOrExpel(dir int) bool { | ||
| 23 | - | if len(w.Columns) == 0 || w.floatFocus || (dir != -1 && dir != 1) { | 23 | + | if len(w.Columns) == 0 || w.onFloat() || (dir != -1 && dir != 1) { |
| 24 | return true | 24 | return true | ||
| 25 | } | 25 | } | ||
| 26 | // A fullscreen window hides the others (fixed) or its column (scroll): | 26 | // A fullscreen window hides the others (fixed) or its column (scroll): | ||
| @@ -82,7 +82,7 @@ func (w *Workspace) dropSlotOf(id WindowID) { | |||||
| 82 | // workspace; its slot, if any, is released. | 82 | // workspace; its slot, if any, is released. | ||
| 83 | func (w *Workspace) takeWindow() (WindowID, bool) { | 83 | func (w *Workspace) takeWindow() (WindowID, bool) { | ||
| 84 | id, ok := w.Focused() | 84 | id, ok := w.Focused() | ||
| 85 | - | if !ok || w.floatFocus { | 85 | + | if !ok || w.onFloat() { |
| 86 | return 0, false | 86 | return 0, false | ||
| 87 | } | 87 | } | ||
| 88 | w.dropSlotOf(id) | 88 | w.dropSlotOf(id) | ||
M internal/core/floating_test.go +183 -86
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestToggleWindowFloating(t *testing.T) { | |
| 11 | + | func TestToggleWindowStash(t *testing.T) { | |
| 12 | 12 | for _, stacked := range []bool{false, true} { | |
| 13 | 13 | t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) { | |
| 14 | 14 | w := workspace() | |
| @@ -20,59 +20,169 @@ func TestToggleWindowFloating(t *testing.T) { | |||
| 20 | 20 | w.Columns[1].Windows = []WindowID{3, 2} | |
| 21 | 21 | w.Columns[1].Focus = 1 | |
| 22 | 22 | } | |
| 23 | - | w.Apply(ActionToggleWindowFloating) | |
| 24 | - | if len(w.Floats) != 1 || !w.Floats[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked { | |
| 25 | - | t.Fatalf("float %+v", w.Floats) | |
| 23 | + | w.Apply(ActionToggleWindowStash) | |
| 24 | + | if len(w.Stash) != 1 || len(w.Floats) != 0 || !w.Stash[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked { | |
| 25 | + | t.Fatalf("stash %+v", w.Stash) | |
| 26 | 26 | } | |
| 27 | - | p := w.Layout()[len(w.Layout())-1] | |
| 28 | - | if !p.Focused || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) { | |
| 27 | + | p := placement(w, 2) | |
| 28 | + | if !p.Focused || !p.Floating || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) { | |
| 29 | 29 | t.Fatalf("placement %+v", p) | |
| 30 | 30 | } | |
| 31 | 31 | w.ResizeFloating(2, 12, 14) | |
| 32 | - | if w.Layout()[len(w.Layout())-1].Rect != p.Rect { | |
| 33 | - | t.Fatal("client resize moved imposed float") | |
| 32 | + | if placement(w, 2).Rect != p.Rect { | |
| 33 | + | t.Fatal("client resize moved stashed window") | |
| 34 | 34 | } | |
| 35 | - | w.Apply(ActionToggleWindowFloating) | |
| 35 | + | w.Apply(ActionToggleWindowStash) | |
| 36 | 36 | id, _ := w.Focused() | |
| 37 | - | if id != 2 || len(w.Floats) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) { | |
| 37 | + | if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) { | |
| 38 | 38 | t.Fatalf("round trip: %+v", w) | |
| 39 | 39 | } | |
| 40 | 40 | }) | |
| 41 | 41 | } | |
| 42 | 42 | w := workspace() | |
| 43 | 43 | w.AddFloating(4, 20, 10) | |
| 44 | - | w.Apply(ActionToggleWindowFloating) | |
| 44 | + | w.Apply(ActionToggleWindowStash) | |
| 45 | 45 | if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 { | |
| 46 | 46 | t.Fatalf("native float: %+v", w) | |
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | - | func TestToggleFloatingVisible(t *testing.T) { | |
| 50 | + | // placement returns the placement of id in the workspace layout. | |
| 51 | + | func placement(w *Workspace, id WindowID) Placement { | |
| 52 | + | for _, p := range w.Layout() { | |
| 53 | + | if p.ID == id { | |
| 54 | + | return p | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return Placement{} | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // The stash is a strip: new windows go last, selected and centred; the | |
| 61 | + | // neighbors peek in beside it, stash.gap away; focus moves inside it and stops at its | |
| 62 | + | // ends; up and down do nothing there. | |
| 63 | + | func TestStashStrip(t *testing.T) { | |
| 51 | 64 | w := workspace() | |
| 52 | - | w.Apply(ActionToggleFloatingVisible) | |
| 65 | + | w.stashGap = 5 | |
| 66 | + | for id := WindowID(1); id <= 4; id++ { | |
| 67 | + | w.AddWindow(id) | |
| 68 | + | } | |
| 69 | + | for _, id := range []WindowID{4, 3, 2} { | |
| 70 | + | w.FocusID(id) | |
| 71 | + | w.Apply(ActionToggleWindowStash) | |
| 72 | + | } | |
| 73 | + | if got := []WindowID{w.Stash[0].ID, w.Stash[1].ID, w.Stash[2].ID}; !slices.Equal(got, []WindowID{4, 3, 2}) { | |
| 74 | + | t.Fatalf("order %v", got) | |
| 75 | + | } | |
| 76 | + | if id, _ := w.Focused(); id != 2 || w.stashAt != 2 { | |
| 77 | + | t.Fatalf("focused %d at %d", id, w.stashAt) | |
| 78 | + | } | |
| 79 | + | center := Rect{X: 10, Y: 8, W: 80, H: 64} | |
| 80 | + | if p := placement(w, 2); p.Rect != center || p.Peek || p.Hidden { | |
| 81 | + | t.Fatalf("selected %+v", p) | |
| 82 | + | } | |
| 83 | + | if p := placement(w, 3); !p.Peek || p.Rect != (Rect{X: 5 - 80, Y: 8, W: 80, H: 64}) { | |
| 84 | + | t.Fatalf("left peek %+v", p) | |
| 85 | + | } | |
| 86 | + | if p := placement(w, 4); !p.Hidden { | |
| 87 | + | t.Fatalf("far window %+v", p) | |
| 88 | + | } | |
| 89 | + | w.Apply(ActionFocusColumnRight) | |
| 90 | + | if id, _ := w.Focused(); id != 2 { | |
| 91 | + | t.Fatalf("moved past the end: %d", id) | |
| 92 | + | } | |
| 93 | + | w.Apply(ActionFocusColumnLeft) | |
| 94 | + | if p := placement(w, 2); !p.Peek || p.Rect != (Rect{X: 95, Y: 8, W: 80, H: 64}) { | |
| 95 | + | t.Fatalf("right peek %+v", p) | |
| 96 | + | } | |
| 97 | + | w.Apply(ActionFocusColumnLeft) | |
| 98 | + | w.Apply(ActionFocusColumnLeft) | |
| 99 | + | w.Apply(ActionFocusWindowUp) | |
| 100 | + | if id, _ := w.Focused(); id != 4 { | |
| 101 | + | t.Fatalf("focused %d, want the first", id) | |
| 102 | + | } | |
| 103 | + | // Column actions do nothing while the stash has the focus. | |
| 104 | + | w.Apply(ActionMoveColumnLeft) | |
| 105 | + | w.Apply(ActionCycleColumnWidth) | |
| 106 | + | if w.Columns[0].Windows[0] != 1 || w.Columns[0].Width != (Width{}) { | |
| 107 | + | t.Fatalf("column changed: %+v", w.Columns) | |
| 108 | + | } | |
| 109 | + | // A gap as wide as the margin leaves nothing to show: neighbors wait | |
| 110 | + | // off screen. | |
| 111 | + | w.stashGap = 10 | |
| 112 | + | if p := placement(w, 3); !p.Hidden || p.Peek { | |
| 113 | + | t.Fatalf("no peek: %+v", p) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Hiding the stash is the way out: tiles get the focus, a new stashed | |
| 118 | + | // window shows it again, and the native floats stay as they are. | |
| 119 | + | func TestToggleStashVisible(t *testing.T) { | |
| 120 | + | w := workspace() | |
| 121 | + | w.Apply(ActionToggleStashVisible) | |
| 53 | 122 | w.AddWindow(1) | |
| 54 | - | w.AddFloating(2, 20, 10) | |
| 55 | - | w.AddFloating(3, 20, 10) | |
| 56 | - | w.Apply(ActionToggleFloatingVisible) | |
| 57 | - | if id, _ := w.Focused(); id != 1 { | |
| 58 | - | t.Fatalf("focused %d", id) | |
| 123 | + | w.AddWindow(2) | |
| 124 | + | w.AddWindow(3) | |
| 125 | + | w.Apply(ActionToggleWindowStash) | |
| 126 | + | w.FocusID(2) | |
| 127 | + | w.Apply(ActionToggleWindowStash) | |
| 128 | + | w.Apply(ActionFocusColumnLeft) | |
| 129 | + | w.Apply(ActionToggleStashVisible) | |
| 130 | + | if id, _ := w.Focused(); id != 1 || !w.stashHidden { | |
| 131 | + | t.Fatalf("focused %d hidden %v", id, w.stashHidden) | |
| 59 | 132 | } | |
| 60 | - | for _, p := range w.Layout()[1:] { | |
| 61 | - | if !p.Hidden || p.Focused { | |
| 133 | + | for _, id := range []WindowID{2, 3} { | |
| 134 | + | if p := placement(w, id); !p.Hidden || p.Focused { | |
| 62 | 135 | t.Fatalf("hidden %+v", p) | |
| 63 | 136 | } | |
| 64 | 137 | } | |
| 65 | 138 | if w.FocusID(2) { | |
| 66 | - | t.Fatal("focused hidden float") | |
| 139 | + | t.Fatal("focused hidden stash window") | |
| 67 | 140 | } | |
| 68 | - | w.Apply(ActionToggleFloatingVisible) | |
| 69 | - | if id, _ := w.Focused(); id != 3 { | |
| 141 | + | // Shown from a tile: the stash takes the focus on its selected window. | |
| 142 | + | w.Apply(ActionToggleStashVisible) | |
| 143 | + | if id, _ := w.Focused(); id != 3 || w.stashHidden { | |
| 70 | 144 | t.Fatalf("restored focus %d", id) | |
| 71 | 145 | } | |
| 72 | - | w.Apply(ActionToggleFloatingVisible) | |
| 73 | - | w.AddFloating(4, 12, 12) | |
| 74 | - | if w.floatsHidden || w.Layout()[3].Hidden { | |
| 75 | - | t.Fatal("new float did not reveal floats") | |
| 146 | + | w.Apply(ActionToggleStashVisible) | |
| 147 | + | w.FocusID(1) | |
| 148 | + | w.Apply(ActionToggleWindowStash) | |
| 149 | + | if id, _ := w.Focused(); id != 1 || w.stashHidden || w.stashAt != 2 { | |
| 150 | + | t.Fatalf("new stashed window: focused %d hidden %v", id, w.stashHidden) | |
| 151 | + | } | |
| 152 | + | // A native dialog stays when the stash hides. | |
| 153 | + | w.AddFloating(9, 10, 10) | |
| 154 | + | w.Apply(ActionToggleStashVisible) | |
| 155 | + | if p := placement(w, 9); p.Hidden || !w.stashHidden { | |
| 156 | + | t.Fatalf("dialog hidden with the stash: %+v", p) | |
| 157 | + | } | |
| 158 | + | if id, _ := w.Focused(); id != 9 { | |
| 159 | + | t.Fatalf("focused %d, want the dialog", id) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Closing the selected stashed window selects its right neighbor, else its | |
| 164 | + | // left one, else gives the focus back to the tiles. | |
| 165 | + | func TestStashClose(t *testing.T) { | |
| 166 | + | w := workspace() | |
| 167 | + | for id := WindowID(1); id <= 4; id++ { | |
| 168 | + | w.AddWindow(id) | |
| 169 | + | } | |
| 170 | + | for _, id := range []WindowID{4, 3, 2} { | |
| 171 | + | w.FocusID(id) | |
| 172 | + | w.Apply(ActionToggleWindowStash) | |
| 173 | + | } | |
| 174 | + | w.FocusID(3) // the middle one | |
| 175 | + | w.RemoveWindow(3) | |
| 176 | + | if id, _ := w.Focused(); id != 2 { | |
| 177 | + | t.Fatalf("after middle: %d", id) | |
| 178 | + | } | |
| 179 | + | w.RemoveWindow(2) | |
| 180 | + | if id, _ := w.Focused(); id != 4 { | |
| 181 | + | t.Fatalf("after last: %d", id) | |
| 182 | + | } | |
| 183 | + | w.RemoveWindow(4) | |
| 184 | + | if id, _ := w.Focused(); id != 1 || len(w.Stash) != 0 || w.stashFocus { | |
| 185 | + | t.Fatalf("after all: %d", id) | |
| 76 | 186 | } | |
| 77 | 187 | } | |
| 78 | 188 | ||
| @@ -90,7 +200,7 @@ func TestExclusiveFullscreenNewWindow(t *testing.T) { | |||
| 90 | 200 | t.Fatalf("overflow %v: %+v", overflow, p) | |
| 91 | 201 | } | |
| 92 | 202 | } | |
| 93 | - | if w.ToggleFloatingVisible(); w.floatsHidden { | |
| 203 | + | if w.ToggleStashVisible(); w.stashHidden { | |
| 94 | 204 | t.Fatal("floats toggled under fullscreen") | |
| 95 | 205 | } | |
| 96 | 206 | w.SetFullscreen(1, false) | |
| @@ -143,37 +253,37 @@ func TestFullscreenPinsFocus(t *testing.T) { | |||
| 143 | 253 | } | |
| 144 | 254 | ||
| 145 | 255 | // A fullscreen window does not float until it leaves fullscreen. | |
| 146 | - | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | |
| 256 | + | func TestToggleWindowStashIgnoresFullscreen(t *testing.T) { | |
| 147 | 257 | w := workspace() | |
| 148 | 258 | w.AddWindow(1) | |
| 149 | 259 | w.SetFullscreen(1, true) | |
| 150 | - | w.Apply(ActionToggleWindowFloating) | |
| 151 | - | if len(w.Floats) != 0 || w.fullscreen != 1 { | |
| 260 | + | w.Apply(ActionToggleWindowStash) | |
| 261 | + | if len(w.Stash) != 0 || w.fullscreen != 1 { | |
| 152 | 262 | t.Fatalf("fullscreen window floated: %+v", w) | |
| 153 | 263 | } | |
| 154 | 264 | w.SetFullscreen(1, false) | |
| 155 | - | w.Apply(ActionToggleWindowFloating) | |
| 156 | - | if len(w.Floats) != 1 { | |
| 265 | + | w.Apply(ActionToggleWindowStash) | |
| 266 | + | if len(w.Stash) != 1 { | |
| 157 | 267 | t.Fatal("window did not float after leaving fullscreen") | |
| 158 | 268 | } | |
| 159 | 269 | } | |
| 160 | 270 | ||
| 161 | 271 | // A lone maximized column comes back maximized. | |
| 162 | - | func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) { | |
| 272 | + | func TestToggleWindowStashKeepsFullWidth(t *testing.T) { | |
| 163 | 273 | w := workspace() | |
| 164 | 274 | w.Overflow = OverflowFixed | |
| 165 | 275 | w.AddWindow(1) | |
| 166 | 276 | w.AddWindow(2) | |
| 167 | 277 | w.Apply(ActionMaximizeColumn) | |
| 168 | - | w.Apply(ActionToggleWindowFloating) | |
| 169 | - | w.Apply(ActionToggleWindowFloating) | |
| 278 | + | w.Apply(ActionToggleWindowStash) | |
| 279 | + | w.Apply(ActionToggleWindowStash) | |
| 170 | 280 | if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth { | |
| 171 | 281 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | |
| 172 | 282 | } | |
| 173 | 283 | } | |
| 174 | 284 | ||
| 175 | 285 | // A lone expanded column (fixed overflow) comes back expanded. | |
| 176 | - | func TestToggleWindowFloatingKeepsExpanded(t *testing.T) { | |
| 286 | + | func TestToggleWindowStashKeepsExpanded(t *testing.T) { | |
| 177 | 287 | w := workspace() | |
| 178 | 288 | w.Overflow = OverflowFixed | |
| 179 | 289 | w.SetMaxColumns(3) | |
| @@ -183,64 +293,46 @@ func TestToggleWindowFloatingKeepsExpanded(t *testing.T) { | |||
| 183 | 293 | if !w.Columns[w.Focus].Expanded { | |
| 184 | 294 | t.Fatal("column not expanded") | |
| 185 | 295 | } | |
| 186 | - | w.Apply(ActionToggleWindowFloating) | |
| 187 | - | w.Apply(ActionToggleWindowFloating) | |
| 296 | + | w.Apply(ActionToggleWindowStash) | |
| 297 | + | w.Apply(ActionToggleWindowStash) | |
| 188 | 298 | if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded { | |
| 189 | 299 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | |
| 190 | 300 | } | |
| 191 | 301 | } | |
| 192 | 302 | ||
| 193 | - | // A client fullscreen request of a hidden float leaves it hidden on its | |
| 194 | - | // workspace, even in fixed overflow (which moves fullscreen windows out). | |
| 303 | + | // A client fullscreen request of a hidden stashed window leaves it hidden | |
| 304 | + | // on its workspace, even in fixed overflow (which moves fullscreen windows | |
| 305 | + | // out). | |
| 195 | 306 | func TestHiddenFloatFullscreenRequest(t *testing.T) { | |
| 196 | 307 | m := newMonitor("", "") | |
| 197 | 308 | m.SetOutput(100, 80) | |
| 198 | 309 | m.SetOverflow(OverflowFixed) | |
| 199 | 310 | m.AddWindow(1) | |
| 200 | - | m.AddFloating(2, 20, 10) | |
| 311 | + | m.AddWindow(2) | |
| 201 | 312 | w := m.Current() | |
| 202 | - | w.ToggleFloatingVisible() | |
| 313 | + | w.ToggleWindowStash() | |
| 314 | + | w.ToggleStashVisible() | |
| 203 | 315 | m.SetFullscreen(2, true) | |
| 204 | - | if m.Current() != w || w.floatIndex(2) < 0 || !w.floatsHidden || w.fullscreen != 0 { | |
| 205 | - | t.Fatalf("hidden float left or showed: %+v", w) | |
| 316 | + | if m.Current() != w || w.stashIndex(2) < 0 || !w.stashHidden || w.fullscreen != 0 { | |
| 317 | + | t.Fatalf("hidden stash left or showed: %+v", w) | |
| 206 | 318 | } | |
| 207 | - | w.ToggleFloatingVisible() | |
| 319 | + | w.ToggleStashVisible() | |
| 208 | 320 | if w.fullscreen != 2 { | |
| 209 | 321 | t.Fatalf("fullscreen lost on show: %d", w.fullscreen) | |
| 210 | 322 | } | |
| 211 | 323 | } | |
| 212 | 324 | ||
| 213 | - | // Only a focused float hides the floats, and never over a fullscreen float. | |
| 214 | - | func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | |
| 325 | + | // A fullscreen stashed window (a game in scanout) keeps the stash shown: | |
| 326 | + | // hiding it would drop scanout and VRR. | |
| 327 | + | func TestToggleStashVisibleFullscreen(t *testing.T) { | |
| 215 | 328 | w := workspace() | |
| 216 | - | w.Overflow = OverflowFixed | |
| 217 | 329 | w.AddWindow(1) | |
| 218 | - | w.AddFloating(2, 20, 10) | |
| 219 | - | w.FocusID(1) | |
| 220 | - | w.Apply(ActionToggleFloatingVisible) | |
| 221 | - | if id, _ := w.Focused(); id != 1 || w.floatsHidden { | |
| 222 | - | t.Fatalf("column window hid floats: focused %d hidden %v", id, w.floatsHidden) | |
| 223 | - | } | |
| 224 | - | // Shown again from a column window, the top float takes the focus. | |
| 225 | - | w.FocusID(2) | |
| 226 | - | w.Apply(ActionToggleFloatingVisible) | |
| 227 | - | w.FocusID(1) | |
| 228 | - | w.Apply(ActionToggleFloatingVisible) | |
| 229 | - | if id, _ := w.Focused(); id != 2 || w.floatsHidden { | |
| 230 | - | t.Fatalf("show from column: focused %d hidden %v", id, w.floatsHidden) | |
| 231 | - | } | |
| 232 | - | // A fullscreen float (a game in scanout) stays shown: hiding it would | |
| 233 | - | // drop scanout and VRR. | |
| 234 | - | w.FocusID(2) | |
| 330 | + | w.AddWindow(2) | |
| 331 | + | w.ToggleWindowStash() | |
| 235 | 332 | w.SetFullscreen(2, true) | |
| 236 | - | w.Apply(ActionToggleFloatingVisible) | |
| 237 | - | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.floatsHidden { | |
| 238 | - | t.Fatalf("fullscreen float hidden: focused %d fullscreen %d", id, w.fullscreen) | |
| 239 | - | } | |
| 240 | - | w.FocusID(1) | |
| 241 | - | w.Apply(ActionToggleFloatingVisible) | |
| 242 | - | if w.fullscreen != 2 || w.floatsHidden { | |
| 243 | - | t.Fatalf("fullscreen float hidden from column: fullscreen %d", w.fullscreen) | |
| 333 | + | w.Apply(ActionToggleStashVisible) | |
| 334 | + | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.stashHidden { | |
| 335 | + | t.Fatalf("fullscreen stash hidden: focused %d fullscreen %d", id, w.fullscreen) | |
| 244 | 336 | } | |
| 245 | 337 | } | |
| 246 | 338 | ||
| @@ -270,7 +362,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||
| 270 | 362 | } | |
| 271 | 363 | } | |
| 272 | 364 | check(0) | |
| 273 | - | w.ToggleWindowFloating() | |
| 365 | + | w.ToggleWindowStash() | |
| 274 | 366 | check(0.3) | |
| 275 | 367 | found := false | |
| 276 | 368 | for len(commands) > 0 { | |
| @@ -300,14 +392,18 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||
| 300 | 392 | if !found { | |
| 301 | 393 | t.Fatal("missing native configure") | |
| 302 | 394 | } | |
| 303 | - | w.ToggleFloatingVisible() | |
| 395 | + | // Hiding the stash leaves the dialog, and its veil. | |
| 396 | + | w.ToggleStashVisible() | |
| 397 | + | check(0.3) | |
| 398 | + | w.RemoveWindow(2) | |
| 304 | 399 | check(0) | |
| 305 | - | w.ToggleFloatingVisible() | |
| 400 | + | w.ToggleStashVisible() | |
| 306 | 401 | check(0.3) | |
| 402 | + | w.AddFloating(2, 20, 10) | |
| 307 | 403 | w.SetFullscreen(1, true) | |
| 308 | 404 | check(0) | |
| 309 | 405 | w.SetFullscreen(1, false) | |
| 310 | - | w.ToggleWindowFloating() | |
| 406 | + | w.ToggleWindowStash() | |
| 311 | 407 | w.SetFullscreen(1, true) | |
| 312 | 408 | check(0) | |
| 313 | 409 | // A dialog of a fullscreen tile waits hidden: no veil over the game. | |
| @@ -323,26 +419,27 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||
| 323 | 419 | check(0.3) | |
| 324 | 420 | } | |
| 325 | 421 | ||
| 326 | - | // Activating a hidden float shows the floats and focuses it; the | |
| 422 | + | // Activating a hidden stashed window shows the stash and focuses it; the | |
| 327 | 423 | // fullscreen window it hid behind leaves fullscreen. | |
| 328 | 424 | func TestActivateHiddenFloat(t *testing.T) { | |
| 329 | 425 | w := workspace() | |
| 330 | 426 | w.AddWindow(1) | |
| 331 | - | w.AddFloating(2, 20, 10) | |
| 332 | - | w.ToggleFloatingVisible() | |
| 427 | + | w.AddWindow(2) | |
| 428 | + | w.ToggleWindowStash() | |
| 429 | + | w.ToggleStashVisible() | |
| 333 | 430 | w.FocusID(1) | |
| 334 | 431 | w.SetFullscreen(1, true) | |
| 335 | 432 | w.Activate(2) | |
| 336 | - | if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 0 { | |
| 337 | - | t.Fatalf("focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen) | |
| 433 | + | if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 0 { | |
| 434 | + | t.Fatalf("focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen) | |
| 338 | 435 | } | |
| 339 | 436 | // The float's own fullscreen, asked while hidden, comes back with it. | |
| 340 | 437 | w.FocusID(2) | |
| 341 | - | w.ToggleFloatingVisible() | |
| 438 | + | w.ToggleStashVisible() | |
| 342 | 439 | w.SetFullscreen(2, true) | |
| 343 | 440 | w.SetFullscreen(1, true) | |
| 344 | 441 | w.Activate(2) | |
| 345 | - | if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 2 { | |
| 346 | - | t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen) | |
| 442 | + | if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 2 { | |
| 443 | + | t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen) | |
| 347 | 444 | } | |
| 348 | 445 | } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | "github.com/bnema/neferwl/internal/ports" | 8 | "github.com/bnema/neferwl/internal/ports" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | func TestToggleWindowFloating(t *testing.T) { | 11 | + | func TestToggleWindowStash(t *testing.T) { |
| 12 | for _, stacked := range []bool{false, true} { | 12 | for _, stacked := range []bool{false, true} { | ||
| 13 | t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) { | 13 | t.Run(map[bool]string{false: "column", true: "stack"}[stacked], func(t *testing.T) { | ||
| 14 | w := workspace() | 14 | w := workspace() | ||
| @@ -20,59 +20,169 @@ func TestToggleWindowFloating(t *testing.T) { | |||||
| 20 | w.Columns[1].Windows = []WindowID{3, 2} | 20 | w.Columns[1].Windows = []WindowID{3, 2} | ||
| 21 | w.Columns[1].Focus = 1 | 21 | w.Columns[1].Focus = 1 | ||
| 22 | } | 22 | } | ||
| 23 | - | w.Apply(ActionToggleWindowFloating) | 23 | + | w.Apply(ActionToggleWindowStash) |
| 24 | - | if len(w.Floats) != 1 || !w.Floats[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked { | 24 | + | if len(w.Stash) != 1 || len(w.Floats) != 0 || !w.Stash[0].back.holdsStack(Column{Windows: []WindowID{3}}) && stacked { |
| 25 | - | t.Fatalf("float %+v", w.Floats) | 25 | + | t.Fatalf("stash %+v", w.Stash) |
| 26 | } | 26 | } | ||
| 27 | - | p := w.Layout()[len(w.Layout())-1] | 27 | + | p := placement(w, 2) |
| 28 | - | if !p.Focused || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) { | 28 | + | if !p.Focused || !p.Floating || p.Rect != (Rect{X: 10, Y: 8, W: 80, H: 64}) { |
| 29 | t.Fatalf("placement %+v", p) | 29 | t.Fatalf("placement %+v", p) | ||
| 30 | } | 30 | } | ||
| 31 | w.ResizeFloating(2, 12, 14) | 31 | w.ResizeFloating(2, 12, 14) | ||
| 32 | - | if w.Layout()[len(w.Layout())-1].Rect != p.Rect { | 32 | + | if placement(w, 2).Rect != p.Rect { |
| 33 | - | t.Fatal("client resize moved imposed float") | 33 | + | t.Fatal("client resize moved stashed window") |
| 34 | } | 34 | } | ||
| 35 | - | w.Apply(ActionToggleWindowFloating) | 35 | + | w.Apply(ActionToggleWindowStash) |
| 36 | id, _ := w.Focused() | 36 | id, _ := w.Focused() | ||
| 37 | - | if id != 2 || len(w.Floats) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) { | 37 | + | if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) { |
| 38 | t.Fatalf("round trip: %+v", w) | 38 | t.Fatalf("round trip: %+v", w) | ||
| 39 | } | 39 | } | ||
| 40 | }) | 40 | }) | ||
| 41 | } | 41 | } | ||
| 42 | w := workspace() | 42 | w := workspace() | ||
| 43 | w.AddFloating(4, 20, 10) | 43 | w.AddFloating(4, 20, 10) | ||
| 44 | - | w.Apply(ActionToggleWindowFloating) | 44 | + | w.Apply(ActionToggleWindowStash) |
| 45 | if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 { | 45 | if len(w.Floats) != 0 || len(w.Columns) != 1 || w.Columns[0].Windows[0] != 4 { | ||
| 46 | t.Fatalf("native float: %+v", w) | 46 | t.Fatalf("native float: %+v", w) | ||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | - | func TestToggleFloatingVisible(t *testing.T) { | 50 | + | // placement returns the placement of id in the workspace layout. |
| 51 | + | func placement(w *Workspace, id WindowID) Placement { | |||
| 52 | + | for _, p := range w.Layout() { | |||
| 53 | + | if p.ID == id { | |||
| 54 | + | return p | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return Placement{} | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // The stash is a strip: new windows go last, selected and centred; the | |||
| 61 | + | // neighbors peek in beside it, stash.gap away; focus moves inside it and stops at its | |||
| 62 | + | // ends; up and down do nothing there. | |||
| 63 | + | func TestStashStrip(t *testing.T) { | |||
| 51 | w := workspace() | 64 | w := workspace() | ||
| 52 | - | w.Apply(ActionToggleFloatingVisible) | 65 | + | w.stashGap = 5 |
| 66 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 67 | + | w.AddWindow(id) | |||
| 68 | + | } | |||
| 69 | + | for _, id := range []WindowID{4, 3, 2} { | |||
| 70 | + | w.FocusID(id) | |||
| 71 | + | w.Apply(ActionToggleWindowStash) | |||
| 72 | + | } | |||
| 73 | + | if got := []WindowID{w.Stash[0].ID, w.Stash[1].ID, w.Stash[2].ID}; !slices.Equal(got, []WindowID{4, 3, 2}) { | |||
| 74 | + | t.Fatalf("order %v", got) | |||
| 75 | + | } | |||
| 76 | + | if id, _ := w.Focused(); id != 2 || w.stashAt != 2 { | |||
| 77 | + | t.Fatalf("focused %d at %d", id, w.stashAt) | |||
| 78 | + | } | |||
| 79 | + | center := Rect{X: 10, Y: 8, W: 80, H: 64} | |||
| 80 | + | if p := placement(w, 2); p.Rect != center || p.Peek || p.Hidden { | |||
| 81 | + | t.Fatalf("selected %+v", p) | |||
| 82 | + | } | |||
| 83 | + | if p := placement(w, 3); !p.Peek || p.Rect != (Rect{X: 5 - 80, Y: 8, W: 80, H: 64}) { | |||
| 84 | + | t.Fatalf("left peek %+v", p) | |||
| 85 | + | } | |||
| 86 | + | if p := placement(w, 4); !p.Hidden { | |||
| 87 | + | t.Fatalf("far window %+v", p) | |||
| 88 | + | } | |||
| 89 | + | w.Apply(ActionFocusColumnRight) | |||
| 90 | + | if id, _ := w.Focused(); id != 2 { | |||
| 91 | + | t.Fatalf("moved past the end: %d", id) | |||
| 92 | + | } | |||
| 93 | + | w.Apply(ActionFocusColumnLeft) | |||
| 94 | + | if p := placement(w, 2); !p.Peek || p.Rect != (Rect{X: 95, Y: 8, W: 80, H: 64}) { | |||
| 95 | + | t.Fatalf("right peek %+v", p) | |||
| 96 | + | } | |||
| 97 | + | w.Apply(ActionFocusColumnLeft) | |||
| 98 | + | w.Apply(ActionFocusColumnLeft) | |||
| 99 | + | w.Apply(ActionFocusWindowUp) | |||
| 100 | + | if id, _ := w.Focused(); id != 4 { | |||
| 101 | + | t.Fatalf("focused %d, want the first", id) | |||
| 102 | + | } | |||
| 103 | + | // Column actions do nothing while the stash has the focus. | |||
| 104 | + | w.Apply(ActionMoveColumnLeft) | |||
| 105 | + | w.Apply(ActionCycleColumnWidth) | |||
| 106 | + | if w.Columns[0].Windows[0] != 1 || w.Columns[0].Width != (Width{}) { | |||
| 107 | + | t.Fatalf("column changed: %+v", w.Columns) | |||
| 108 | + | } | |||
| 109 | + | // A gap as wide as the margin leaves nothing to show: neighbors wait | |||
| 110 | + | // off screen. | |||
| 111 | + | w.stashGap = 10 | |||
| 112 | + | if p := placement(w, 3); !p.Hidden || p.Peek { | |||
| 113 | + | t.Fatalf("no peek: %+v", p) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Hiding the stash is the way out: tiles get the focus, a new stashed | |||
| 118 | + | // window shows it again, and the native floats stay as they are. | |||
| 119 | + | func TestToggleStashVisible(t *testing.T) { | |||
| 120 | + | w := workspace() | |||
| 121 | + | w.Apply(ActionToggleStashVisible) | |||
| 53 | w.AddWindow(1) | 122 | w.AddWindow(1) | ||
| 54 | - | w.AddFloating(2, 20, 10) | 123 | + | w.AddWindow(2) |
| 55 | - | w.AddFloating(3, 20, 10) | 124 | + | w.AddWindow(3) |
| 56 | - | w.Apply(ActionToggleFloatingVisible) | 125 | + | w.Apply(ActionToggleWindowStash) |
| 57 | - | if id, _ := w.Focused(); id != 1 { | 126 | + | w.FocusID(2) |
| 58 | - | t.Fatalf("focused %d", id) | 127 | + | w.Apply(ActionToggleWindowStash) |
| 128 | + | w.Apply(ActionFocusColumnLeft) | |||
| 129 | + | w.Apply(ActionToggleStashVisible) | |||
| 130 | + | if id, _ := w.Focused(); id != 1 || !w.stashHidden { | |||
| 131 | + | t.Fatalf("focused %d hidden %v", id, w.stashHidden) | |||
| 59 | } | 132 | } | ||
| 60 | - | for _, p := range w.Layout()[1:] { | 133 | + | for _, id := range []WindowID{2, 3} { |
| 61 | - | if !p.Hidden || p.Focused { | 134 | + | if p := placement(w, id); !p.Hidden || p.Focused { |
| 62 | t.Fatalf("hidden %+v", p) | 135 | t.Fatalf("hidden %+v", p) | ||
| 63 | } | 136 | } | ||
| 64 | } | 137 | } | ||
| 65 | if w.FocusID(2) { | 138 | if w.FocusID(2) { | ||
| 66 | - | t.Fatal("focused hidden float") | 139 | + | t.Fatal("focused hidden stash window") |
| 67 | } | 140 | } | ||
| 68 | - | w.Apply(ActionToggleFloatingVisible) | 141 | + | // Shown from a tile: the stash takes the focus on its selected window. |
| 69 | - | if id, _ := w.Focused(); id != 3 { | 142 | + | w.Apply(ActionToggleStashVisible) |
| 143 | + | if id, _ := w.Focused(); id != 3 || w.stashHidden { | |||
| 70 | t.Fatalf("restored focus %d", id) | 144 | t.Fatalf("restored focus %d", id) | ||
| 71 | } | 145 | } | ||
| 72 | - | w.Apply(ActionToggleFloatingVisible) | 146 | + | w.Apply(ActionToggleStashVisible) |
| 73 | - | w.AddFloating(4, 12, 12) | 147 | + | w.FocusID(1) |
| 74 | - | if w.floatsHidden || w.Layout()[3].Hidden { | 148 | + | w.Apply(ActionToggleWindowStash) |
| 75 | - | t.Fatal("new float did not reveal floats") | 149 | + | if id, _ := w.Focused(); id != 1 || w.stashHidden || w.stashAt != 2 { |
| 150 | + | t.Fatalf("new stashed window: focused %d hidden %v", id, w.stashHidden) | |||
| 151 | + | } | |||
| 152 | + | // A native dialog stays when the stash hides. | |||
| 153 | + | w.AddFloating(9, 10, 10) | |||
| 154 | + | w.Apply(ActionToggleStashVisible) | |||
| 155 | + | if p := placement(w, 9); p.Hidden || !w.stashHidden { | |||
| 156 | + | t.Fatalf("dialog hidden with the stash: %+v", p) | |||
| 157 | + | } | |||
| 158 | + | if id, _ := w.Focused(); id != 9 { | |||
| 159 | + | t.Fatalf("focused %d, want the dialog", id) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // Closing the selected stashed window selects its right neighbor, else its | |||
| 164 | + | // left one, else gives the focus back to the tiles. | |||
| 165 | + | func TestStashClose(t *testing.T) { | |||
| 166 | + | w := workspace() | |||
| 167 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 168 | + | w.AddWindow(id) | |||
| 169 | + | } | |||
| 170 | + | for _, id := range []WindowID{4, 3, 2} { | |||
| 171 | + | w.FocusID(id) | |||
| 172 | + | w.Apply(ActionToggleWindowStash) | |||
| 173 | + | } | |||
| 174 | + | w.FocusID(3) // the middle one | |||
| 175 | + | w.RemoveWindow(3) | |||
| 176 | + | if id, _ := w.Focused(); id != 2 { | |||
| 177 | + | t.Fatalf("after middle: %d", id) | |||
| 178 | + | } | |||
| 179 | + | w.RemoveWindow(2) | |||
| 180 | + | if id, _ := w.Focused(); id != 4 { | |||
| 181 | + | t.Fatalf("after last: %d", id) | |||
| 182 | + | } | |||
| 183 | + | w.RemoveWindow(4) | |||
| 184 | + | if id, _ := w.Focused(); id != 1 || len(w.Stash) != 0 || w.stashFocus { | |||
| 185 | + | t.Fatalf("after all: %d", id) | |||
| 76 | } | 186 | } | ||
| 77 | } | 187 | } | ||
| 78 | 188 | ||||
| @@ -90,7 +200,7 @@ func TestExclusiveFullscreenNewWindow(t *testing.T) { | |||||
| 90 | t.Fatalf("overflow %v: %+v", overflow, p) | 200 | t.Fatalf("overflow %v: %+v", overflow, p) | ||
| 91 | } | 201 | } | ||
| 92 | } | 202 | } | ||
| 93 | - | if w.ToggleFloatingVisible(); w.floatsHidden { | 203 | + | if w.ToggleStashVisible(); w.stashHidden { |
| 94 | t.Fatal("floats toggled under fullscreen") | 204 | t.Fatal("floats toggled under fullscreen") | ||
| 95 | } | 205 | } | ||
| 96 | w.SetFullscreen(1, false) | 206 | w.SetFullscreen(1, false) | ||
| @@ -143,37 +253,37 @@ func TestFullscreenPinsFocus(t *testing.T) { | |||||
| 143 | } | 253 | } | ||
| 144 | 254 | ||||
| 145 | // A fullscreen window does not float until it leaves fullscreen. | 255 | // A fullscreen window does not float until it leaves fullscreen. | ||
| 146 | - | func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) { | 256 | + | func TestToggleWindowStashIgnoresFullscreen(t *testing.T) { |
| 147 | w := workspace() | 257 | w := workspace() | ||
| 148 | w.AddWindow(1) | 258 | w.AddWindow(1) | ||
| 149 | w.SetFullscreen(1, true) | 259 | w.SetFullscreen(1, true) | ||
| 150 | - | w.Apply(ActionToggleWindowFloating) | 260 | + | w.Apply(ActionToggleWindowStash) |
| 151 | - | if len(w.Floats) != 0 || w.fullscreen != 1 { | 261 | + | if len(w.Stash) != 0 || w.fullscreen != 1 { |
| 152 | t.Fatalf("fullscreen window floated: %+v", w) | 262 | t.Fatalf("fullscreen window floated: %+v", w) | ||
| 153 | } | 263 | } | ||
| 154 | w.SetFullscreen(1, false) | 264 | w.SetFullscreen(1, false) | ||
| 155 | - | w.Apply(ActionToggleWindowFloating) | 265 | + | w.Apply(ActionToggleWindowStash) |
| 156 | - | if len(w.Floats) != 1 { | 266 | + | if len(w.Stash) != 1 { |
| 157 | t.Fatal("window did not float after leaving fullscreen") | 267 | t.Fatal("window did not float after leaving fullscreen") | ||
| 158 | } | 268 | } | ||
| 159 | } | 269 | } | ||
| 160 | 270 | ||||
| 161 | // A lone maximized column comes back maximized. | 271 | // A lone maximized column comes back maximized. | ||
| 162 | - | func TestToggleWindowFloatingKeepsFullWidth(t *testing.T) { | 272 | + | func TestToggleWindowStashKeepsFullWidth(t *testing.T) { |
| 163 | w := workspace() | 273 | w := workspace() | ||
| 164 | w.Overflow = OverflowFixed | 274 | w.Overflow = OverflowFixed | ||
| 165 | w.AddWindow(1) | 275 | w.AddWindow(1) | ||
| 166 | w.AddWindow(2) | 276 | w.AddWindow(2) | ||
| 167 | w.Apply(ActionMaximizeColumn) | 277 | w.Apply(ActionMaximizeColumn) | ||
| 168 | - | w.Apply(ActionToggleWindowFloating) | 278 | + | w.Apply(ActionToggleWindowStash) |
| 169 | - | w.Apply(ActionToggleWindowFloating) | 279 | + | w.Apply(ActionToggleWindowStash) |
| 170 | if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth { | 280 | if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].FullWidth { | ||
| 171 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | 281 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | ||
| 172 | } | 282 | } | ||
| 173 | } | 283 | } | ||
| 174 | 284 | ||||
| 175 | // A lone expanded column (fixed overflow) comes back expanded. | 285 | // A lone expanded column (fixed overflow) comes back expanded. | ||
| 176 | - | func TestToggleWindowFloatingKeepsExpanded(t *testing.T) { | 286 | + | func TestToggleWindowStashKeepsExpanded(t *testing.T) { |
| 177 | w := workspace() | 287 | w := workspace() | ||
| 178 | w.Overflow = OverflowFixed | 288 | w.Overflow = OverflowFixed | ||
| 179 | w.SetMaxColumns(3) | 289 | w.SetMaxColumns(3) | ||
| @@ -183,64 +293,46 @@ func TestToggleWindowFloatingKeepsExpanded(t *testing.T) { | |||||
| 183 | if !w.Columns[w.Focus].Expanded { | 293 | if !w.Columns[w.Focus].Expanded { | ||
| 184 | t.Fatal("column not expanded") | 294 | t.Fatal("column not expanded") | ||
| 185 | } | 295 | } | ||
| 186 | - | w.Apply(ActionToggleWindowFloating) | 296 | + | w.Apply(ActionToggleWindowStash) |
| 187 | - | w.Apply(ActionToggleWindowFloating) | 297 | + | w.Apply(ActionToggleWindowStash) |
| 188 | if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded { | 298 | if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded { | ||
| 189 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | 299 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | ||
| 190 | } | 300 | } | ||
| 191 | } | 301 | } | ||
| 192 | 302 | ||||
| 193 | - | // A client fullscreen request of a hidden float leaves it hidden on its | 303 | + | // A client fullscreen request of a hidden stashed window leaves it hidden |
| 194 | - | // workspace, even in fixed overflow (which moves fullscreen windows out). | 304 | + | // on its workspace, even in fixed overflow (which moves fullscreen windows |
| 305 | + | // out). | |||
| 195 | func TestHiddenFloatFullscreenRequest(t *testing.T) { | 306 | func TestHiddenFloatFullscreenRequest(t *testing.T) { | ||
| 196 | m := newMonitor("", "") | 307 | m := newMonitor("", "") | ||
| 197 | m.SetOutput(100, 80) | 308 | m.SetOutput(100, 80) | ||
| 198 | m.SetOverflow(OverflowFixed) | 309 | m.SetOverflow(OverflowFixed) | ||
| 199 | m.AddWindow(1) | 310 | m.AddWindow(1) | ||
| 200 | - | m.AddFloating(2, 20, 10) | 311 | + | m.AddWindow(2) |
| 201 | w := m.Current() | 312 | w := m.Current() | ||
| 202 | - | w.ToggleFloatingVisible() | 313 | + | w.ToggleWindowStash() |
| 314 | + | w.ToggleStashVisible() | |||
| 203 | m.SetFullscreen(2, true) | 315 | m.SetFullscreen(2, true) | ||
| 204 | - | if m.Current() != w || w.floatIndex(2) < 0 || !w.floatsHidden || w.fullscreen != 0 { | 316 | + | if m.Current() != w || w.stashIndex(2) < 0 || !w.stashHidden || w.fullscreen != 0 { |
| 205 | - | t.Fatalf("hidden float left or showed: %+v", w) | 317 | + | t.Fatalf("hidden stash left or showed: %+v", w) |
| 206 | } | 318 | } | ||
| 207 | - | w.ToggleFloatingVisible() | 319 | + | w.ToggleStashVisible() |
| 208 | if w.fullscreen != 2 { | 320 | if w.fullscreen != 2 { | ||
| 209 | t.Fatalf("fullscreen lost on show: %d", w.fullscreen) | 321 | t.Fatalf("fullscreen lost on show: %d", w.fullscreen) | ||
| 210 | } | 322 | } | ||
| 211 | } | 323 | } | ||
| 212 | 324 | ||||
| 213 | - | // Only a focused float hides the floats, and never over a fullscreen float. | 325 | + | // A fullscreen stashed window (a game in scanout) keeps the stash shown: |
| 214 | - | func TestToggleFloatingVisibleFocusAndFullscreen(t *testing.T) { | 326 | + | // hiding it would drop scanout and VRR. |
| 327 | + | func TestToggleStashVisibleFullscreen(t *testing.T) { | |||
| 215 | w := workspace() | 328 | w := workspace() | ||
| 216 | - | w.Overflow = OverflowFixed | |||
| 217 | w.AddWindow(1) | 329 | w.AddWindow(1) | ||
| 218 | - | w.AddFloating(2, 20, 10) | 330 | + | w.AddWindow(2) |
| 219 | - | w.FocusID(1) | 331 | + | w.ToggleWindowStash() |
| 220 | - | w.Apply(ActionToggleFloatingVisible) | |||
| 221 | - | if id, _ := w.Focused(); id != 1 || w.floatsHidden { | |||
| 222 | - | t.Fatalf("column window hid floats: focused %d hidden %v", id, w.floatsHidden) | |||
| 223 | - | } | |||
| 224 | - | // Shown again from a column window, the top float takes the focus. | |||
| 225 | - | w.FocusID(2) | |||
| 226 | - | w.Apply(ActionToggleFloatingVisible) | |||
| 227 | - | w.FocusID(1) | |||
| 228 | - | w.Apply(ActionToggleFloatingVisible) | |||
| 229 | - | if id, _ := w.Focused(); id != 2 || w.floatsHidden { | |||
| 230 | - | t.Fatalf("show from column: focused %d hidden %v", id, w.floatsHidden) | |||
| 231 | - | } | |||
| 232 | - | // A fullscreen float (a game in scanout) stays shown: hiding it would | |||
| 233 | - | // drop scanout and VRR. | |||
| 234 | - | w.FocusID(2) | |||
| 235 | w.SetFullscreen(2, true) | 332 | w.SetFullscreen(2, true) | ||
| 236 | - | w.Apply(ActionToggleFloatingVisible) | 333 | + | w.Apply(ActionToggleStashVisible) |
| 237 | - | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.floatsHidden { | 334 | + | if id, _ := w.Focused(); id != 2 || w.fullscreen != 2 || w.stashHidden { |
| 238 | - | t.Fatalf("fullscreen float hidden: focused %d fullscreen %d", id, w.fullscreen) | 335 | + | t.Fatalf("fullscreen stash hidden: focused %d fullscreen %d", id, w.fullscreen) |
| 239 | - | } | |||
| 240 | - | w.FocusID(1) | |||
| 241 | - | w.Apply(ActionToggleFloatingVisible) | |||
| 242 | - | if w.fullscreen != 2 || w.floatsHidden { | |||
| 243 | - | t.Fatalf("fullscreen float hidden from column: fullscreen %d", w.fullscreen) | |||
| 244 | } | 336 | } | ||
| 245 | } | 337 | } | ||
| 246 | 338 | ||||
| @@ -270,7 +362,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||||
| 270 | } | 362 | } | ||
| 271 | } | 363 | } | ||
| 272 | check(0) | 364 | check(0) | ||
| 273 | - | w.ToggleWindowFloating() | 365 | + | w.ToggleWindowStash() |
| 274 | check(0.3) | 366 | check(0.3) | ||
| 275 | found := false | 367 | found := false | ||
| 276 | for len(commands) > 0 { | 368 | for len(commands) > 0 { | ||
| @@ -300,14 +392,18 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||||
| 300 | if !found { | 392 | if !found { | ||
| 301 | t.Fatal("missing native configure") | 393 | t.Fatal("missing native configure") | ||
| 302 | } | 394 | } | ||
| 303 | - | w.ToggleFloatingVisible() | 395 | + | // Hiding the stash leaves the dialog, and its veil. |
| 396 | + | w.ToggleStashVisible() | |||
| 397 | + | check(0.3) | |||
| 398 | + | w.RemoveWindow(2) | |||
| 304 | check(0) | 399 | check(0) | ||
| 305 | - | w.ToggleFloatingVisible() | 400 | + | w.ToggleStashVisible() |
| 306 | check(0.3) | 401 | check(0.3) | ||
| 402 | + | w.AddFloating(2, 20, 10) | |||
| 307 | w.SetFullscreen(1, true) | 403 | w.SetFullscreen(1, true) | ||
| 308 | check(0) | 404 | check(0) | ||
| 309 | w.SetFullscreen(1, false) | 405 | w.SetFullscreen(1, false) | ||
| 310 | - | w.ToggleWindowFloating() | 406 | + | w.ToggleWindowStash() |
| 311 | w.SetFullscreen(1, true) | 407 | w.SetFullscreen(1, true) | ||
| 312 | check(0) | 408 | check(0) | ||
| 313 | // A dialog of a fullscreen tile waits hidden: no veil over the game. | 409 | // A dialog of a fullscreen tile waits hidden: no veil over the game. | ||
| @@ -323,26 +419,27 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) { | |||||
| 323 | check(0.3) | 419 | check(0.3) | ||
| 324 | } | 420 | } | ||
| 325 | 421 | ||||
| 326 | - | // Activating a hidden float shows the floats and focuses it; the | 422 | + | // Activating a hidden stashed window shows the stash and focuses it; the |
| 327 | // fullscreen window it hid behind leaves fullscreen. | 423 | // fullscreen window it hid behind leaves fullscreen. | ||
| 328 | func TestActivateHiddenFloat(t *testing.T) { | 424 | func TestActivateHiddenFloat(t *testing.T) { | ||
| 329 | w := workspace() | 425 | w := workspace() | ||
| 330 | w.AddWindow(1) | 426 | w.AddWindow(1) | ||
| 331 | - | w.AddFloating(2, 20, 10) | 427 | + | w.AddWindow(2) |
| 332 | - | w.ToggleFloatingVisible() | 428 | + | w.ToggleWindowStash() |
| 429 | + | w.ToggleStashVisible() | |||
| 333 | w.FocusID(1) | 430 | w.FocusID(1) | ||
| 334 | w.SetFullscreen(1, true) | 431 | w.SetFullscreen(1, true) | ||
| 335 | w.Activate(2) | 432 | w.Activate(2) | ||
| 336 | - | if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 0 { | 433 | + | if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 0 { |
| 337 | - | t.Fatalf("focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen) | 434 | + | t.Fatalf("focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen) |
| 338 | } | 435 | } | ||
| 339 | // The float's own fullscreen, asked while hidden, comes back with it. | 436 | // The float's own fullscreen, asked while hidden, comes back with it. | ||
| 340 | w.FocusID(2) | 437 | w.FocusID(2) | ||
| 341 | - | w.ToggleFloatingVisible() | 438 | + | w.ToggleStashVisible() |
| 342 | w.SetFullscreen(2, true) | 439 | w.SetFullscreen(2, true) | ||
| 343 | w.SetFullscreen(1, true) | 440 | w.SetFullscreen(1, true) | ||
| 344 | w.Activate(2) | 441 | w.Activate(2) | ||
| 345 | - | if id, _ := w.Focused(); id != 2 || w.floatsHidden || w.fullscreen != 2 { | 442 | + | if id, _ := w.Focused(); id != 2 || w.stashHidden || w.fullscreen != 2 { |
| 346 | - | t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.floatsHidden, w.fullscreen) | 443 | + | t.Fatalf("own fullscreen: focused %d hidden %v fullscreen %d", id, w.stashHidden, w.fullscreen) |
| 347 | } | 444 | } | ||
| 348 | } | 445 | } | ||
M internal/core/layout.go +115 -141
| @@ -97,6 +97,8 @@ type Placement struct { | |||
| 97 | 97 | Fullscreen, Focused, Hidden bool | |
| 98 | 98 | // Floating windows sit over the columns at their own size. | |
| 99 | 99 | Floating bool | |
| 100 | + | // Peek is a stashed window peeking in beside the selected one: dimmed. | |
| 101 | + | Peek bool | |
| 100 | 102 | // Neighbors are the sides touching another tiled window. Inset are the | |
| 101 | 103 | // sides where the border takes room from the window: all sides of a | |
| 102 | 104 | // float; for tiles, the right and bottom neighbors only, so two | |
| @@ -134,13 +136,23 @@ type Workspace struct { | |||
| 134 | 136 | border int | |
| 135 | 137 | presets []Width | |
| 136 | 138 | fullscreen WindowID | |
| 137 | - | // Floats are floating windows, bottom to top; floatFocus is set while | |
| 138 | - | // the top one has the focus instead of the columns. | |
| 139 | - | Floats []Float | |
| 140 | - | floatFocus bool | |
| 141 | - | floatsHidden bool | |
| 142 | - | // hiddenFullscreen holds a float's fullscreen while the floats are | |
| 143 | - | // hidden, restored when shown. | |
| 139 | + | // Floats are native floating windows (dialogs), bottom to top; | |
| 140 | + | // floatFocus is set while the top one has the focus. | |
| 141 | + | Floats []Float | |
| 142 | + | floatFocus bool | |
| 143 | + | // Stash holds the windows set aside by toggle-window-stash, left to | |
| 144 | + | // right (stash.go); stashAt is the selected one. stashFocus is set | |
| 145 | + | // while it has the focus, under a focused native float. | |
| 146 | + | Stash []Float | |
| 147 | + | stashAt int | |
| 148 | + | stashFocus bool | |
| 149 | + | stashHidden bool | |
| 150 | + | // stashWidth is the width of a stashed window (0: the default) and | |
| 151 | + | // stashGap the space between it and its neighbors, in percent of the | |
| 152 | + | // usable width. | |
| 153 | + | stashWidth, stashGap int | |
| 154 | + | // hiddenFullscreen holds a stashed window's fullscreen while the stash | |
| 155 | + | // is hidden, restored when shown. | |
| 144 | 156 | hiddenFullscreen WindowID | |
| 145 | 157 | // home is the monitor (key or connector) the workspace belongs to; "" | |
| 146 | 158 | // means the one it is on. On another monitor it is a guest; homePos is | |
| @@ -171,24 +183,32 @@ type origPlace struct { | |||
| 171 | 183 | // fullWidth is the column's maximize-column state. | |
| 172 | 184 | fullWidth bool | |
| 173 | 185 | float *Float | |
| 186 | + | // stash is set when float came from the stash, at index col. | |
| 187 | + | stash bool | |
| 174 | 188 | } | |
| 175 | 189 | ||
| 176 | 190 | // Float is a floating window and its client size, logical. | |
| 177 | 191 | type Float struct { | |
| 178 | 192 | ID WindowID | |
| 179 | 193 | W, H int | |
| 180 | - | // back records the former column of a tiled window; nil for native floats. | |
| 194 | + | // back records the former column of a stashed window; nil for native floats. | |
| 181 | 195 | back *origPlace | |
| 182 | 196 | } | |
| 183 | 197 | ||
| 184 | - | func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 } | |
| 198 | + | func (w *Workspace) empty() bool { | |
| 199 | + | return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0 | |
| 200 | + | } | |
| 185 | 201 | ||
| 186 | - | // windows lists every window: columns first, then floating ones. | |
| 202 | + | // windows lists every window: columns first, then the stash, then native | |
| 203 | + | // floats. | |
| 187 | 204 | func (w *Workspace) windows() []WindowID { | |
| 188 | 205 | var ids []WindowID | |
| 189 | 206 | for _, c := range w.Columns { | |
| 190 | 207 | ids = append(ids, c.Windows...) | |
| 191 | 208 | } | |
| 209 | + | for _, f := range w.Stash { | |
| 210 | + | ids = append(ids, f.ID) | |
| 211 | + | } | |
| 192 | 212 | for _, f := range w.Floats { | |
| 193 | 213 | ids = append(ids, f.ID) | |
| 194 | 214 | } | |
| @@ -199,15 +219,12 @@ func (w *Workspace) floatIndex(id WindowID) int { | |||
| 199 | 219 | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | |
| 200 | 220 | } | |
| 201 | 221 | ||
| 202 | - | // AddFloating shows a window over the columns and focuses it. | |
| 222 | + | // AddFloating shows a native floating window over the columns and the | |
| 223 | + | // stash, and focuses it. | |
| 203 | 224 | func (w *Workspace) AddFloating(id WindowID, width, height int) { | |
| 204 | 225 | if id == 0 || w.has(id) { | |
| 205 | 226 | return | |
| 206 | 227 | } | |
| 207 | - | if w.floatsHidden { | |
| 208 | - | // A new float must be seen: show the hidden ones with it. | |
| 209 | - | w.showFloats() | |
| 210 | - | } | |
| 211 | 228 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | |
| 212 | 229 | w.floatFocus = true | |
| 213 | 230 | } | |
| @@ -215,15 +232,13 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 215 | 232 | // ResizeFloating records the size a floating window draws. | |
| 216 | 233 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | |
| 217 | 234 | if i := w.floatIndex(id); i >= 0 { | |
| 218 | - | if w.Floats[i].back == nil { | |
| 219 | - | w.Floats[i].W, w.Floats[i].H = width, height | |
| 220 | - | } | |
| 235 | + | w.Floats[i].W, w.Floats[i].H = width, height | |
| 221 | 236 | } | |
| 222 | 237 | } | |
| 223 | 238 | ||
| 224 | 239 | // has reports whether the workspace holds the window. | |
| 225 | 240 | func (w *Workspace) has(id WindowID) bool { | |
| 226 | - | if w.floatIndex(id) >= 0 { | |
| 241 | + | if w.isFloat(id) { | |
| 227 | 242 | return true | |
| 228 | 243 | } | |
| 229 | 244 | for _, c := range w.Columns { | |
| @@ -241,9 +256,12 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 241 | 256 | if full := w.cover(); full != 0 { | |
| 242 | 257 | return full, true | |
| 243 | 258 | } | |
| 244 | - | if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 { | |
| 259 | + | if w.floatFocus && len(w.Floats) > 0 { | |
| 245 | 260 | return w.Floats[len(w.Floats)-1].ID, true | |
| 246 | 261 | } | |
| 262 | + | if w.stashFocused() { | |
| 263 | + | return w.Stash[w.stashAt].ID, true | |
| 264 | + | } | |
| 247 | 265 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | |
| 248 | 266 | return 0, false | |
| 249 | 267 | } | |
| @@ -340,16 +358,27 @@ func (w *Workspace) unslot(n int) { | |||
| 340 | 358 | } | |
| 341 | 359 | ||
| 342 | 360 | func (w *Workspace) RemoveWindow(id WindowID) { | |
| 361 | + | if w.fullscreen == id { | |
| 362 | + | w.fullscreen = 0 | |
| 363 | + | } | |
| 364 | + | if w.hiddenFullscreen == id { | |
| 365 | + | w.hiddenFullscreen = 0 | |
| 366 | + | } | |
| 343 | 367 | if i := w.floatIndex(id); i >= 0 { | |
| 344 | 368 | w.Floats = slices.Delete(w.Floats, i, i+1) | |
| 345 | - | if w.fullscreen == id { | |
| 346 | - | w.fullscreen = 0 | |
| 369 | + | if len(w.Floats) == 0 { | |
| 370 | + | w.floatFocus = false | |
| 347 | 371 | } | |
| 348 | - | if w.hiddenFullscreen == id { | |
| 349 | - | w.hiddenFullscreen = 0 | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | if i := w.stashIndex(id); i >= 0 { | |
| 375 | + | // The right neighbor takes the selection, else the left one. | |
| 376 | + | w.Stash = slices.Delete(w.Stash, i, i+1) | |
| 377 | + | if i < w.stashAt || w.stashAt == len(w.Stash) { | |
| 378 | + | w.stashAt = max(w.stashAt-1, 0) | |
| 350 | 379 | } | |
| 351 | - | if len(w.Floats) == 0 { | |
| 352 | - | w.floatFocus, w.floatsHidden = false, false | |
| 380 | + | if len(w.Stash) == 0 { | |
| 381 | + | w.stashFocus, w.stashHidden = false, false | |
| 353 | 382 | } | |
| 354 | 383 | return | |
| 355 | 384 | } | |
| @@ -360,9 +389,6 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 360 | 389 | } | |
| 361 | 390 | c := &w.Columns[i] | |
| 362 | 391 | c.Windows = append(c.Windows[:j], c.Windows[j+1:]...) | |
| 363 | - | if w.fullscreen == id { | |
| 364 | - | w.fullscreen = 0 | |
| 365 | - | } | |
| 366 | 392 | if len(c.Windows) == 0 { | |
| 367 | 393 | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | |
| 368 | 394 | if len(w.Columns) == 0 { | |
| @@ -386,22 +412,26 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 386 | 412 | } | |
| 387 | 413 | } | |
| 388 | 414 | ||
| 389 | - | // FocusID selects a window and scrolls its column into view; a floating | |
| 390 | - | // window is raised. | |
| 415 | + | // FocusID selects a window and scrolls its column into view; a native | |
| 416 | + | // float is raised, a stashed window is selected. | |
| 391 | 417 | func (w *Workspace) FocusID(id WindowID) bool { | |
| 392 | 418 | if i := w.floatIndex(id); i >= 0 { | |
| 393 | - | if w.floatsHidden { | |
| 394 | - | return false | |
| 395 | - | } | |
| 396 | 419 | f := w.Floats[i] | |
| 397 | 420 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | |
| 398 | 421 | w.floatFocus = true | |
| 399 | 422 | return true | |
| 400 | 423 | } | |
| 401 | - | w.floatFocus = false | |
| 424 | + | if i := w.stashIndex(id); i >= 0 { | |
| 425 | + | if w.stashHidden { | |
| 426 | + | return false | |
| 427 | + | } | |
| 428 | + | w.stashAt, w.stashFocus, w.floatFocus = i, true, false | |
| 429 | + | return true | |
| 430 | + | } | |
| 402 | 431 | for i := range w.Columns { | |
| 403 | 432 | for j, v := range w.Columns[i].Windows { | |
| 404 | 433 | if v == id { | |
| 434 | + | w.floatFocus, w.stashFocus = false, false | |
| 405 | 435 | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | |
| 406 | 436 | w.Columns[w.Focus].FullWidth = false | |
| 407 | 437 | } | |
| @@ -419,10 +449,17 @@ func (w *Workspace) FocusColumn(dir int) { | |||
| 419 | 449 | return | |
| 420 | 450 | } | |
| 421 | 451 | if w.floatFocus { | |
| 422 | - | // The first move leaves the floating window for the columns. | |
| 452 | + | // The first move leaves the native float for the stash or columns. | |
| 423 | 453 | w.floatFocus = false | |
| 424 | 454 | return | |
| 425 | 455 | } | |
| 456 | + | if w.stashFocused() { | |
| 457 | + | // Inside the stash, stopping at its ends: only hiding it leaves. | |
| 458 | + | if (dir == -1 || dir == 1) && w.stashAt+dir >= 0 && w.stashAt+dir < len(w.Stash) { | |
| 459 | + | w.stashAt += dir | |
| 460 | + | } | |
| 461 | + | return | |
| 462 | + | } | |
| 426 | 463 | if i := w.columnToward(dir); i >= 0 { | |
| 427 | 464 | if w.Overflow == OverflowFixed { | |
| 428 | 465 | w.Columns[w.Focus].FullWidth = false | |
| @@ -466,6 +503,10 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||
| 466 | 503 | w.floatFocus = false | |
| 467 | 504 | return true | |
| 468 | 505 | } | |
| 506 | + | if w.stashFocused() { | |
| 507 | + | // The stash is one row: up and down do nothing there. | |
| 508 | + | return true | |
| 509 | + | } | |
| 469 | 510 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | |
| 470 | 511 | return false | |
| 471 | 512 | } | |
| @@ -522,7 +563,7 @@ func (w *Workspace) screenNeighbor(dx, dy int) int { | |||
| 522 | 563 | ||
| 523 | 564 | func abs(n int) int { return max(n, -n) } | |
| 524 | 565 | func (w *Workspace) MoveColumn(dir int) { | |
| 525 | - | if w.floatFocus { | |
| 566 | + | if w.onFloat() { | |
| 526 | 567 | return | |
| 527 | 568 | } | |
| 528 | 569 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | |
| @@ -535,7 +576,7 @@ func (w *Workspace) MoveColumn(dir int) { | |||
| 535 | 576 | ||
| 536 | 577 | // takeColumn removes the focused column and returns it, as a normal column. | |
| 537 | 578 | func (w *Workspace) takeColumn() (Column, bool) { | |
| 538 | - | if len(w.Columns) == 0 || w.floatFocus { | |
| 579 | + | if len(w.Columns) == 0 || w.onFloat() { | |
| 539 | 580 | return Column{}, false | |
| 540 | 581 | } | |
| 541 | 582 | col := w.Columns[w.Focus] | |
| @@ -571,7 +612,7 @@ func (w *Workspace) CycleWidth() { | |||
| 571 | 612 | w.toggleExpanded() | |
| 572 | 613 | return | |
| 573 | 614 | } | |
| 574 | - | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { | |
| 615 | + | if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() { | |
| 575 | 616 | return | |
| 576 | 617 | } | |
| 577 | 618 | // auto → presets in order → auto. | |
| @@ -594,7 +635,7 @@ func (w *Workspace) CycleWidth() { | |||
| 594 | 635 | // toggleExpanded makes the focused column the expanded one, or shrinks it | |
| 595 | 636 | // back when it already is. One column at most is expanded. | |
| 596 | 637 | func (w *Workspace) toggleExpanded() { | |
| 597 | - | if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.floatFocus { | |
| 638 | + | if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.onFloat() { | |
| 598 | 639 | return | |
| 599 | 640 | } | |
| 600 | 641 | on := !w.Columns[w.Focus].Expanded | |
| @@ -607,7 +648,7 @@ func (w *Workspace) toggleExpanded() { | |||
| 607 | 648 | ||
| 608 | 649 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | |
| 609 | 650 | func (w *Workspace) ToggleFullWidth() { | |
| 610 | - | if len(w.Columns) == 0 || w.floatFocus { | |
| 651 | + | if len(w.Columns) == 0 || w.onFloat() { | |
| 611 | 652 | return | |
| 612 | 653 | } | |
| 613 | 654 | c := &w.Columns[w.Focus] | |
| @@ -631,12 +672,12 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 631 | 672 | // Activate focuses a window and makes it visible: it leaves another | |
| 632 | 673 | // window's fullscreen, which hides everything else. | |
| 633 | 674 | func (w *Workspace) Activate(id WindowID) { | |
| 634 | - | if w.floatsHidden && w.floatIndex(id) >= 0 { | |
| 635 | - | // A hidden float must be seen: show the floats with it. Another | |
| 636 | - | // window's fullscreen leaves first, so the float's own pending | |
| 637 | - | // fullscreen comes back. | |
| 638 | - | w.fullscreen = 0 | |
| 639 | - | w.showFloats() | |
| 675 | + | if i := w.stashIndex(id); i >= 0 && w.stashHidden { | |
| 676 | + | // A hidden stashed window must be seen: show the stash with it, | |
| 677 | + | // selected. Another window's fullscreen leaves first, so its own | |
| 678 | + | // pending fullscreen comes back. | |
| 679 | + | w.fullscreen, w.stashAt = 0, i | |
| 680 | + | w.showStash() | |
| 640 | 681 | } | |
| 641 | 682 | if w.fullscreen != 0 && w.fullscreen != id { | |
| 642 | 683 | w.fullscreen = 0 | |
| @@ -647,9 +688,9 @@ func (w *Workspace) Activate(id WindowID) { | |||
| 647 | 688 | // SetFullscreen applies a client request. It never moves focus: client | |
| 648 | 689 | // requests are automatic events (ADR 011 golden rule). | |
| 649 | 690 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 650 | - | if w.floatIndex(id) >= 0 { | |
| 651 | - | if w.floatsHidden { | |
| 652 | - | // Applied when the floats are shown again. | |
| 691 | + | if w.isFloat(id) { | |
| 692 | + | if w.stashHidden && w.stashIndex(id) >= 0 { | |
| 693 | + | // Applied when the stash is shown again. | |
| 653 | 694 | if on { | |
| 654 | 695 | w.hiddenFullscreen = id | |
| 655 | 696 | } else if w.hiddenFullscreen == id { | |
| @@ -723,7 +764,7 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |||
| 723 | 764 | // Moves to another workspace or monitor still work. | |
| 724 | 765 | func (w *Workspace) pinned() bool { | |
| 725 | 766 | c := w.cover() | |
| 726 | - | return c != 0 && (w.Overflow == OverflowFixed || w.floatIndex(c) >= 0) | |
| 767 | + | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) | |
| 727 | 768 | } | |
| 728 | 769 | ||
| 729 | 770 | // focusCover points the focus state at the covering fullscreen window, | |
| @@ -734,12 +775,11 @@ func (w *Workspace) focusCover() { | |||
| 734 | 775 | if c == 0 { | |
| 735 | 776 | return | |
| 736 | 777 | } | |
| 737 | - | if w.floatIndex(c) >= 0 { | |
| 738 | - | w.floatFocus = true | |
| 778 | + | if w.isFloat(c) { | |
| 739 | 779 | w.FocusID(c) | |
| 740 | 780 | return | |
| 741 | 781 | } | |
| 742 | - | w.floatFocus = false | |
| 782 | + | w.floatFocus, w.stashFocus = false, false | |
| 743 | 783 | for i, col := range w.Columns { | |
| 744 | 784 | if j := slices.Index(col.Windows, c); j >= 0 { | |
| 745 | 785 | w.Focus, w.Columns[i].Focus = i, j | |
| @@ -754,7 +794,7 @@ func (w *Workspace) cover() WindowID { | |||
| 754 | 794 | if w.fullscreen == 0 { | |
| 755 | 795 | return 0 | |
| 756 | 796 | } | |
| 757 | - | if w.floatIndex(w.fullscreen) >= 0 || w.Overflow == OverflowFixed { | |
| 797 | + | if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed { | |
| 758 | 798 | return w.fullscreen | |
| 759 | 799 | } | |
| 760 | 800 | // Scroll overflow: only while its column is aligned on the output. | |
| @@ -963,11 +1003,13 @@ func (w *Workspace) Layout() []Placement { | |||
| 963 | 1003 | } | |
| 964 | 1004 | } | |
| 965 | 1005 | setNeighbors(result, gap) | |
| 966 | - | // Floating windows go last: they are drawn and hit on top. | |
| 1006 | + | // Floating windows go last: they are drawn and hit on top, native | |
| 1007 | + | // floats (dialogs) over the stash. | |
| 1008 | + | result = append(result, w.stashLayout(focusedID, cover)...) | |
| 967 | 1009 | for _, f := range w.Floats { | |
| 968 | 1010 | // A covering fullscreen window hides every float, its own dialogs | |
| 969 | 1011 | // too: they show again when it leaves fullscreen. | |
| 970 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden || cover != 0 && f.ID != cover} | |
| 1012 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover} | |
| 971 | 1013 | if p.Hidden { | |
| 972 | 1014 | p.Rect = Rect{} | |
| 973 | 1015 | result = append(result, p) | |
| @@ -1014,106 +1056,38 @@ func setNeighbors(tiles []Placement, gap int) { | |||
| 1014 | 1056 | } | |
| 1015 | 1057 | } | |
| 1016 | 1058 | ||
| 1017 | - | // floatRect centres a floating window in the usable area, its border | |
| 1018 | - | // around its client size, clamped to the area. | |
| 1059 | + | // floatRect centres a native floating window in the usable area, its | |
| 1060 | + | // border around its client size, clamped to the area. | |
| 1019 | 1061 | func (w *Workspace) floatRect(f Float) Rect { | |
| 1020 | 1062 | u := w.Usable | |
| 1021 | 1063 | fw, fh := f.W, f.H | |
| 1022 | - | if f.back != nil { | |
| 1023 | - | fw, fh = max(u.W*80/100-2*w.border, 0), max(u.H*80/100-2*w.border, 0) | |
| 1024 | - | } | |
| 1025 | - | if f.back == nil && (fw <= 0 || fh <= 0) { | |
| 1064 | + | if fw <= 0 || fh <= 0 { | |
| 1026 | 1065 | fw, fh = u.W/2, u.H/2 | |
| 1027 | 1066 | } | |
| 1028 | 1067 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | |
| 1029 | 1068 | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 1030 | 1069 | } | |
| 1031 | 1070 | ||
| 1032 | - | // imposedFloat reports whether core, rather than the client, owns the size. | |
| 1033 | - | func (w *Workspace) imposedFloat(id WindowID) bool { | |
| 1034 | - | i := w.floatIndex(id) | |
| 1035 | - | return i >= 0 && w.Floats[i].back != nil | |
| 1036 | - | } | |
| 1071 | + | // imposedFloat reports whether core, rather than the client, owns the | |
| 1072 | + | // size of a floating window: a stashed one. | |
| 1073 | + | func (w *Workspace) imposedFloat(id WindowID) bool { return w.stashIndex(id) >= 0 } | |
| 1037 | 1074 | ||
| 1038 | - | // ToggleWindowFloating converts a tile to an 80% float, remembering its | |
| 1039 | - | // column and row. Native floats instead become new columns. | |
| 1040 | - | func (w *Workspace) ToggleWindowFloating() { | |
| 1075 | + | // ToggleWindowStash stashes the focused tile, or returns the focused | |
| 1076 | + | // stashed window to its column. A native float becomes a new column. | |
| 1077 | + | func (w *Workspace) ToggleWindowStash() { | |
| 1041 | 1078 | id, ok := w.Focused() | |
| 1042 | 1079 | // A fullscreen window (a Wine game in scanout) keeps its place: it | |
| 1043 | 1080 | // leaves fullscreen first, then may float. | |
| 1044 | 1081 | if !ok || id == w.fullscreen { | |
| 1045 | 1082 | return | |
| 1046 | 1083 | } | |
| 1047 | - | if i := w.floatIndex(id); i >= 0 { | |
| 1048 | - | f := w.Floats[i] | |
| 1084 | + | switch { | |
| 1085 | + | case w.floatIndex(id) >= 0: | |
| 1049 | 1086 | w.RemoveWindow(id) | |
| 1050 | - | if f.back == nil { | |
| 1051 | - | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 1052 | - | } else { | |
| 1053 | - | back := f.back | |
| 1054 | - | at := min(back.col, len(w.Columns)) | |
| 1055 | - | if i := slices.IndexFunc(w.Columns, back.holdsStack); i >= 0 { | |
| 1056 | - | at = i | |
| 1057 | - | c := &w.Columns[at] | |
| 1058 | - | row := 0 | |
| 1059 | - | for _, v := range back.stacked[:back.row] { | |
| 1060 | - | if slices.Contains(c.Windows, v) { | |
| 1061 | - | row++ | |
| 1062 | - | } | |
| 1063 | - | } | |
| 1064 | - | c.Windows = slices.Insert(c.Windows, row, id) | |
| 1065 | - | c.Focus = row | |
| 1066 | - | w.Focus = at | |
| 1067 | - | w.scroll() | |
| 1068 | - | } else { | |
| 1069 | - | // Its column is gone: it comes back expanded, unless another | |
| 1070 | - | // column was expanded meanwhile (as leaveFullscreen). | |
| 1071 | - | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | |
| 1072 | - | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded}) | |
| 1073 | - | } | |
| 1074 | - | } | |
| 1075 | - | w.floatFocus = false | |
| 1076 | - | return | |
| 1077 | - | } | |
| 1078 | - | col := w.Focus | |
| 1079 | - | c := w.Columns[col] | |
| 1080 | - | row := c.Focus | |
| 1081 | - | lone := len(c.Windows) == 1 | |
| 1082 | - | back := &origPlace{col: col, row: row, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | |
| 1083 | - | for _, v := range c.Windows { | |
| 1084 | - | if v != id { | |
| 1085 | - | back.stacked = append(back.stacked, v) | |
| 1086 | - | } | |
| 1087 | - | } | |
| 1088 | - | w.RemoveWindow(id) | |
| 1089 | - | w.AddFloating(id, 0, 0) | |
| 1090 | - | w.Floats[len(w.Floats)-1].back = back | |
| 1091 | - | } | |
| 1092 | - | ||
| 1093 | - | // ToggleFloatingVisible hides every float without changing its placement | |
| 1094 | - | // when a float has the focus, and shows them again from any window. It | |
| 1095 | - | // does nothing under a covering fullscreen window (a game in scanout): | |
| 1096 | - | // hiding it would drop scanout and VRR. | |
| 1097 | - | func (w *Workspace) ToggleFloatingVisible() { | |
| 1098 | - | if len(w.Floats) == 0 || w.cover() != 0 { | |
| 1099 | - | return | |
| 1100 | - | } | |
| 1101 | - | if !w.floatsHidden { | |
| 1102 | - | if w.floatFocus { | |
| 1103 | - | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 | |
| 1104 | - | } | |
| 1105 | - | return | |
| 1106 | - | } | |
| 1107 | - | w.showFloats() | |
| 1108 | - | } | |
| 1109 | - | ||
| 1110 | - | // showFloats shows the hidden floats; the top one takes the focus and a | |
| 1111 | - | // hidden fullscreen float is fullscreen again. | |
| 1112 | - | func (w *Workspace) showFloats() { | |
| 1113 | - | w.floatsHidden = false | |
| 1114 | - | w.floatFocus = true | |
| 1115 | - | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | |
| 1116 | - | w.fullscreen = w.hiddenFullscreen | |
| 1087 | + | w.restore(id, nil) | |
| 1088 | + | case w.stashIndex(id) >= 0: | |
| 1089 | + | w.unstash(w.stashIndex(id)) | |
| 1090 | + | default: | |
| 1091 | + | w.stashWindow(id) | |
| 1117 | 1092 | } | |
| 1118 | - | w.hiddenFullscreen = 0 | |
| 1119 | 1093 | } | |
| @@ -97,6 +97,8 @@ type Placement struct { | |||||
| 97 | Fullscreen, Focused, Hidden bool | 97 | Fullscreen, Focused, Hidden bool | ||
| 98 | // Floating windows sit over the columns at their own size. | 98 | // Floating windows sit over the columns at their own size. | ||
| 99 | Floating bool | 99 | Floating bool | ||
| 100 | + | // Peek is a stashed window peeking in beside the selected one: dimmed. | |||
| 101 | + | Peek bool | |||
| 100 | // Neighbors are the sides touching another tiled window. Inset are the | 102 | // Neighbors are the sides touching another tiled window. Inset are the | ||
| 101 | // sides where the border takes room from the window: all sides of a | 103 | // sides where the border takes room from the window: all sides of a | ||
| 102 | // float; for tiles, the right and bottom neighbors only, so two | 104 | // float; for tiles, the right and bottom neighbors only, so two | ||
| @@ -134,13 +136,23 @@ type Workspace struct { | |||||
| 134 | border int | 136 | border int | ||
| 135 | presets []Width | 137 | presets []Width | ||
| 136 | fullscreen WindowID | 138 | fullscreen WindowID | ||
| 137 | - | // Floats are floating windows, bottom to top; floatFocus is set while | 139 | + | // Floats are native floating windows (dialogs), bottom to top; |
| 138 | - | // the top one has the focus instead of the columns. | 140 | + | // floatFocus is set while the top one has the focus. |
| 139 | - | Floats []Float | 141 | + | Floats []Float |
| 140 | - | floatFocus bool | 142 | + | floatFocus bool |
| 141 | - | floatsHidden bool | 143 | + | // Stash holds the windows set aside by toggle-window-stash, left to |
| 142 | - | // hiddenFullscreen holds a float's fullscreen while the floats are | 144 | + | // right (stash.go); stashAt is the selected one. stashFocus is set |
| 143 | - | // hidden, restored when shown. | 145 | + | // while it has the focus, under a focused native float. |
| 146 | + | Stash []Float | |||
| 147 | + | stashAt int | |||
| 148 | + | stashFocus bool | |||
| 149 | + | stashHidden bool | |||
| 150 | + | // stashWidth is the width of a stashed window (0: the default) and | |||
| 151 | + | // stashGap the space between it and its neighbors, in percent of the | |||
| 152 | + | // usable width. | |||
| 153 | + | stashWidth, stashGap int | |||
| 154 | + | // hiddenFullscreen holds a stashed window's fullscreen while the stash | |||
| 155 | + | // is hidden, restored when shown. | |||
| 144 | hiddenFullscreen WindowID | 156 | hiddenFullscreen WindowID | ||
| 145 | // home is the monitor (key or connector) the workspace belongs to; "" | 157 | // home is the monitor (key or connector) the workspace belongs to; "" | ||
| 146 | // means the one it is on. On another monitor it is a guest; homePos is | 158 | // means the one it is on. On another monitor it is a guest; homePos is | ||
| @@ -171,24 +183,32 @@ type origPlace struct { | |||||
| 171 | // fullWidth is the column's maximize-column state. | 183 | // fullWidth is the column's maximize-column state. | ||
| 172 | fullWidth bool | 184 | fullWidth bool | ||
| 173 | float *Float | 185 | float *Float | ||
| 186 | + | // stash is set when float came from the stash, at index col. | |||
| 187 | + | stash bool | |||
| 174 | } | 188 | } | ||
| 175 | 189 | ||||
| 176 | // Float is a floating window and its client size, logical. | 190 | // Float is a floating window and its client size, logical. | ||
| 177 | type Float struct { | 191 | type Float struct { | ||
| 178 | ID WindowID | 192 | ID WindowID | ||
| 179 | W, H int | 193 | W, H int | ||
| 180 | - | // back records the former column of a tiled window; nil for native floats. | 194 | + | // back records the former column of a stashed window; nil for native floats. |
| 181 | back *origPlace | 195 | back *origPlace | ||
| 182 | } | 196 | } | ||
| 183 | 197 | ||||
| 184 | - | func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 } | 198 | + | func (w *Workspace) empty() bool { |
| 199 | + | return len(w.Columns) == 0 && len(w.Floats) == 0 && len(w.Stash) == 0 | |||
| 200 | + | } | |||
| 185 | 201 | ||||
| 186 | - | // windows lists every window: columns first, then floating ones. | 202 | + | // windows lists every window: columns first, then the stash, then native |
| 203 | + | // floats. | |||
| 187 | func (w *Workspace) windows() []WindowID { | 204 | func (w *Workspace) windows() []WindowID { | ||
| 188 | var ids []WindowID | 205 | var ids []WindowID | ||
| 189 | for _, c := range w.Columns { | 206 | for _, c := range w.Columns { | ||
| 190 | ids = append(ids, c.Windows...) | 207 | ids = append(ids, c.Windows...) | ||
| 191 | } | 208 | } | ||
| 209 | + | for _, f := range w.Stash { | |||
| 210 | + | ids = append(ids, f.ID) | |||
| 211 | + | } | |||
| 192 | for _, f := range w.Floats { | 212 | for _, f := range w.Floats { | ||
| 193 | ids = append(ids, f.ID) | 213 | ids = append(ids, f.ID) | ||
| 194 | } | 214 | } | ||
| @@ -199,15 +219,12 @@ func (w *Workspace) floatIndex(id WindowID) int { | |||||
| 199 | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | 219 | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | ||
| 200 | } | 220 | } | ||
| 201 | 221 | ||||
| 202 | - | // AddFloating shows a window over the columns and focuses it. | 222 | + | // AddFloating shows a native floating window over the columns and the |
| 223 | + | // stash, and focuses it. | |||
| 203 | func (w *Workspace) AddFloating(id WindowID, width, height int) { | 224 | func (w *Workspace) AddFloating(id WindowID, width, height int) { | ||
| 204 | if id == 0 || w.has(id) { | 225 | if id == 0 || w.has(id) { | ||
| 205 | return | 226 | return | ||
| 206 | } | 227 | } | ||
| 207 | - | if w.floatsHidden { | |||
| 208 | - | // A new float must be seen: show the hidden ones with it. | |||
| 209 | - | w.showFloats() | |||
| 210 | - | } | |||
| 211 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | 228 | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | ||
| 212 | w.floatFocus = true | 229 | w.floatFocus = true | ||
| 213 | } | 230 | } | ||
| @@ -215,15 +232,13 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||||
| 215 | // ResizeFloating records the size a floating window draws. | 232 | // ResizeFloating records the size a floating window draws. | ||
| 216 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | 233 | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | ||
| 217 | if i := w.floatIndex(id); i >= 0 { | 234 | if i := w.floatIndex(id); i >= 0 { | ||
| 218 | - | if w.Floats[i].back == nil { | 235 | + | w.Floats[i].W, w.Floats[i].H = width, height |
| 219 | - | w.Floats[i].W, w.Floats[i].H = width, height | |||
| 220 | - | } | |||
| 221 | } | 236 | } | ||
| 222 | } | 237 | } | ||
| 223 | 238 | ||||
| 224 | // has reports whether the workspace holds the window. | 239 | // has reports whether the workspace holds the window. | ||
| 225 | func (w *Workspace) has(id WindowID) bool { | 240 | func (w *Workspace) has(id WindowID) bool { | ||
| 226 | - | if w.floatIndex(id) >= 0 { | 241 | + | if w.isFloat(id) { |
| 227 | return true | 242 | return true | ||
| 228 | } | 243 | } | ||
| 229 | for _, c := range w.Columns { | 244 | for _, c := range w.Columns { | ||
| @@ -241,9 +256,12 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||||
| 241 | if full := w.cover(); full != 0 { | 256 | if full := w.cover(); full != 0 { | ||
| 242 | return full, true | 257 | return full, true | ||
| 243 | } | 258 | } | ||
| 244 | - | if w.floatFocus && !w.floatsHidden && len(w.Floats) > 0 { | 259 | + | if w.floatFocus && len(w.Floats) > 0 { |
| 245 | return w.Floats[len(w.Floats)-1].ID, true | 260 | return w.Floats[len(w.Floats)-1].ID, true | ||
| 246 | } | 261 | } | ||
| 262 | + | if w.stashFocused() { | |||
| 263 | + | return w.Stash[w.stashAt].ID, true | |||
| 264 | + | } | |||
| 247 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | 265 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | ||
| 248 | return 0, false | 266 | return 0, false | ||
| 249 | } | 267 | } | ||
| @@ -340,16 +358,27 @@ func (w *Workspace) unslot(n int) { | |||||
| 340 | } | 358 | } | ||
| 341 | 359 | ||||
| 342 | func (w *Workspace) RemoveWindow(id WindowID) { | 360 | func (w *Workspace) RemoveWindow(id WindowID) { | ||
| 361 | + | if w.fullscreen == id { | |||
| 362 | + | w.fullscreen = 0 | |||
| 363 | + | } | |||
| 364 | + | if w.hiddenFullscreen == id { | |||
| 365 | + | w.hiddenFullscreen = 0 | |||
| 366 | + | } | |||
| 343 | if i := w.floatIndex(id); i >= 0 { | 367 | if i := w.floatIndex(id); i >= 0 { | ||
| 344 | w.Floats = slices.Delete(w.Floats, i, i+1) | 368 | w.Floats = slices.Delete(w.Floats, i, i+1) | ||
| 345 | - | if w.fullscreen == id { | 369 | + | if len(w.Floats) == 0 { |
| 346 | - | w.fullscreen = 0 | 370 | + | w.floatFocus = false |
| 347 | } | 371 | } | ||
| 348 | - | if w.hiddenFullscreen == id { | 372 | + | return |
| 349 | - | w.hiddenFullscreen = 0 | 373 | + | } |
| 374 | + | if i := w.stashIndex(id); i >= 0 { | |||
| 375 | + | // The right neighbor takes the selection, else the left one. | |||
| 376 | + | w.Stash = slices.Delete(w.Stash, i, i+1) | |||
| 377 | + | if i < w.stashAt || w.stashAt == len(w.Stash) { | |||
| 378 | + | w.stashAt = max(w.stashAt-1, 0) | |||
| 350 | } | 379 | } | ||
| 351 | - | if len(w.Floats) == 0 { | 380 | + | if len(w.Stash) == 0 { |
| 352 | - | w.floatFocus, w.floatsHidden = false, false | 381 | + | w.stashFocus, w.stashHidden = false, false |
| 353 | } | 382 | } | ||
| 354 | return | 383 | return | ||
| 355 | } | 384 | } | ||
| @@ -360,9 +389,6 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 360 | } | 389 | } | ||
| 361 | c := &w.Columns[i] | 390 | c := &w.Columns[i] | ||
| 362 | c.Windows = append(c.Windows[:j], c.Windows[j+1:]...) | 391 | c.Windows = append(c.Windows[:j], c.Windows[j+1:]...) | ||
| 363 | - | if w.fullscreen == id { | |||
| 364 | - | w.fullscreen = 0 | |||
| 365 | - | } | |||
| 366 | if len(c.Windows) == 0 { | 392 | if len(c.Windows) == 0 { | ||
| 367 | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | 393 | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | ||
| 368 | if len(w.Columns) == 0 { | 394 | if len(w.Columns) == 0 { | ||
| @@ -386,22 +412,26 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 386 | } | 412 | } | ||
| 387 | } | 413 | } | ||
| 388 | 414 | ||||
| 389 | - | // FocusID selects a window and scrolls its column into view; a floating | 415 | + | // FocusID selects a window and scrolls its column into view; a native |
| 390 | - | // window is raised. | 416 | + | // float is raised, a stashed window is selected. |
| 391 | func (w *Workspace) FocusID(id WindowID) bool { | 417 | func (w *Workspace) FocusID(id WindowID) bool { | ||
| 392 | if i := w.floatIndex(id); i >= 0 { | 418 | if i := w.floatIndex(id); i >= 0 { | ||
| 393 | - | if w.floatsHidden { | |||
| 394 | - | return false | |||
| 395 | - | } | |||
| 396 | f := w.Floats[i] | 419 | f := w.Floats[i] | ||
| 397 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | 420 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | ||
| 398 | w.floatFocus = true | 421 | w.floatFocus = true | ||
| 399 | return true | 422 | return true | ||
| 400 | } | 423 | } | ||
| 401 | - | w.floatFocus = false | 424 | + | if i := w.stashIndex(id); i >= 0 { |
| 425 | + | if w.stashHidden { | |||
| 426 | + | return false | |||
| 427 | + | } | |||
| 428 | + | w.stashAt, w.stashFocus, w.floatFocus = i, true, false | |||
| 429 | + | return true | |||
| 430 | + | } | |||
| 402 | for i := range w.Columns { | 431 | for i := range w.Columns { | ||
| 403 | for j, v := range w.Columns[i].Windows { | 432 | for j, v := range w.Columns[i].Windows { | ||
| 404 | if v == id { | 433 | if v == id { | ||
| 434 | + | w.floatFocus, w.stashFocus = false, false | |||
| 405 | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | 435 | if w.Overflow == OverflowFixed && w.Focus != i && w.Focus < len(w.Columns) { | ||
| 406 | w.Columns[w.Focus].FullWidth = false | 436 | w.Columns[w.Focus].FullWidth = false | ||
| 407 | } | 437 | } | ||
| @@ -419,10 +449,17 @@ func (w *Workspace) FocusColumn(dir int) { | |||||
| 419 | return | 449 | return | ||
| 420 | } | 450 | } | ||
| 421 | if w.floatFocus { | 451 | if w.floatFocus { | ||
| 422 | - | // The first move leaves the floating window for the columns. | 452 | + | // The first move leaves the native float for the stash or columns. |
| 423 | w.floatFocus = false | 453 | w.floatFocus = false | ||
| 424 | return | 454 | return | ||
| 425 | } | 455 | } | ||
| 456 | + | if w.stashFocused() { | |||
| 457 | + | // Inside the stash, stopping at its ends: only hiding it leaves. | |||
| 458 | + | if (dir == -1 || dir == 1) && w.stashAt+dir >= 0 && w.stashAt+dir < len(w.Stash) { | |||
| 459 | + | w.stashAt += dir | |||
| 460 | + | } | |||
| 461 | + | return | |||
| 462 | + | } | |||
| 426 | if i := w.columnToward(dir); i >= 0 { | 463 | if i := w.columnToward(dir); i >= 0 { | ||
| 427 | if w.Overflow == OverflowFixed { | 464 | if w.Overflow == OverflowFixed { | ||
| 428 | w.Columns[w.Focus].FullWidth = false | 465 | w.Columns[w.Focus].FullWidth = false | ||
| @@ -466,6 +503,10 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||||
| 466 | w.floatFocus = false | 503 | w.floatFocus = false | ||
| 467 | return true | 504 | return true | ||
| 468 | } | 505 | } | ||
| 506 | + | if w.stashFocused() { | |||
| 507 | + | // The stash is one row: up and down do nothing there. | |||
| 508 | + | return true | |||
| 509 | + | } | |||
| 469 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | 510 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | ||
| 470 | return false | 511 | return false | ||
| 471 | } | 512 | } | ||
| @@ -522,7 +563,7 @@ func (w *Workspace) screenNeighbor(dx, dy int) int { | |||||
| 522 | 563 | ||||
| 523 | func abs(n int) int { return max(n, -n) } | 564 | func abs(n int) int { return max(n, -n) } | ||
| 524 | func (w *Workspace) MoveColumn(dir int) { | 565 | func (w *Workspace) MoveColumn(dir int) { | ||
| 525 | - | if w.floatFocus { | 566 | + | if w.onFloat() { |
| 526 | return | 567 | return | ||
| 527 | } | 568 | } | ||
| 528 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | 569 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | ||
| @@ -535,7 +576,7 @@ func (w *Workspace) MoveColumn(dir int) { | |||||
| 535 | 576 | ||||
| 536 | // takeColumn removes the focused column and returns it, as a normal column. | 577 | // takeColumn removes the focused column and returns it, as a normal column. | ||
| 537 | func (w *Workspace) takeColumn() (Column, bool) { | 578 | func (w *Workspace) takeColumn() (Column, bool) { | ||
| 538 | - | if len(w.Columns) == 0 || w.floatFocus { | 579 | + | if len(w.Columns) == 0 || w.onFloat() { |
| 539 | return Column{}, false | 580 | return Column{}, false | ||
| 540 | } | 581 | } | ||
| 541 | col := w.Columns[w.Focus] | 582 | col := w.Columns[w.Focus] | ||
| @@ -571,7 +612,7 @@ func (w *Workspace) CycleWidth() { | |||||
| 571 | w.toggleExpanded() | 612 | w.toggleExpanded() | ||
| 572 | return | 613 | return | ||
| 573 | } | 614 | } | ||
| 574 | - | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { | 615 | + | if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() { |
| 575 | return | 616 | return | ||
| 576 | } | 617 | } | ||
| 577 | // auto → presets in order → auto. | 618 | // auto → presets in order → auto. | ||
| @@ -594,7 +635,7 @@ func (w *Workspace) CycleWidth() { | |||||
| 594 | // toggleExpanded makes the focused column the expanded one, or shrinks it | 635 | // toggleExpanded makes the focused column the expanded one, or shrinks it | ||
| 595 | // back when it already is. One column at most is expanded. | 636 | // back when it already is. One column at most is expanded. | ||
| 596 | func (w *Workspace) toggleExpanded() { | 637 | func (w *Workspace) toggleExpanded() { | ||
| 597 | - | if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.floatFocus { | 638 | + | if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.onFloat() { |
| 598 | return | 639 | return | ||
| 599 | } | 640 | } | ||
| 600 | on := !w.Columns[w.Focus].Expanded | 641 | on := !w.Columns[w.Focus].Expanded | ||
| @@ -607,7 +648,7 @@ func (w *Workspace) toggleExpanded() { | |||||
| 607 | 648 | ||||
| 608 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | 649 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | ||
| 609 | func (w *Workspace) ToggleFullWidth() { | 650 | func (w *Workspace) ToggleFullWidth() { | ||
| 610 | - | if len(w.Columns) == 0 || w.floatFocus { | 651 | + | if len(w.Columns) == 0 || w.onFloat() { |
| 611 | return | 652 | return | ||
| 612 | } | 653 | } | ||
| 613 | c := &w.Columns[w.Focus] | 654 | c := &w.Columns[w.Focus] | ||
| @@ -631,12 +672,12 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 631 | // Activate focuses a window and makes it visible: it leaves another | 672 | // Activate focuses a window and makes it visible: it leaves another | ||
| 632 | // window's fullscreen, which hides everything else. | 673 | // window's fullscreen, which hides everything else. | ||
| 633 | func (w *Workspace) Activate(id WindowID) { | 674 | func (w *Workspace) Activate(id WindowID) { | ||
| 634 | - | if w.floatsHidden && w.floatIndex(id) >= 0 { | 675 | + | if i := w.stashIndex(id); i >= 0 && w.stashHidden { |
| 635 | - | // A hidden float must be seen: show the floats with it. Another | 676 | + | // A hidden stashed window must be seen: show the stash with it, |
| 636 | - | // window's fullscreen leaves first, so the float's own pending | 677 | + | // selected. Another window's fullscreen leaves first, so its own |
| 637 | - | // fullscreen comes back. | 678 | + | // pending fullscreen comes back. |
| 638 | - | w.fullscreen = 0 | 679 | + | w.fullscreen, w.stashAt = 0, i |
| 639 | - | w.showFloats() | 680 | + | w.showStash() |
| 640 | } | 681 | } | ||
| 641 | if w.fullscreen != 0 && w.fullscreen != id { | 682 | if w.fullscreen != 0 && w.fullscreen != id { | ||
| 642 | w.fullscreen = 0 | 683 | w.fullscreen = 0 | ||
| @@ -647,9 +688,9 @@ func (w *Workspace) Activate(id WindowID) { | |||||
| 647 | // SetFullscreen applies a client request. It never moves focus: client | 688 | // SetFullscreen applies a client request. It never moves focus: client | ||
| 648 | // requests are automatic events (ADR 011 golden rule). | 689 | // requests are automatic events (ADR 011 golden rule). | ||
| 649 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | 690 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | ||
| 650 | - | if w.floatIndex(id) >= 0 { | 691 | + | if w.isFloat(id) { |
| 651 | - | if w.floatsHidden { | 692 | + | if w.stashHidden && w.stashIndex(id) >= 0 { |
| 652 | - | // Applied when the floats are shown again. | 693 | + | // Applied when the stash is shown again. |
| 653 | if on { | 694 | if on { | ||
| 654 | w.hiddenFullscreen = id | 695 | w.hiddenFullscreen = id | ||
| 655 | } else if w.hiddenFullscreen == id { | 696 | } else if w.hiddenFullscreen == id { | ||
| @@ -723,7 +764,7 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |||||
| 723 | // Moves to another workspace or monitor still work. | 764 | // Moves to another workspace or monitor still work. | ||
| 724 | func (w *Workspace) pinned() bool { | 765 | func (w *Workspace) pinned() bool { | ||
| 725 | c := w.cover() | 766 | c := w.cover() | ||
| 726 | - | return c != 0 && (w.Overflow == OverflowFixed || w.floatIndex(c) >= 0) | 767 | + | return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c)) |
| 727 | } | 768 | } | ||
| 728 | 769 | ||||
| 729 | // focusCover points the focus state at the covering fullscreen window, | 770 | // focusCover points the focus state at the covering fullscreen window, | ||
| @@ -734,12 +775,11 @@ func (w *Workspace) focusCover() { | |||||
| 734 | if c == 0 { | 775 | if c == 0 { | ||
| 735 | return | 776 | return | ||
| 736 | } | 777 | } | ||
| 737 | - | if w.floatIndex(c) >= 0 { | 778 | + | if w.isFloat(c) { |
| 738 | - | w.floatFocus = true | |||
| 739 | w.FocusID(c) | 779 | w.FocusID(c) | ||
| 740 | return | 780 | return | ||
| 741 | } | 781 | } | ||
| 742 | - | w.floatFocus = false | 782 | + | w.floatFocus, w.stashFocus = false, false |
| 743 | for i, col := range w.Columns { | 783 | for i, col := range w.Columns { | ||
| 744 | if j := slices.Index(col.Windows, c); j >= 0 { | 784 | if j := slices.Index(col.Windows, c); j >= 0 { | ||
| 745 | w.Focus, w.Columns[i].Focus = i, j | 785 | w.Focus, w.Columns[i].Focus = i, j | ||
| @@ -754,7 +794,7 @@ func (w *Workspace) cover() WindowID { | |||||
| 754 | if w.fullscreen == 0 { | 794 | if w.fullscreen == 0 { | ||
| 755 | return 0 | 795 | return 0 | ||
| 756 | } | 796 | } | ||
| 757 | - | if w.floatIndex(w.fullscreen) >= 0 || w.Overflow == OverflowFixed { | 797 | + | if w.isFloat(w.fullscreen) || w.Overflow == OverflowFixed { |
| 758 | return w.fullscreen | 798 | return w.fullscreen | ||
| 759 | } | 799 | } | ||
| 760 | // Scroll overflow: only while its column is aligned on the output. | 800 | // Scroll overflow: only while its column is aligned on the output. | ||
| @@ -963,11 +1003,13 @@ func (w *Workspace) Layout() []Placement { | |||||
| 963 | } | 1003 | } | ||
| 964 | } | 1004 | } | ||
| 965 | setNeighbors(result, gap) | 1005 | setNeighbors(result, gap) | ||
| 966 | - | // Floating windows go last: they are drawn and hit on top. | 1006 | + | // Floating windows go last: they are drawn and hit on top, native |
| 1007 | + | // floats (dialogs) over the stash. | |||
| 1008 | + | result = append(result, w.stashLayout(focusedID, cover)...) | |||
| 967 | for _, f := range w.Floats { | 1009 | for _, f := range w.Floats { | ||
| 968 | // A covering fullscreen window hides every float, its own dialogs | 1010 | // A covering fullscreen window hides every float, its own dialogs | ||
| 969 | // too: they show again when it leaves fullscreen. | 1011 | // too: they show again when it leaves fullscreen. | ||
| 970 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: w.floatsHidden || cover != 0 && f.ID != cover} | 1012 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover} |
| 971 | if p.Hidden { | 1013 | if p.Hidden { | ||
| 972 | p.Rect = Rect{} | 1014 | p.Rect = Rect{} | ||
| 973 | result = append(result, p) | 1015 | result = append(result, p) | ||
| @@ -1014,106 +1056,38 @@ func setNeighbors(tiles []Placement, gap int) { | |||||
| 1014 | } | 1056 | } | ||
| 1015 | } | 1057 | } | ||
| 1016 | 1058 | ||||
| 1017 | - | // floatRect centres a floating window in the usable area, its border | 1059 | + | // floatRect centres a native floating window in the usable area, its |
| 1018 | - | // around its client size, clamped to the area. | 1060 | + | // border around its client size, clamped to the area. |
| 1019 | func (w *Workspace) floatRect(f Float) Rect { | 1061 | func (w *Workspace) floatRect(f Float) Rect { | ||
| 1020 | u := w.Usable | 1062 | u := w.Usable | ||
| 1021 | fw, fh := f.W, f.H | 1063 | fw, fh := f.W, f.H | ||
| 1022 | - | if f.back != nil { | 1064 | + | if fw <= 0 || fh <= 0 { |
| 1023 | - | fw, fh = max(u.W*80/100-2*w.border, 0), max(u.H*80/100-2*w.border, 0) | |||
| 1024 | - | } | |||
| 1025 | - | if f.back == nil && (fw <= 0 || fh <= 0) { | |||
| 1026 | fw, fh = u.W/2, u.H/2 | 1065 | fw, fh = u.W/2, u.H/2 | ||
| 1027 | } | 1066 | } | ||
| 1028 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | 1067 | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | ||
| 1029 | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | 1068 | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | ||
| 1030 | } | 1069 | } | ||
| 1031 | 1070 | ||||
| 1032 | - | // imposedFloat reports whether core, rather than the client, owns the size. | 1071 | + | // imposedFloat reports whether core, rather than the client, owns the |
| 1033 | - | func (w *Workspace) imposedFloat(id WindowID) bool { | 1072 | + | // size of a floating window: a stashed one. |
| 1034 | - | i := w.floatIndex(id) | 1073 | + | func (w *Workspace) imposedFloat(id WindowID) bool { return w.stashIndex(id) >= 0 } |
| 1035 | - | return i >= 0 && w.Floats[i].back != nil | |||
| 1036 | - | } | |||
| 1037 | 1074 | ||||
| 1038 | - | // ToggleWindowFloating converts a tile to an 80% float, remembering its | 1075 | + | // ToggleWindowStash stashes the focused tile, or returns the focused |
| 1039 | - | // column and row. Native floats instead become new columns. | 1076 | + | // stashed window to its column. A native float becomes a new column. |
| 1040 | - | func (w *Workspace) ToggleWindowFloating() { | 1077 | + | func (w *Workspace) ToggleWindowStash() { |
| 1041 | id, ok := w.Focused() | 1078 | id, ok := w.Focused() | ||
| 1042 | // A fullscreen window (a Wine game in scanout) keeps its place: it | 1079 | // A fullscreen window (a Wine game in scanout) keeps its place: it | ||
| 1043 | // leaves fullscreen first, then may float. | 1080 | // leaves fullscreen first, then may float. | ||
| 1044 | if !ok || id == w.fullscreen { | 1081 | if !ok || id == w.fullscreen { | ||
| 1045 | return | 1082 | return | ||
| 1046 | } | 1083 | } | ||
| 1047 | - | if i := w.floatIndex(id); i >= 0 { | 1084 | + | switch { |
| 1048 | - | f := w.Floats[i] | 1085 | + | case w.floatIndex(id) >= 0: |
| 1049 | w.RemoveWindow(id) | 1086 | w.RemoveWindow(id) | ||
| 1050 | - | if f.back == nil { | 1087 | + | w.restore(id, nil) |
| 1051 | - | w.addColumn(Column{Windows: []WindowID{id}}) | 1088 | + | case w.stashIndex(id) >= 0: |
| 1052 | - | } else { | 1089 | + | w.unstash(w.stashIndex(id)) |
| 1053 | - | back := f.back | 1090 | + | default: |
| 1054 | - | at := min(back.col, len(w.Columns)) | 1091 | + | w.stashWindow(id) |
| 1055 | - | if i := slices.IndexFunc(w.Columns, back.holdsStack); i >= 0 { | |||
| 1056 | - | at = i | |||
| 1057 | - | c := &w.Columns[at] | |||
| 1058 | - | row := 0 | |||
| 1059 | - | for _, v := range back.stacked[:back.row] { | |||
| 1060 | - | if slices.Contains(c.Windows, v) { | |||
| 1061 | - | row++ | |||
| 1062 | - | } | |||
| 1063 | - | } | |||
| 1064 | - | c.Windows = slices.Insert(c.Windows, row, id) | |||
| 1065 | - | c.Focus = row | |||
| 1066 | - | w.Focus = at | |||
| 1067 | - | w.scroll() | |||
| 1068 | - | } else { | |||
| 1069 | - | // Its column is gone: it comes back expanded, unless another | |||
| 1070 | - | // column was expanded meanwhile (as leaveFullscreen). | |||
| 1071 | - | expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded }) | |||
| 1072 | - | w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, FullWidth: back.fullWidth, Expanded: expanded}) | |||
| 1073 | - | } | |||
| 1074 | - | } | |||
| 1075 | - | w.floatFocus = false | |||
| 1076 | - | return | |||
| 1077 | - | } | |||
| 1078 | - | col := w.Focus | |||
| 1079 | - | c := w.Columns[col] | |||
| 1080 | - | row := c.Focus | |||
| 1081 | - | lone := len(c.Windows) == 1 | |||
| 1082 | - | back := &origPlace{col: col, row: row, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | |||
| 1083 | - | for _, v := range c.Windows { | |||
| 1084 | - | if v != id { | |||
| 1085 | - | back.stacked = append(back.stacked, v) | |||
| 1086 | - | } | |||
| 1087 | - | } | |||
| 1088 | - | w.RemoveWindow(id) | |||
| 1089 | - | w.AddFloating(id, 0, 0) | |||
| 1090 | - | w.Floats[len(w.Floats)-1].back = back | |||
| 1091 | - | } | |||
| 1092 | - | ||||
| 1093 | - | // ToggleFloatingVisible hides every float without changing its placement | |||
| 1094 | - | // when a float has the focus, and shows them again from any window. It | |||
| 1095 | - | // does nothing under a covering fullscreen window (a game in scanout): | |||
| 1096 | - | // hiding it would drop scanout and VRR. | |||
| 1097 | - | func (w *Workspace) ToggleFloatingVisible() { | |||
| 1098 | - | if len(w.Floats) == 0 || w.cover() != 0 { | |||
| 1099 | - | return | |||
| 1100 | - | } | |||
| 1101 | - | if !w.floatsHidden { | |||
| 1102 | - | if w.floatFocus { | |||
| 1103 | - | w.floatsHidden, w.floatFocus, w.hiddenFullscreen = true, false, 0 | |||
| 1104 | - | } | |||
| 1105 | - | return | |||
| 1106 | - | } | |||
| 1107 | - | w.showFloats() | |||
| 1108 | - | } | |||
| 1109 | - | ||||
| 1110 | - | // showFloats shows the hidden floats; the top one takes the focus and a | |||
| 1111 | - | // hidden fullscreen float is fullscreen again. | |||
| 1112 | - | func (w *Workspace) showFloats() { | |||
| 1113 | - | w.floatsHidden = false | |||
| 1114 | - | w.floatFocus = true | |||
| 1115 | - | if w.fullscreen == 0 && w.floatIndex(w.hiddenFullscreen) >= 0 { | |||
| 1116 | - | w.fullscreen = w.hiddenFullscreen | |||
| 1117 | } | 1092 | } | ||
| 1118 | - | w.hiddenFullscreen = 0 | |||
| 1119 | } | 1093 | } | ||
M internal/core/monitor.go +51 -8
| @@ -89,6 +89,7 @@ func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | |||
| 89 | 89 | w.presets = append([]Width(nil), m.template.presets...) | |
| 90 | 90 | w.Gaps = m.template.Gaps | |
| 91 | 91 | w.border = m.template.border | |
| 92 | + | w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap | |
| 92 | 93 | w.SetOutput(m.template.Output.W, m.template.Output.H) | |
| 93 | 94 | w.SetUsable(m.template.Usable) | |
| 94 | 95 | if hidden { | |
| @@ -124,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 124 | 125 | w := m.template | |
| 125 | 126 | w.presets = append([]Width(nil), m.template.presets...) | |
| 126 | 127 | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | |
| 128 | + | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | |
| 127 | 129 | (*m.nextID)++ | |
| 128 | 130 | w.ID = *m.nextID | |
| 129 | 131 | return &w | |
| @@ -284,8 +286,8 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||
| 284 | 286 | case w.origin != nil && id != w.back.id: | |
| 285 | 287 | // A dialog of a fullscreen workspace already floats above it; its | |
| 286 | 288 | // own fullscreen would take the workspace from the window it holds. | |
| 287 | - | case w.floatsHidden && w.floatIndex(id) >= 0: | |
| 288 | - | // A hidden float stays hidden: its fullscreen waits for the show. | |
| 289 | + | case w.stashHidden && w.stashIndex(id) >= 0: | |
| 290 | + | // A hidden stash stays hidden: its fullscreen waits for the show. | |
| 289 | 291 | w.SetFullscreen(id, on) | |
| 290 | 292 | case on && m.ownWorkspace(w, id): | |
| 291 | 293 | focused, _ := w.Focused() | |
| @@ -340,6 +342,12 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||
| 340 | 342 | w.RemoveWindow(id) | |
| 341 | 343 | fs.AddFloating(id, fl.W, fl.H) | |
| 342 | 344 | fs.Floats[len(fs.Floats)-1] = fl | |
| 345 | + | } else if f := w.stashIndex(id); f >= 0 { | |
| 346 | + | // It returns to its place in the stash. | |
| 347 | + | fl := w.Stash[f] | |
| 348 | + | fs.back.float, fs.back.stash, fs.back.col = &fl, true, f | |
| 349 | + | w.RemoveWindow(id) | |
| 350 | + | fs.addStash(fl) | |
| 343 | 351 | } else { | |
| 344 | 352 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 345 | 353 | c := w.Columns[col] | |
| @@ -382,6 +390,16 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 382 | 390 | // A closure: back.col is where the window lands, known after the switch. | |
| 383 | 391 | defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }() | |
| 384 | 392 | switch prev := origin.Focus; { | |
| 393 | + | case back.stash: | |
| 394 | + | at := min(back.col, len(origin.Stash)) | |
| 395 | + | origin.Stash = slices.Insert(origin.Stash, at, *back.float) | |
| 396 | + | if focus { | |
| 397 | + | origin.stashAt = at | |
| 398 | + | origin.showStash() | |
| 399 | + | } else if at <= origin.stashAt && len(origin.Stash) > 1 { | |
| 400 | + | origin.stashAt++ | |
| 401 | + | } | |
| 402 | + | back.col = len(origin.Columns) | |
| 385 | 403 | case back.float != nil && focus: | |
| 386 | 404 | origin.AddFloating(id, back.float.W, back.float.H) | |
| 387 | 405 | origin.Floats[len(origin.Floats)-1] = *back.float | |
| @@ -401,7 +419,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 401 | 419 | row = min(row, len(c.Windows)) | |
| 402 | 420 | c.Windows = slices.Insert(c.Windows, row, id) | |
| 403 | 421 | if focus { | |
| 404 | - | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | |
| 422 | + | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false | |
| 405 | 423 | } else if row <= c.Focus && len(c.Windows) > 1 { | |
| 406 | 424 | c.Focus++ | |
| 407 | 425 | } | |
| @@ -412,7 +430,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 412 | 430 | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | |
| 413 | 431 | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |
| 414 | 432 | if focus { | |
| 415 | - | origin.floatFocus = false | |
| 433 | + | origin.floatFocus, origin.stashFocus = false, false | |
| 416 | 434 | } else if len(origin.Columns) > 1 { | |
| 417 | 435 | origin.Focus = prev | |
| 418 | 436 | if at <= prev { | |
| @@ -453,7 +471,11 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi | |||
| 453 | 471 | // The user is elsewhere: below, the focused float keeps the focus. | |
| 454 | 472 | origin.Floats = append(floats, origin.Floats...) | |
| 455 | 473 | } | |
| 456 | - | fs.Columns, fs.Floats = nil, nil | |
| 474 | + | // Windows stashed there join the end of the stash. | |
| 475 | + | for _, f := range fs.Stash { | |
| 476 | + | origin.Stash = append(origin.Stash, f.rehome()) | |
| 477 | + | } | |
| 478 | + | fs.Columns, fs.Floats, fs.Stash = nil, nil, nil | |
| 457 | 479 | origin.scroll() | |
| 458 | 480 | m.take(fs) | |
| 459 | 481 | if shown { | |
| @@ -524,6 +546,14 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||
| 524 | 546 | } else { | |
| 525 | 547 | to.FocusID(id) | |
| 526 | 548 | } | |
| 549 | + | } else if f := cur.stashIndex(id); f >= 0 { | |
| 550 | + | // It joins the end of the stash there, shown and selected. | |
| 551 | + | fl := cur.Stash[f].rehome() | |
| 552 | + | cur.RemoveWindow(id) | |
| 553 | + | to.addStash(fl) | |
| 554 | + | if to.origin == nil { | |
| 555 | + | to.Activate(id) | |
| 556 | + | } | |
| 527 | 557 | } else { | |
| 528 | 558 | col := Column{Windows: []WindowID{id}} | |
| 529 | 559 | ok := true | |
| @@ -613,9 +643,19 @@ func (m *Monitor) each(f func(*Workspace)) { | |||
| 613 | 643 | func (m *Monitor) SetOutput(width, height int) { | |
| 614 | 644 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | |
| 615 | 645 | } | |
| 616 | - | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 617 | - | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 618 | - | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |
| 646 | + | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 647 | + | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 648 | + | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |
| 649 | + | ||
| 650 | + | // SetStash sets the width of a stashed window (stash.width; 0 is the | |
| 651 | + | // default) and the space between it and its neighbors (stash.gap), in | |
| 652 | + | // percent of the usable width. | |
| 653 | + | func (m *Monitor) SetStash(width, gap int) { | |
| 654 | + | if width != 0 { | |
| 655 | + | width = min(max(width, 10), 90) | |
| 656 | + | } | |
| 657 | + | m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) }) | |
| 658 | + | } | |
| 619 | 659 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | |
| 620 | 660 | ||
| 621 | 661 | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | |
| @@ -688,6 +728,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 688 | 728 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | |
| 689 | 729 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | |
| 690 | 730 | } | |
| 731 | + | for _, f := range w.Stash { | |
| 732 | + | m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome()) | |
| 733 | + | } | |
| 691 | 734 | } | |
| 692 | 735 | m.hidden = hidden | |
| 693 | 736 | m.named = append([]NamedWorkspace(nil), specs...) | |
| @@ -89,6 +89,7 @@ func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | |||||
| 89 | w.presets = append([]Width(nil), m.template.presets...) | 89 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 90 | w.Gaps = m.template.Gaps | 90 | w.Gaps = m.template.Gaps | ||
| 91 | w.border = m.template.border | 91 | w.border = m.template.border | ||
| 92 | + | w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap | |||
| 92 | w.SetOutput(m.template.Output.W, m.template.Output.H) | 93 | w.SetOutput(m.template.Output.W, m.template.Output.H) | ||
| 93 | w.SetUsable(m.template.Usable) | 94 | w.SetUsable(m.template.Usable) | ||
| 94 | if hidden { | 95 | if hidden { | ||
| @@ -124,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 124 | w := m.template | 125 | w := m.template | ||
| 125 | w.presets = append([]Width(nil), m.template.presets...) | 126 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 126 | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | 127 | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | ||
| 128 | + | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | |||
| 127 | (*m.nextID)++ | 129 | (*m.nextID)++ | ||
| 128 | w.ID = *m.nextID | 130 | w.ID = *m.nextID | ||
| 129 | return &w | 131 | return &w | ||
| @@ -284,8 +286,8 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||||
| 284 | case w.origin != nil && id != w.back.id: | 286 | case w.origin != nil && id != w.back.id: | ||
| 285 | // A dialog of a fullscreen workspace already floats above it; its | 287 | // A dialog of a fullscreen workspace already floats above it; its | ||
| 286 | // own fullscreen would take the workspace from the window it holds. | 288 | // own fullscreen would take the workspace from the window it holds. | ||
| 287 | - | case w.floatsHidden && w.floatIndex(id) >= 0: | 289 | + | case w.stashHidden && w.stashIndex(id) >= 0: |
| 288 | - | // A hidden float stays hidden: its fullscreen waits for the show. | 290 | + | // A hidden stash stays hidden: its fullscreen waits for the show. |
| 289 | w.SetFullscreen(id, on) | 291 | w.SetFullscreen(id, on) | ||
| 290 | case on && m.ownWorkspace(w, id): | 292 | case on && m.ownWorkspace(w, id): | ||
| 291 | focused, _ := w.Focused() | 293 | focused, _ := w.Focused() | ||
| @@ -340,6 +342,12 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||||
| 340 | w.RemoveWindow(id) | 342 | w.RemoveWindow(id) | ||
| 341 | fs.AddFloating(id, fl.W, fl.H) | 343 | fs.AddFloating(id, fl.W, fl.H) | ||
| 342 | fs.Floats[len(fs.Floats)-1] = fl | 344 | fs.Floats[len(fs.Floats)-1] = fl | ||
| 345 | + | } else if f := w.stashIndex(id); f >= 0 { | |||
| 346 | + | // It returns to its place in the stash. | |||
| 347 | + | fl := w.Stash[f] | |||
| 348 | + | fs.back.float, fs.back.stash, fs.back.col = &fl, true, f | |||
| 349 | + | w.RemoveWindow(id) | |||
| 350 | + | fs.addStash(fl) | |||
| 343 | } else { | 351 | } else { | ||
| 344 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | 352 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | ||
| 345 | c := w.Columns[col] | 353 | c := w.Columns[col] | ||
| @@ -382,6 +390,16 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 382 | // A closure: back.col is where the window lands, known after the switch. | 390 | // A closure: back.col is where the window lands, known after the switch. | ||
| 383 | defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }() | 391 | defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }() | ||
| 384 | switch prev := origin.Focus; { | 392 | switch prev := origin.Focus; { | ||
| 393 | + | case back.stash: | |||
| 394 | + | at := min(back.col, len(origin.Stash)) | |||
| 395 | + | origin.Stash = slices.Insert(origin.Stash, at, *back.float) | |||
| 396 | + | if focus { | |||
| 397 | + | origin.stashAt = at | |||
| 398 | + | origin.showStash() | |||
| 399 | + | } else if at <= origin.stashAt && len(origin.Stash) > 1 { | |||
| 400 | + | origin.stashAt++ | |||
| 401 | + | } | |||
| 402 | + | back.col = len(origin.Columns) | |||
| 385 | case back.float != nil && focus: | 403 | case back.float != nil && focus: | ||
| 386 | origin.AddFloating(id, back.float.W, back.float.H) | 404 | origin.AddFloating(id, back.float.W, back.float.H) | ||
| 387 | origin.Floats[len(origin.Floats)-1] = *back.float | 405 | origin.Floats[len(origin.Floats)-1] = *back.float | ||
| @@ -401,7 +419,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 401 | row = min(row, len(c.Windows)) | 419 | row = min(row, len(c.Windows)) | ||
| 402 | c.Windows = slices.Insert(c.Windows, row, id) | 420 | c.Windows = slices.Insert(c.Windows, row, id) | ||
| 403 | if focus { | 421 | if focus { | ||
| 404 | - | origin.Focus, c.Focus, origin.floatFocus = back.col, row, false | 422 | + | origin.Focus, c.Focus, origin.floatFocus, origin.stashFocus = back.col, row, false, false |
| 405 | } else if row <= c.Focus && len(c.Windows) > 1 { | 423 | } else if row <= c.Focus && len(c.Windows) > 1 { | ||
| 406 | c.Focus++ | 424 | c.Focus++ | ||
| 407 | } | 425 | } | ||
| @@ -412,7 +430,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 412 | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | 430 | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | ||
| 413 | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | 431 | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | ||
| 414 | if focus { | 432 | if focus { | ||
| 415 | - | origin.floatFocus = false | 433 | + | origin.floatFocus, origin.stashFocus = false, false |
| 416 | } else if len(origin.Columns) > 1 { | 434 | } else if len(origin.Columns) > 1 { | ||
| 417 | origin.Focus = prev | 435 | origin.Focus = prev | ||
| 418 | if at <= prev { | 436 | if at <= prev { | ||
| @@ -453,7 +471,11 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []Wi | |||||
| 453 | // The user is elsewhere: below, the focused float keeps the focus. | 471 | // The user is elsewhere: below, the focused float keeps the focus. | ||
| 454 | origin.Floats = append(floats, origin.Floats...) | 472 | origin.Floats = append(floats, origin.Floats...) | ||
| 455 | } | 473 | } | ||
| 456 | - | fs.Columns, fs.Floats = nil, nil | 474 | + | // Windows stashed there join the end of the stash. |
| 475 | + | for _, f := range fs.Stash { | |||
| 476 | + | origin.Stash = append(origin.Stash, f.rehome()) | |||
| 477 | + | } | |||
| 478 | + | fs.Columns, fs.Floats, fs.Stash = nil, nil, nil | |||
| 457 | origin.scroll() | 479 | origin.scroll() | ||
| 458 | m.take(fs) | 480 | m.take(fs) | ||
| 459 | if shown { | 481 | if shown { | ||
| @@ -524,6 +546,14 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) { | |||||
| 524 | } else { | 546 | } else { | ||
| 525 | to.FocusID(id) | 547 | to.FocusID(id) | ||
| 526 | } | 548 | } | ||
| 549 | + | } else if f := cur.stashIndex(id); f >= 0 { | |||
| 550 | + | // It joins the end of the stash there, shown and selected. | |||
| 551 | + | fl := cur.Stash[f].rehome() | |||
| 552 | + | cur.RemoveWindow(id) | |||
| 553 | + | to.addStash(fl) | |||
| 554 | + | if to.origin == nil { | |||
| 555 | + | to.Activate(id) | |||
| 556 | + | } | |||
| 527 | } else { | 557 | } else { | ||
| 528 | col := Column{Windows: []WindowID{id}} | 558 | col := Column{Windows: []WindowID{id}} | ||
| 529 | ok := true | 559 | ok := true | ||
| @@ -613,9 +643,19 @@ func (m *Monitor) each(f func(*Workspace)) { | |||||
| 613 | func (m *Monitor) SetOutput(width, height int) { | 643 | func (m *Monitor) SetOutput(width, height int) { | ||
| 614 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | 644 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | ||
| 615 | } | 645 | } | ||
| 616 | - | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | 646 | + | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } |
| 617 | - | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | 647 | + | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } |
| 618 | - | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | 648 | + | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } |
| 649 | + | ||||
| 650 | + | // SetStash sets the width of a stashed window (stash.width; 0 is the | |||
| 651 | + | // default) and the space between it and its neighbors (stash.gap), in | |||
| 652 | + | // percent of the usable width. | |||
| 653 | + | func (m *Monitor) SetStash(width, gap int) { | |||
| 654 | + | if width != 0 { | |||
| 655 | + | width = min(max(width, 10), 90) | |||
| 656 | + | } | |||
| 657 | + | m.each(func(w *Workspace) { w.stashWidth, w.stashGap = width, min(max(gap, 0), 10) }) | |||
| 658 | + | } | |||
| 619 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | 659 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | ||
| 620 | 660 | ||||
| 621 | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | 661 | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | ||
| @@ -688,6 +728,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||||
| 688 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | 728 | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | ||
| 689 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | 729 | m.Workspaces[m.Active].Floats[len(m.Workspaces[m.Active].Floats)-1] = f | ||
| 690 | } | 730 | } | ||
| 731 | + | for _, f := range w.Stash { | |||
| 732 | + | m.Workspaces[m.Active].Stash = append(m.Workspaces[m.Active].Stash, f.rehome()) | |||
| 733 | + | } | |||
| 691 | } | 734 | } | ||
| 692 | m.hidden = hidden | 735 | m.hidden = hidden | ||
| 693 | m.named = append([]NamedWorkspace(nil), specs...) | 736 | m.named = append([]NamedWorkspace(nil), specs...) | ||
M internal/core/popup.go +15 -1
| @@ -130,7 +130,9 @@ func flipY(e uint32) uint32 { | |||
| 130 | 130 | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |
| 131 | 131 | if p := c.popups[id]; p != nil { | |
| 132 | 132 | sc, pr, ok := c.windowRect(p.parent) | |
| 133 | - | if !ok { | |
| 133 | + | // A peeking stashed window shows no popups: they would cover the | |
| 134 | + | // selected one. | |
| 135 | + | if !ok || c.peeking(p.parent) { | |
| 134 | 136 | return nil, Rect{}, false | |
| 135 | 137 | } | |
| 136 | 138 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | |
| @@ -155,6 +157,18 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 155 | 157 | return nil, Rect{}, false | |
| 156 | 158 | } | |
| 157 | 159 | ||
| 160 | + | // peeking reports whether id is a stashed window peeking in on screen. | |
| 161 | + | func (c *Core) peeking(id WindowID) bool { | |
| 162 | + | for _, sc := range c.screens { | |
| 163 | + | for _, pl := range sc.mon.Layout() { | |
| 164 | + | if pl.ID == id { | |
| 165 | + | return pl.Peek | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | ||
| 158 | 172 | // placePopup places a new or repositioned popup and configures it; a popup | |
| 159 | 173 | // whose parent is not on screen is dismissed. | |
| 160 | 174 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |
| @@ -130,7 +130,9 @@ func flipY(e uint32) uint32 { | |||||
| 130 | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | 130 | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | ||
| 131 | if p := c.popups[id]; p != nil { | 131 | if p := c.popups[id]; p != nil { | ||
| 132 | sc, pr, ok := c.windowRect(p.parent) | 132 | sc, pr, ok := c.windowRect(p.parent) | ||
| 133 | - | if !ok { | 133 | + | // A peeking stashed window shows no popups: they would cover the |
| 134 | + | // selected one. | |||
| 135 | + | if !ok || c.peeking(p.parent) { | |||
| 134 | return nil, Rect{}, false | 136 | return nil, Rect{}, false | ||
| 135 | } | 137 | } | ||
| 136 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | 138 | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | ||
| @@ -155,6 +157,18 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||||
| 155 | return nil, Rect{}, false | 157 | return nil, Rect{}, false | ||
| 156 | } | 158 | } | ||
| 157 | 159 | ||||
| 160 | + | // peeking reports whether id is a stashed window peeking in on screen. | |||
| 161 | + | func (c *Core) peeking(id WindowID) bool { | |||
| 162 | + | for _, sc := range c.screens { | |||
| 163 | + | for _, pl := range sc.mon.Layout() { | |||
| 164 | + | if pl.ID == id { | |||
| 165 | + | return pl.Peek | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | ||||
| 158 | // placePopup places a new or repositioned popup and configures it; a popup | 172 | // placePopup places a new or repositioned popup and configures it; a popup | ||
| 159 | // whose parent is not on screen is dismissed. | 173 | // whose parent is not on screen is dismissed. | ||
| 160 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | 174 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | ||
M internal/core/screens.go +1 -0
| @@ -335,6 +335,7 @@ func (c *Core) settings(m *Monitor) { | |||
| 335 | 335 | m.SetPresets(c.presets) | |
| 336 | 336 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 337 | 337 | m.SetBorder(c.cfg.Border.Width) | |
| 338 | + | m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap) | |
| 338 | 339 | m.SetFollowMove(c.cfg.Focus.FollowMove) | |
| 339 | 340 | } | |
| 340 | 341 | ||
| @@ -335,6 +335,7 @@ func (c *Core) settings(m *Monitor) { | |||||
| 335 | m.SetPresets(c.presets) | 335 | m.SetPresets(c.presets) | ||
| 336 | m.SetGaps(c.cfg.Layout.Gaps) | 336 | m.SetGaps(c.cfg.Layout.Gaps) | ||
| 337 | m.SetBorder(c.cfg.Border.Width) | 337 | m.SetBorder(c.cfg.Border.Width) | ||
| 338 | + | m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap) | |||
| 338 | m.SetFollowMove(c.cfg.Focus.FollowMove) | 339 | m.SetFollowMove(c.cfg.Focus.FollowMove) | ||
| 339 | } | 340 | } | ||
| 340 | 341 | ||||
M internal/core/screens_test.go +160 -0
| @@ -879,3 +879,163 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) { | |||
| 879 | 879 | t.Fatalf("focused %v", focused) | |
| 880 | 880 | } | |
| 881 | 881 | } | |
| 882 | + | ||
| 883 | + | // The stash end to end: binds stash tiles, peeking neighbors are dimmed | |
| 884 | + | // and their popups wait, a click selects a peek, and the script state | |
| 885 | + | // tells the stash and whether it is hidden. | |
| 886 | + | func TestStashEndToEnd(t *testing.T) { | |
| 887 | + | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left) | |
| 888 | + | for id := ports.WindowID(1); id <= 3; id++ { | |
| 889 | + | r.mapWindow(t, id) | |
| 890 | + | } | |
| 891 | + | r.key(t, "s", ports.ModAlt) // 3 stashed | |
| 892 | + | r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window | |
| 893 | + | sc := r.key(t, "s", ports.ModAlt|ports.ModShift) | |
| 894 | + | if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) { | |
| 895 | + | t.Fatalf("hidden stash: %v", got) | |
| 896 | + | } | |
| 897 | + | r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1 | |
| 898 | + | sc = r.key(t, "s", ports.ModAlt) | |
| 899 | + | // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed. | |
| 900 | + | var peek, sel ports.SceneWindow | |
| 901 | + | for _, w := range sc[0].Windows { | |
| 902 | + | switch w.ID { | |
| 903 | + | case 3: | |
| 904 | + | peek = w | |
| 905 | + | case 1: | |
| 906 | + | sel = w | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | if peek.Hidden || peek.Dim != 0.5 || !peek.Floating || sel.Dim != 0 || sel.Hidden || !sel.Focused || !sel.Floating { | |
| 910 | + | t.Fatalf("peek %+v selected %+v", peek, sel) | |
| 911 | + | } | |
| 912 | + | // A popup of the peek waits until the peek is selected. | |
| 913 | + | r.client <- ports.PopupRequest{ID: 9, Parent: 3, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}} | |
| 914 | + | r.client <- ports.PopupMapped{ID: 9} | |
| 915 | + | sc = receive(t, r.scenes) | |
| 916 | + | for _, w := range sc[0].Windows { | |
| 917 | + | if w.ID == 9 { | |
| 918 | + | t.Fatalf("popup of a peek drawn: %+v", w) | |
| 919 | + | } | |
| 920 | + | } | |
| 921 | + | // A click on the peek (x 0..19 of 200) selects it. | |
| 922 | + | r.input <- ports.PointerMotion{X: 5, Y: 50} | |
| 923 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 924 | + | receive(t, r.scenes) | |
| 925 | + | st := receive(t, r.state) | |
| 926 | + | for st.Window == nil || st.Window.ID != 3 { | |
| 927 | + | st = receive(t, r.state) | |
| 928 | + | } | |
| 929 | + | byID := map[ports.WindowID]ports.WindowState{} | |
| 930 | + | for _, w := range st.Windows { | |
| 931 | + | byID[w.ID] = w | |
| 932 | + | } | |
| 933 | + | if w := byID[3]; !w.Floating || w.StashIndex != 1 || w.StashCount != 2 || w.Hidden || !w.Visible { | |
| 934 | + | t.Fatalf("window 3 %+v", w) | |
| 935 | + | } | |
| 936 | + | if w := byID[2]; w.Floating || w.StashIndex != 0 || w.Hidden { | |
| 937 | + | t.Fatalf("tile %+v", w) | |
| 938 | + | } | |
| 939 | + | r.input <- ports.PointerButton{Button: 0x110} | |
| 940 | + | r.key(t, "s", ports.ModAlt|ports.ModShift) | |
| 941 | + | st = receive(t, r.state) | |
| 942 | + | for !st.Windows[0].Hidden { | |
| 943 | + | st = receive(t, r.state) | |
| 944 | + | } | |
| 945 | + | for _, w := range st.Windows { | |
| 946 | + | if (w.StashIndex > 0) != w.Hidden { | |
| 947 | + | t.Fatalf("after hide %+v", w) | |
| 948 | + | } | |
| 949 | + | } | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | // stateAfter waits for the published state that ok accepts, or fails: | |
| 953 | + | // snapshots from before an event may still be queued. | |
| 954 | + | func stateAfter(t *testing.T, ch <-chan ports.State, ok func(ports.State) bool) ports.State { | |
| 955 | + | t.Helper() | |
| 956 | + | deadline := time.After(time.Second) | |
| 957 | + | for { | |
| 958 | + | select { | |
| 959 | + | case st := <-ch: | |
| 960 | + | if ok(st) { | |
| 961 | + | return st | |
| 962 | + | } | |
| 963 | + | case <-deadline: | |
| 964 | + | t.Fatal("no matching state") | |
| 965 | + | return ports.State{} | |
| 966 | + | } | |
| 967 | + | } | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | // At the ends of the stash, focus moves stay there: no hop to the | |
| 971 | + | // neighbor monitor or workspace. | |
| 972 | + | func TestStashKeepsFocusAtEdges(t *testing.T) { | |
| 973 | + | r := startMulti(t, nil, left, right) | |
| 974 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1 | |
| 975 | + | r.mapWindow(t, 1) | |
| 976 | + | r.mapWindow(t, 2) | |
| 977 | + | r.key(t, "s", ports.ModAlt) | |
| 978 | + | for _, k := range []string{"Right", "Down"} { | |
| 979 | + | r.key(t, k, ports.ModAlt) | |
| 980 | + | } | |
| 981 | + | // A marker after the keys: its state follows theirs. | |
| 982 | + | r.mapWindow(t, 9) | |
| 983 | + | st := stateAfter(t, r.state, func(st ports.State) bool { return len(st.Windows) == 3 }) | |
| 984 | + | if st.Output != "DP-1" || st.Outputs[0].Active != 1 { | |
| 985 | + | t.Fatalf("output %s active %d", st.Output, st.Outputs[0].Active) | |
| 986 | + | } | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | // A focused native dialog: focus-column first leaves the dialog for the | |
| 990 | + | // tiles below, it does not hop to the neighbor monitor. | |
| 991 | + | func TestDialogFocusStaysOnMonitor(t *testing.T) { | |
| 992 | + | r := startMulti(t, nil, left, right) | |
| 993 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1 | |
| 994 | + | r.mapWindow(t, 1) | |
| 995 | + | r.client <- ports.WindowMapped{ID: 3, Floating: true, Width: 10, Height: 10} | |
| 996 | + | receive(t, r.scenes) | |
| 997 | + | r.key(t, "Right", ports.ModAlt) | |
| 998 | + | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window == nil || st.Window.ID != 3 }) | |
| 999 | + | if st.Output != "DP-1" || st.Window == nil || st.Window.ID != 1 { | |
| 1000 | + | t.Fatalf("output %s window %+v", st.Output, st.Window) | |
| 1001 | + | } | |
| 1002 | + | } | |
| 1003 | + | ||
| 1004 | + | // A click on a tile behind the shown stash hides the stash and focuses | |
| 1005 | + | // the tile. | |
| 1006 | + | func TestStashHidesOnBackgroundClick(t *testing.T) { | |
| 1007 | + | // Gap 10 at 80%: no peeks, the margins show the tile behind. | |
| 1008 | + | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left) | |
| 1009 | + | r.mapWindow(t, 1) | |
| 1010 | + | r.mapWindow(t, 2) | |
| 1011 | + | r.key(t, "s", ports.ModAlt) // 2 stashed and focused, over tile 1 | |
| 1012 | + | r.input <- ports.PointerMotion{X: 5, Y: 50} | |
| 1013 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 1014 | + | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 }) | |
| 1015 | + | for _, w := range st.Windows { | |
| 1016 | + | if w.ID == 2 && !w.Hidden { | |
| 1017 | + | t.Fatalf("stash still shown: %+v", w) | |
| 1018 | + | } | |
| 1019 | + | } | |
| 1020 | + | } | |
| 1021 | + | ||
| 1022 | + | // A peek narrower than its border is still clickable. | |
| 1023 | + | func TestStashThinPeekClick(t *testing.T) { | |
| 1024 | + | // 200 wide, 80%: a 20px margin, 18px of gap, 2px of the peek. | |
| 1025 | + | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left) | |
| 1026 | + | r.mapWindow(t, 1) | |
| 1027 | + | r.mapWindow(t, 2) | |
| 1028 | + | r.key(t, "s", ports.ModAlt) // 2 stashed | |
| 1029 | + | r.key(t, "s", ports.ModAlt|ports.ModShift) | |
| 1030 | + | r.key(t, "Left", ports.ModAlt) | |
| 1031 | + | r.key(t, "s", ports.ModAlt) // stash 2 1, 2 peeks 2px from the left | |
| 1032 | + | r.input <- ports.PointerMotion{X: 1, Y: 50} | |
| 1033 | + | for { | |
| 1034 | + | if v, ok := (<-r.commands).(ports.PointerFocus); ok { | |
| 1035 | + | if v.ID != 2 { | |
| 1036 | + | t.Fatalf("pointer focus %+v, want the peek", v) | |
| 1037 | + | } | |
| 1038 | + | break | |
| 1039 | + | } | |
| 1040 | + | } | |
| 1041 | + | } | |
| @@ -879,3 +879,163 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) { | |||||
| 879 | t.Fatalf("focused %v", focused) | 879 | t.Fatalf("focused %v", focused) | ||
| 880 | } | 880 | } | ||
| 881 | } | 881 | } | ||
| 882 | + | ||||
| 883 | + | // The stash end to end: binds stash tiles, peeking neighbors are dimmed | |||
| 884 | + | // and their popups wait, a click selects a peek, and the script state | |||
| 885 | + | // tells the stash and whether it is hidden. | |||
| 886 | + | func TestStashEndToEnd(t *testing.T) { | |||
| 887 | + | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left) | |||
| 888 | + | for id := ports.WindowID(1); id <= 3; id++ { | |||
| 889 | + | r.mapWindow(t, id) | |||
| 890 | + | } | |||
| 891 | + | r.key(t, "s", ports.ModAlt) // 3 stashed | |||
| 892 | + | r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window | |||
| 893 | + | sc := r.key(t, "s", ports.ModAlt|ports.ModShift) | |||
| 894 | + | if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) { | |||
| 895 | + | t.Fatalf("hidden stash: %v", got) | |||
| 896 | + | } | |||
| 897 | + | r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1 | |||
| 898 | + | sc = r.key(t, "s", ports.ModAlt) | |||
| 899 | + | // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed. | |||
| 900 | + | var peek, sel ports.SceneWindow | |||
| 901 | + | for _, w := range sc[0].Windows { | |||
| 902 | + | switch w.ID { | |||
| 903 | + | case 3: | |||
| 904 | + | peek = w | |||
| 905 | + | case 1: | |||
| 906 | + | sel = w | |||
| 907 | + | } | |||
| 908 | + | } | |||
| 909 | + | if peek.Hidden || peek.Dim != 0.5 || !peek.Floating || sel.Dim != 0 || sel.Hidden || !sel.Focused || !sel.Floating { | |||
| 910 | + | t.Fatalf("peek %+v selected %+v", peek, sel) | |||
| 911 | + | } | |||
| 912 | + | // A popup of the peek waits until the peek is selected. | |||
| 913 | + | r.client <- ports.PopupRequest{ID: 9, Parent: 3, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}} | |||
| 914 | + | r.client <- ports.PopupMapped{ID: 9} | |||
| 915 | + | sc = receive(t, r.scenes) | |||
| 916 | + | for _, w := range sc[0].Windows { | |||
| 917 | + | if w.ID == 9 { | |||
| 918 | + | t.Fatalf("popup of a peek drawn: %+v", w) | |||
| 919 | + | } | |||
| 920 | + | } | |||
| 921 | + | // A click on the peek (x 0..19 of 200) selects it. | |||
| 922 | + | r.input <- ports.PointerMotion{X: 5, Y: 50} | |||
| 923 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 924 | + | receive(t, r.scenes) | |||
| 925 | + | st := receive(t, r.state) | |||
| 926 | + | for st.Window == nil || st.Window.ID != 3 { | |||
| 927 | + | st = receive(t, r.state) | |||
| 928 | + | } | |||
| 929 | + | byID := map[ports.WindowID]ports.WindowState{} | |||
| 930 | + | for _, w := range st.Windows { | |||
| 931 | + | byID[w.ID] = w | |||
| 932 | + | } | |||
| 933 | + | if w := byID[3]; !w.Floating || w.StashIndex != 1 || w.StashCount != 2 || w.Hidden || !w.Visible { | |||
| 934 | + | t.Fatalf("window 3 %+v", w) | |||
| 935 | + | } | |||
| 936 | + | if w := byID[2]; w.Floating || w.StashIndex != 0 || w.Hidden { | |||
| 937 | + | t.Fatalf("tile %+v", w) | |||
| 938 | + | } | |||
| 939 | + | r.input <- ports.PointerButton{Button: 0x110} | |||
| 940 | + | r.key(t, "s", ports.ModAlt|ports.ModShift) | |||
| 941 | + | st = receive(t, r.state) | |||
| 942 | + | for !st.Windows[0].Hidden { | |||
| 943 | + | st = receive(t, r.state) | |||
| 944 | + | } | |||
| 945 | + | for _, w := range st.Windows { | |||
| 946 | + | if (w.StashIndex > 0) != w.Hidden { | |||
| 947 | + | t.Fatalf("after hide %+v", w) | |||
| 948 | + | } | |||
| 949 | + | } | |||
| 950 | + | } | |||
| 951 | + | ||||
| 952 | + | // stateAfter waits for the published state that ok accepts, or fails: | |||
| 953 | + | // snapshots from before an event may still be queued. | |||
| 954 | + | func stateAfter(t *testing.T, ch <-chan ports.State, ok func(ports.State) bool) ports.State { | |||
| 955 | + | t.Helper() | |||
| 956 | + | deadline := time.After(time.Second) | |||
| 957 | + | for { | |||
| 958 | + | select { | |||
| 959 | + | case st := <-ch: | |||
| 960 | + | if ok(st) { | |||
| 961 | + | return st | |||
| 962 | + | } | |||
| 963 | + | case <-deadline: | |||
| 964 | + | t.Fatal("no matching state") | |||
| 965 | + | return ports.State{} | |||
| 966 | + | } | |||
| 967 | + | } | |||
| 968 | + | } | |||
| 969 | + | ||||
| 970 | + | // At the ends of the stash, focus moves stay there: no hop to the | |||
| 971 | + | // neighbor monitor or workspace. | |||
| 972 | + | func TestStashKeepsFocusAtEdges(t *testing.T) { | |||
| 973 | + | r := startMulti(t, nil, left, right) | |||
| 974 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1 | |||
| 975 | + | r.mapWindow(t, 1) | |||
| 976 | + | r.mapWindow(t, 2) | |||
| 977 | + | r.key(t, "s", ports.ModAlt) | |||
| 978 | + | for _, k := range []string{"Right", "Down"} { | |||
| 979 | + | r.key(t, k, ports.ModAlt) | |||
| 980 | + | } | |||
| 981 | + | // A marker after the keys: its state follows theirs. | |||
| 982 | + | r.mapWindow(t, 9) | |||
| 983 | + | st := stateAfter(t, r.state, func(st ports.State) bool { return len(st.Windows) == 3 }) | |||
| 984 | + | if st.Output != "DP-1" || st.Outputs[0].Active != 1 { | |||
| 985 | + | t.Fatalf("output %s active %d", st.Output, st.Outputs[0].Active) | |||
| 986 | + | } | |||
| 987 | + | } | |||
| 988 | + | ||||
| 989 | + | // A focused native dialog: focus-column first leaves the dialog for the | |||
| 990 | + | // tiles below, it does not hop to the neighbor monitor. | |||
| 991 | + | func TestDialogFocusStaysOnMonitor(t *testing.T) { | |||
| 992 | + | r := startMulti(t, nil, left, right) | |||
| 993 | + | r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1 | |||
| 994 | + | r.mapWindow(t, 1) | |||
| 995 | + | r.client <- ports.WindowMapped{ID: 3, Floating: true, Width: 10, Height: 10} | |||
| 996 | + | receive(t, r.scenes) | |||
| 997 | + | r.key(t, "Right", ports.ModAlt) | |||
| 998 | + | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window == nil || st.Window.ID != 3 }) | |||
| 999 | + | if st.Output != "DP-1" || st.Window == nil || st.Window.ID != 1 { | |||
| 1000 | + | t.Fatalf("output %s window %+v", st.Output, st.Window) | |||
| 1001 | + | } | |||
| 1002 | + | } | |||
| 1003 | + | ||||
| 1004 | + | // A click on a tile behind the shown stash hides the stash and focuses | |||
| 1005 | + | // the tile. | |||
| 1006 | + | func TestStashHidesOnBackgroundClick(t *testing.T) { | |||
| 1007 | + | // Gap 10 at 80%: no peeks, the margins show the tile behind. | |||
| 1008 | + | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left) | |||
| 1009 | + | r.mapWindow(t, 1) | |||
| 1010 | + | r.mapWindow(t, 2) | |||
| 1011 | + | r.key(t, "s", ports.ModAlt) // 2 stashed and focused, over tile 1 | |||
| 1012 | + | r.input <- ports.PointerMotion{X: 5, Y: 50} | |||
| 1013 | + | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 1014 | + | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 }) | |||
| 1015 | + | for _, w := range st.Windows { | |||
| 1016 | + | if w.ID == 2 && !w.Hidden { | |||
| 1017 | + | t.Fatalf("stash still shown: %+v", w) | |||
| 1018 | + | } | |||
| 1019 | + | } | |||
| 1020 | + | } | |||
| 1021 | + | ||||
| 1022 | + | // A peek narrower than its border is still clickable. | |||
| 1023 | + | func TestStashThinPeekClick(t *testing.T) { | |||
| 1024 | + | // 200 wide, 80%: a 20px margin, 18px of gap, 2px of the peek. | |||
| 1025 | + | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Border.Width = 9, 4 }, left) | |||
| 1026 | + | r.mapWindow(t, 1) | |||
| 1027 | + | r.mapWindow(t, 2) | |||
| 1028 | + | r.key(t, "s", ports.ModAlt) // 2 stashed | |||
| 1029 | + | r.key(t, "s", ports.ModAlt|ports.ModShift) | |||
| 1030 | + | r.key(t, "Left", ports.ModAlt) | |||
| 1031 | + | r.key(t, "s", ports.ModAlt) // stash 2 1, 2 peeks 2px from the left | |||
| 1032 | + | r.input <- ports.PointerMotion{X: 1, Y: 50} | |||
| 1033 | + | for { | |||
| 1034 | + | if v, ok := (<-r.commands).(ports.PointerFocus); ok { | |||
| 1035 | + | if v.ID != 2 { | |||
| 1036 | + | t.Fatalf("pointer focus %+v, want the peek", v) | |||
| 1037 | + | } | |||
| 1038 | + | break | |||
| 1039 | + | } | |||
| 1040 | + | } | |||
| 1041 | + | } | |||
A internal/core/stash.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "math" | |
| 6 | + | "slices" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The stash is a workspace's strip of windows set aside with | |
| 12 | + | // toggle-window-stash, left to right in arrival order. The selected one | |
| 13 | + | // is centred, stash.width% of the usable width and stashHeight% of its | |
| 14 | + | // height; its neighbors fill the margins beside it, stash.gap% away, | |
| 15 | + | // the sides, dimmed; the others wait off screen. toggle-stash-visible | |
| 16 | + | // hides and shows the whole strip. Native floats (dialogs) are not in it. | |
| 17 | + | ||
| 18 | + | // stashWidth is the default share of the usable width, in percent, a | |
| 19 | + | // stashed window takes (stash.width); stashHeight is its share of the | |
| 20 | + | // usable height. | |
| 21 | + | const stashWidth, stashHeight = 80, 80 | |
| 22 | + | ||
| 23 | + | func (w *Workspace) stashIndex(id WindowID) int { | |
| 24 | + | return slices.IndexFunc(w.Stash, func(f Float) bool { return f.ID == id }) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // isFloat reports whether the window floats: native or stashed. | |
| 28 | + | func (w *Workspace) isFloat(id WindowID) bool { | |
| 29 | + | return w.floatIndex(id) >= 0 || w.stashIndex(id) >= 0 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // stashFocused reports whether the stash has the focus, under any native | |
| 33 | + | // float that has it. A shown stash without tiles always has it. | |
| 34 | + | func (w *Workspace) stashFocused() bool { | |
| 35 | + | return (w.stashFocus || len(w.Columns) == 0) && !w.stashHidden && len(w.Stash) > 0 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // onFloat reports whether the focus is on a floating window: column | |
| 39 | + | // actions do nothing then. | |
| 40 | + | func (w *Workspace) onFloat() bool { return w.floatFocus || w.stashFocused() } | |
| 41 | + | ||
| 42 | + | // stashWindow moves the focused tile id to the end of the stash, | |
| 43 | + | // remembering its place in its column to return there. | |
| 44 | + | func (w *Workspace) stashWindow(id WindowID) { | |
| 45 | + | c := w.Columns[w.Focus] | |
| 46 | + | lone := len(c.Windows) == 1 | |
| 47 | + | back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | |
| 48 | + | for _, v := range c.Windows { | |
| 49 | + | if v != id { | |
| 50 | + | back.stacked = append(back.stacked, v) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | w.RemoveWindow(id) | |
| 54 | + | w.addStash(Float{ID: id, back: back}) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // addStash appends a window to the stash, shows the stash and selects it. | |
| 58 | + | func (w *Workspace) addStash(f Float) { | |
| 59 | + | w.Stash = append(w.Stash, f) | |
| 60 | + | w.stashAt = len(w.Stash) - 1 | |
| 61 | + | w.showStash() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // showStash shows the stash and gives it the focus; a member whose | |
| 65 | + | // fullscreen waited while hidden is fullscreen again, selected, as if it | |
| 66 | + | // asked now. | |
| 67 | + | func (w *Workspace) showStash() { | |
| 68 | + | w.stashHidden, w.stashFocus, w.floatFocus = false, true, false | |
| 69 | + | if i := w.stashIndex(w.hiddenFullscreen); i >= 0 { | |
| 70 | + | w.fullscreen, w.stashAt = w.hiddenFullscreen, i | |
| 71 | + | } | |
| 72 | + | w.hiddenFullscreen = 0 | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // unstash returns stash entry i to its former column, focused. | |
| 76 | + | func (w *Workspace) unstash(i int) { | |
| 77 | + | f := w.Stash[i] | |
| 78 | + | w.RemoveWindow(f.ID) | |
| 79 | + | w.restore(f.ID, f.back) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // restore tiles id back where back says: its row in the column that still | |
| 83 | + | // holds its former neighbors, else a column of its own at its former | |
| 84 | + | // index. It takes the focus. | |
| 85 | + | func (w *Workspace) restore(id WindowID, back *origPlace) { | |
| 86 | + | w.floatFocus, w.stashFocus = false, false | |
| 87 | + | if back == nil { | |
| 88 | + | w.addColumn(Column{Windows: []WindowID{id}}) | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | at := min(back.col, len(w.Columns)) | |
| 92 | + | if i := slices.IndexFunc(w.Columns, back.holdsStack); i >= 0 { | |
| 93 | + | c := &w.Columns[i] | |
| 94 | + | row := 0 | |
| 95 | + | for _, v := range back.stacked[:back.row] { | |
| 96 | + | if slices.Contains(c.Windows, v) { | |
| 97 | + | row++ | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | c.Windows = slices.Insert(c.Windows, row, id) | |
| 101 | + | c.Focus = row | |
| 102 | + | w.Focus = i | |
| 103 | + | w.scroll() | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | // Its column is gone: it comes back expanded, unless another column | |
| 107 | + | // was expanded meanwhile (as leaveFullscreen). | |
| 108 | + | 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 | + | } | |
| 111 | + | ||
| 112 | + | // rehome is a stashed window moved to another workspace: its former column | |
| 113 | + | // is not there, so it returns as the last column, at its width. | |
| 114 | + | func (f Float) rehome() Float { | |
| 115 | + | back := &origPlace{col: math.MaxInt} | |
| 116 | + | if f.back != nil { | |
| 117 | + | back.width = f.back.width | |
| 118 | + | } | |
| 119 | + | f.back = back | |
| 120 | + | return f | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ToggleStashVisible hides the stash, or shows it and gives it the | |
| 124 | + | // focus. Native floats stay as they are. It does nothing under a covering | |
| 125 | + | // fullscreen window (a game in scanout): hiding it would drop scanout and | |
| 126 | + | // VRR. | |
| 127 | + | func (w *Workspace) ToggleStashVisible() { | |
| 128 | + | if len(w.Stash) == 0 || w.cover() != 0 { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | if w.stashHidden { | |
| 132 | + | w.showStash() | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | w.stashHidden, w.stashFocus = true, false | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Click focuses the clicked window. A click on a tile behind the shown | |
| 139 | + | // stash hides the stash, as a click outside a menu closes it. | |
| 140 | + | func (w *Workspace) Click(id WindowID) { | |
| 141 | + | if !w.stashHidden && len(w.Stash) > 0 && !w.isFloat(id) && w.cover() == 0 { | |
| 142 | + | w.stashHidden = true | |
| 143 | + | } | |
| 144 | + | w.FocusID(id) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // stashLayout places the stash: the selected window centred, its | |
| 148 | + | // neighbors gap% of the usable width away, showing what the margins | |
| 149 | + | // leave of them, the others hidden. cover is the covering fullscreen | |
| 150 | + | // window, if any. | |
| 151 | + | func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |
| 152 | + | u := w.Usable | |
| 153 | + | fw := min(max(u.W*cmp.Or(w.stashWidth, stashWidth)/100-2*w.border, 0)+2*w.border, u.W) | |
| 154 | + | fh := min(max(u.H*stashHeight/100-2*w.border, 0)+2*w.border, u.H) | |
| 155 | + | center := Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 156 | + | // Peeks stay in the margins: they never overlap the selected window. | |
| 157 | + | gap := u.W * w.stashGap / 100 | |
| 158 | + | peek := max((u.W-fw)/2-gap, 0) | |
| 159 | + | out := make([]Placement, 0, len(w.Stash)) | |
| 160 | + | for i, f := range w.Stash { | |
| 161 | + | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |
| 162 | + | switch { | |
| 163 | + | case f.ID == cover: | |
| 164 | + | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 165 | + | case w.stashHidden || cover != 0: | |
| 166 | + | p.Hidden = true | |
| 167 | + | case i == w.stashAt: | |
| 168 | + | p.Rect = center | |
| 169 | + | case peek > 0 && i == w.stashAt-1: | |
| 170 | + | p.Rect, p.Peek = Rect{X: center.X - gap - fw, Y: center.Y, W: fw, H: fh}, true | |
| 171 | + | case peek > 0 && i == w.stashAt+1: | |
| 172 | + | p.Rect, p.Peek = Rect{X: center.X + fw + gap, Y: center.Y, W: fw, H: fh}, true | |
| 173 | + | default: | |
| 174 | + | p.Hidden = true | |
| 175 | + | } | |
| 176 | + | out = append(out, p) | |
| 177 | + | } | |
| 178 | + | return out | |
| 179 | + | } | |
| @@ -0,0 +1,179 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "cmp" | |||
| 5 | + | "math" | |||
| 6 | + | "slices" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The stash is a workspace's strip of windows set aside with | |||
| 12 | + | // toggle-window-stash, left to right in arrival order. The selected one | |||
| 13 | + | // is centred, stash.width% of the usable width and stashHeight% of its | |||
| 14 | + | // height; its neighbors fill the margins beside it, stash.gap% away, | |||
| 15 | + | // the sides, dimmed; the others wait off screen. toggle-stash-visible | |||
| 16 | + | // hides and shows the whole strip. Native floats (dialogs) are not in it. | |||
| 17 | + | ||||
| 18 | + | // stashWidth is the default share of the usable width, in percent, a | |||
| 19 | + | // stashed window takes (stash.width); stashHeight is its share of the | |||
| 20 | + | // usable height. | |||
| 21 | + | const stashWidth, stashHeight = 80, 80 | |||
| 22 | + | ||||
| 23 | + | func (w *Workspace) stashIndex(id WindowID) int { | |||
| 24 | + | return slices.IndexFunc(w.Stash, func(f Float) bool { return f.ID == id }) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // isFloat reports whether the window floats: native or stashed. | |||
| 28 | + | func (w *Workspace) isFloat(id WindowID) bool { | |||
| 29 | + | return w.floatIndex(id) >= 0 || w.stashIndex(id) >= 0 | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // stashFocused reports whether the stash has the focus, under any native | |||
| 33 | + | // float that has it. A shown stash without tiles always has it. | |||
| 34 | + | func (w *Workspace) stashFocused() bool { | |||
| 35 | + | return (w.stashFocus || len(w.Columns) == 0) && !w.stashHidden && len(w.Stash) > 0 | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // onFloat reports whether the focus is on a floating window: column | |||
| 39 | + | // actions do nothing then. | |||
| 40 | + | func (w *Workspace) onFloat() bool { return w.floatFocus || w.stashFocused() } | |||
| 41 | + | ||||
| 42 | + | // stashWindow moves the focused tile id to the end of the stash, | |||
| 43 | + | // remembering its place in its column to return there. | |||
| 44 | + | func (w *Workspace) stashWindow(id WindowID) { | |||
| 45 | + | c := w.Columns[w.Focus] | |||
| 46 | + | lone := len(c.Windows) == 1 | |||
| 47 | + | back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone} | |||
| 48 | + | for _, v := range c.Windows { | |||
| 49 | + | if v != id { | |||
| 50 | + | back.stacked = append(back.stacked, v) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | w.RemoveWindow(id) | |||
| 54 | + | w.addStash(Float{ID: id, back: back}) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // addStash appends a window to the stash, shows the stash and selects it. | |||
| 58 | + | func (w *Workspace) addStash(f Float) { | |||
| 59 | + | w.Stash = append(w.Stash, f) | |||
| 60 | + | w.stashAt = len(w.Stash) - 1 | |||
| 61 | + | w.showStash() | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // showStash shows the stash and gives it the focus; a member whose | |||
| 65 | + | // fullscreen waited while hidden is fullscreen again, selected, as if it | |||
| 66 | + | // asked now. | |||
| 67 | + | func (w *Workspace) showStash() { | |||
| 68 | + | w.stashHidden, w.stashFocus, w.floatFocus = false, true, false | |||
| 69 | + | if i := w.stashIndex(w.hiddenFullscreen); i >= 0 { | |||
| 70 | + | w.fullscreen, w.stashAt = w.hiddenFullscreen, i | |||
| 71 | + | } | |||
| 72 | + | w.hiddenFullscreen = 0 | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // unstash returns stash entry i to its former column, focused. | |||
| 76 | + | func (w *Workspace) unstash(i int) { | |||
| 77 | + | f := w.Stash[i] | |||
| 78 | + | w.RemoveWindow(f.ID) | |||
| 79 | + | w.restore(f.ID, f.back) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // restore tiles id back where back says: its row in the column that still | |||
| 83 | + | // holds its former neighbors, else a column of its own at its former | |||
| 84 | + | // index. It takes the focus. | |||
| 85 | + | func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 86 | + | w.floatFocus, w.stashFocus = false, false | |||
| 87 | + | if back == nil { | |||
| 88 | + | w.addColumn(Column{Windows: []WindowID{id}}) | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | at := min(back.col, len(w.Columns)) | |||
| 92 | + | if i := slices.IndexFunc(w.Columns, back.holdsStack); i >= 0 { | |||
| 93 | + | c := &w.Columns[i] | |||
| 94 | + | row := 0 | |||
| 95 | + | for _, v := range back.stacked[:back.row] { | |||
| 96 | + | if slices.Contains(c.Windows, v) { | |||
| 97 | + | row++ | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | c.Windows = slices.Insert(c.Windows, row, id) | |||
| 101 | + | c.Focus = row | |||
| 102 | + | w.Focus = i | |||
| 103 | + | w.scroll() | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | // Its column is gone: it comes back expanded, unless another column | |||
| 107 | + | // was expanded meanwhile (as leaveFullscreen). | |||
| 108 | + | 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 | + | } | |||
| 111 | + | ||||
| 112 | + | // rehome is a stashed window moved to another workspace: its former column | |||
| 113 | + | // is not there, so it returns as the last column, at its width. | |||
| 114 | + | func (f Float) rehome() Float { | |||
| 115 | + | back := &origPlace{col: math.MaxInt} | |||
| 116 | + | if f.back != nil { | |||
| 117 | + | back.width = f.back.width | |||
| 118 | + | } | |||
| 119 | + | f.back = back | |||
| 120 | + | return f | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // ToggleStashVisible hides the stash, or shows it and gives it the | |||
| 124 | + | // focus. Native floats stay as they are. It does nothing under a covering | |||
| 125 | + | // fullscreen window (a game in scanout): hiding it would drop scanout and | |||
| 126 | + | // VRR. | |||
| 127 | + | func (w *Workspace) ToggleStashVisible() { | |||
| 128 | + | if len(w.Stash) == 0 || w.cover() != 0 { | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | if w.stashHidden { | |||
| 132 | + | w.showStash() | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | w.stashHidden, w.stashFocus = true, false | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Click focuses the clicked window. A click on a tile behind the shown | |||
| 139 | + | // stash hides the stash, as a click outside a menu closes it. | |||
| 140 | + | func (w *Workspace) Click(id WindowID) { | |||
| 141 | + | if !w.stashHidden && len(w.Stash) > 0 && !w.isFloat(id) && w.cover() == 0 { | |||
| 142 | + | w.stashHidden = true | |||
| 143 | + | } | |||
| 144 | + | w.FocusID(id) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // stashLayout places the stash: the selected window centred, its | |||
| 148 | + | // neighbors gap% of the usable width away, showing what the margins | |||
| 149 | + | // leave of them, the others hidden. cover is the covering fullscreen | |||
| 150 | + | // window, if any. | |||
| 151 | + | func (w *Workspace) stashLayout(focusedID, cover WindowID) []Placement { | |||
| 152 | + | u := w.Usable | |||
| 153 | + | fw := min(max(u.W*cmp.Or(w.stashWidth, stashWidth)/100-2*w.border, 0)+2*w.border, u.W) | |||
| 154 | + | fh := min(max(u.H*stashHeight/100-2*w.border, 0)+2*w.border, u.H) | |||
| 155 | + | center := Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |||
| 156 | + | // Peeks stay in the margins: they never overlap the selected window. | |||
| 157 | + | gap := u.W * w.stashGap / 100 | |||
| 158 | + | peek := max((u.W-fw)/2-gap, 0) | |||
| 159 | + | out := make([]Placement, 0, len(w.Stash)) | |||
| 160 | + | for i, f := range w.Stash { | |||
| 161 | + | p := Placement{ID: f.ID, Floating: true, Focused: f.ID == focusedID, Inset: ports.SideAll} | |||
| 162 | + | switch { | |||
| 163 | + | case f.ID == cover: | |||
| 164 | + | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |||
| 165 | + | case w.stashHidden || cover != 0: | |||
| 166 | + | p.Hidden = true | |||
| 167 | + | case i == w.stashAt: | |||
| 168 | + | p.Rect = center | |||
| 169 | + | case peek > 0 && i == w.stashAt-1: | |||
| 170 | + | p.Rect, p.Peek = Rect{X: center.X - gap - fw, Y: center.Y, W: fw, H: fh}, true | |||
| 171 | + | case peek > 0 && i == w.stashAt+1: | |||
| 172 | + | p.Rect, p.Peek = Rect{X: center.X + fw + gap, Y: center.Y, W: fw, H: fh}, true | |||
| 173 | + | default: | |||
| 174 | + | p.Hidden = true | |||
| 175 | + | } | |||
| 176 | + | out = append(out, p) | |||
| 177 | + | } | |||
| 178 | + | return out | |||
| 179 | + | } | |||
A internal/core/stash_test.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // stashIDs lists the stash of w, left to right. | |
| 9 | + | func stashIDs(w *Workspace) []WindowID { | |
| 10 | + | var ids []WindowID | |
| 11 | + | for _, f := range w.Stash { | |
| 12 | + | ids = append(ids, f.ID) | |
| 13 | + | } | |
| 14 | + | return ids | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // stashMonitor has tiles 1, 2, 3 with 2 and 3 stashed, 3 selected. | |
| 18 | + | func stashMonitor(overflow Overflow) *Monitor { | |
| 19 | + | m := newMonitor("", "") | |
| 20 | + | m.SetOutput(100, 80) | |
| 21 | + | m.SetOverflow(overflow) | |
| 22 | + | for id := WindowID(1); id <= 3; id++ { | |
| 23 | + | m.AddWindow(id) | |
| 24 | + | } | |
| 25 | + | w := m.Current() | |
| 26 | + | for _, id := range []WindowID{2, 3} { | |
| 27 | + | w.FocusID(id) | |
| 28 | + | w.ToggleWindowStash() | |
| 29 | + | } | |
| 30 | + | return m | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // A stashed window moved to another workspace joins the end of its stash, | |
| 34 | + | // selected, and returns there as a last column. | |
| 35 | + | func TestStashMoveToWorkspace(t *testing.T) { | |
| 36 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |
| 37 | + | m := stashMonitor(overflow) | |
| 38 | + | w := m.Current() | |
| 39 | + | m.MoveToWorkspace(1, false) | |
| 40 | + | to := m.Workspaces[1] | |
| 41 | + | if !slices.Equal(stashIDs(w), []WindowID{2}) || !slices.Equal(stashIDs(to), []WindowID{3}) || to.stashAt != 0 || to.stashHidden { | |
| 42 | + | t.Fatalf("%v: from %v to %v", overflow, stashIDs(w), stashIDs(to)) | |
| 43 | + | } | |
| 44 | + | if id, _ := w.Focused(); id != 2 { | |
| 45 | + | t.Fatalf("%v: source focus %d", overflow, id) | |
| 46 | + | } | |
| 47 | + | to.AddWindow(9) | |
| 48 | + | to.FocusID(3) | |
| 49 | + | to.ToggleWindowStash() | |
| 50 | + | if len(to.Columns) != 2 || to.Columns[1].Windows[0] != 3 { | |
| 51 | + | t.Fatalf("%v: unstash elsewhere %+v", overflow, to.Columns) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // A workspace moved to another monitor keeps its stash, selection and | |
| 57 | + | // hidden state. | |
| 58 | + | func TestStashFollowsWorkspace(t *testing.T) { | |
| 59 | + | m := stashMonitor(OverflowScroll) | |
| 60 | + | w := m.Current() | |
| 61 | + | w.FocusID(2) | |
| 62 | + | w.ToggleStashVisible() | |
| 63 | + | m.take(w) | |
| 64 | + | dst := newMonitor("", "") | |
| 65 | + | dst.SetOutput(200, 100) | |
| 66 | + | dst.adopt(w, false, 0) | |
| 67 | + | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.stashAt != 0 || !w.stashHidden { | |
| 68 | + | t.Fatalf("stash %v at %d hidden %v", stashIDs(w), w.stashAt, w.stashHidden) | |
| 69 | + | } | |
| 70 | + | w.ToggleStashVisible() | |
| 71 | + | if p := placement(w, 2); p.Rect != (Rect{X: 20, Y: 10, W: 160, H: 80}) { | |
| 72 | + | t.Fatalf("placement on the new output %+v", p) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Fixed overflow gives a fullscreen window its own workspace: a stashed | |
| 77 | + | // one returns to its place in the stash, selected when the user was on it. | |
| 78 | + | func TestStashFullscreenRoundTrip(t *testing.T) { | |
| 79 | + | m := stashMonitor(OverflowFixed) | |
| 80 | + | w := m.Current() | |
| 81 | + | w.FocusID(2) | |
| 82 | + | m.ToggleFullscreen() | |
| 83 | + | if m.Current() == w || !slices.Equal(stashIDs(w), []WindowID{3}) { | |
| 84 | + | t.Fatalf("fullscreen stayed: %v", stashIDs(w)) | |
| 85 | + | } | |
| 86 | + | m.ToggleFullscreen() | |
| 87 | + | if m.Current() != w || !slices.Equal(stashIDs(w), []WindowID{2, 3}) { | |
| 88 | + | t.Fatalf("back %v", stashIDs(w)) | |
| 89 | + | } | |
| 90 | + | if id, _ := w.Focused(); id != 2 || w.stashAt != 0 { | |
| 91 | + | t.Fatalf("focused %d at %d", id, w.stashAt) | |
| 92 | + | } | |
| 93 | + | // Left by a client request while the user is elsewhere: the selection | |
| 94 | + | // stays on the same window. | |
| 95 | + | w.FocusID(2) | |
| 96 | + | m.ToggleFullscreen() | |
| 97 | + | fs := m.Current() | |
| 98 | + | m.Focus(indexOf(m.Workspaces, w)) | |
| 99 | + | w.FocusID(3) | |
| 100 | + | m.SetFullscreen(2, false) | |
| 101 | + | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.Stash[w.stashAt].ID != 3 || m.has(fs) { | |
| 102 | + | t.Fatalf("request: %v at %d", stashIDs(w), w.stashAt) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // A shown stash without tiles has the focus, however it got there: a | |
| 107 | + | // named workspace handing its windows over, or a fullscreen window | |
| 108 | + | // returning while the user is elsewhere. | |
| 109 | + | func TestStashAloneHasFocus(t *testing.T) { | |
| 110 | + | m := stashMonitor(OverflowFixed) | |
| 111 | + | w := m.Current() | |
| 112 | + | w.FocusID(1) | |
| 113 | + | w.RemoveWindow(1) // only stashed windows left, the tile had the focus | |
| 114 | + | if id, _ := w.Focused(); id != 3 { | |
| 115 | + | t.Fatalf("focused %d, want the selected stashed window", id) | |
| 116 | + | } | |
| 117 | + | // Fixed overflow: the stashed window goes fullscreen on its own | |
| 118 | + | // workspace, its only tile closes, then the client leaves fullscreen | |
| 119 | + | // while the user is elsewhere. | |
| 120 | + | m = newMonitor("", "") | |
| 121 | + | m.SetOutput(100, 80) | |
| 122 | + | m.SetOverflow(OverflowFixed) | |
| 123 | + | m.AddWindow(1) | |
| 124 | + | m.AddWindow(2) | |
| 125 | + | w = m.Current() | |
| 126 | + | w.ToggleWindowStash() | |
| 127 | + | m.ToggleFullscreen() | |
| 128 | + | fs := m.Current() | |
| 129 | + | if fs == w || fs.origin != w { | |
| 130 | + | t.Fatal("no fullscreen workspace") | |
| 131 | + | } | |
| 132 | + | m.RemoveWindow(1) | |
| 133 | + | m.Focus(indexOf(m.Workspaces, fs) + 1) | |
| 134 | + | m.SetFullscreen(2, false) | |
| 135 | + | if len(w.Columns) != 0 || !slices.Equal(stashIDs(w), []WindowID{2}) || m.has(fs) { | |
| 136 | + | t.Fatalf("origin %+v", w) | |
| 137 | + | } | |
| 138 | + | if id, ok := w.Focused(); !ok || id != 2 { | |
| 139 | + | t.Fatalf("focused %d %v", id, ok) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // A fullscreen request made while the stash was hidden applies on show, | |
| 144 | + | // even over an off-screen fullscreen tile. | |
| 145 | + | func TestStashPendingFullscreenOverTile(t *testing.T) { | |
| 146 | + | w := workspace() | |
| 147 | + | w.SetMaxColumns(1) | |
| 148 | + | w.AddWindow(1) | |
| 149 | + | w.AddWindow(2) | |
| 150 | + | w.AddWindow(3) | |
| 151 | + | w.ToggleWindowStash() // 3 stashed | |
| 152 | + | w.ToggleStashVisible() | |
| 153 | + | w.SetFullscreen(3, true) // waits | |
| 154 | + | w.FocusID(1) | |
| 155 | + | w.SetFullscreen(1, true) | |
| 156 | + | w.FocusID(2) // 1 scrolled off: it no longer covers | |
| 157 | + | if w.cover() != 0 { | |
| 158 | + | t.Fatal("tile still covers") | |
| 159 | + | } | |
| 160 | + | w.ToggleStashVisible() | |
| 161 | + | if w.fullscreen != 3 || w.hiddenFullscreen != 0 { | |
| 162 | + | t.Fatalf("fullscreen %d pending %d", w.fullscreen, w.hiddenFullscreen) | |
| 163 | + | } | |
| 164 | + | if id, _ := w.Focused(); id != 3 { | |
| 165 | + | t.Fatalf("focused %d", id) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // A removed named workspace hands its stash to the active one; with no | |
| 170 | + | // tiles there, the stash takes the focus. | |
| 171 | + | func TestStashFromRemovedNamedWorkspace(t *testing.T) { | |
| 172 | + | m := newMonitor("", "") | |
| 173 | + | m.SetOutput(100, 80) | |
| 174 | + | m.SetNamed([]NamedWorkspace{{Name: "notes"}}) | |
| 175 | + | m.ToggleNamed("notes") | |
| 176 | + | m.AddWindow(1) | |
| 177 | + | m.AddWindow(2) | |
| 178 | + | w := m.Current() | |
| 179 | + | w.ToggleWindowStash() | |
| 180 | + | w.FocusID(1) | |
| 181 | + | w.ToggleWindowStash() | |
| 182 | + | m.SetNamed(nil) | |
| 183 | + | to := m.Workspaces[m.Active] | |
| 184 | + | if !slices.Equal(stashIDs(to), []WindowID{2, 1}) { | |
| 185 | + | t.Fatalf("stash %v", stashIDs(to)) | |
| 186 | + | } | |
| 187 | + | if id, ok := to.Focused(); !ok || id != 2 { | |
| 188 | + | t.Fatalf("focused %d %v", id, ok) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // stash.width sets the selected window's width; the neighbors sit | |
| 193 | + | // stash.gap beside it, in what the margins show. | |
| 194 | + | func TestStashWidth(t *testing.T) { | |
| 195 | + | m := stashMonitor(OverflowScroll) | |
| 196 | + | m.SetStash(50, 10) | |
| 197 | + | w := m.Current() | |
| 198 | + | if p := placement(w, 3); p.Rect != (Rect{X: 25, Y: 8, W: 50, H: 64}) { | |
| 199 | + | t.Fatalf("selected %+v", p) | |
| 200 | + | } | |
| 201 | + | // 25 margin - 10 gap: 15 of it shows, then the gap, then the window. | |
| 202 | + | if p := placement(w, 2); !p.Peek || p.Rect != (Rect{X: 15 - 50, Y: 8, W: 50, H: 64}) { | |
| 203 | + | t.Fatalf("left peek %+v", p) | |
| 204 | + | } | |
| 205 | + | // At 90%, a 2% gap leaves 3% of each neighbor. | |
| 206 | + | m.SetStash(90, 2) | |
| 207 | + | if p := placement(w, 2); !p.Peek || p.Rect.X+p.Rect.W != 3 { | |
| 208 | + | t.Fatalf("narrow peek %+v", p) | |
| 209 | + | } | |
| 210 | + | // A gap wider than the margin hides the neighbors. | |
| 211 | + | m.SetStash(90, 10) | |
| 212 | + | if p := placement(w, 2); !p.Hidden { | |
| 213 | + | t.Fatalf("peek past the gap %+v", p) | |
| 214 | + | } | |
| 215 | + | // 0 is the default, out-of-range values are clamped. | |
| 216 | + | m.SetStash(0, 3) | |
| 217 | + | if p := placement(w, 3); p.Rect.W != 80 { | |
| 218 | + | t.Fatalf("default width %d", p.Rect.W) | |
| 219 | + | } | |
| 220 | + | m.SetStash(5, 3) | |
| 221 | + | if p := placement(w, 3); p.Rect.W != 10 { | |
| 222 | + | t.Fatalf("clamped low %d", p.Rect.W) | |
| 223 | + | } | |
| 224 | + | m.SetStash(100, 3) | |
| 225 | + | if p := placement(w, 3); p.Rect.W != 90 { | |
| 226 | + | t.Fatalf("clamped high %d", p.Rect.W) | |
| 227 | + | } | |
| 228 | + | } | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // stashIDs lists the stash of w, left to right. | |||
| 9 | + | func stashIDs(w *Workspace) []WindowID { | |||
| 10 | + | var ids []WindowID | |||
| 11 | + | for _, f := range w.Stash { | |||
| 12 | + | ids = append(ids, f.ID) | |||
| 13 | + | } | |||
| 14 | + | return ids | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // stashMonitor has tiles 1, 2, 3 with 2 and 3 stashed, 3 selected. | |||
| 18 | + | func stashMonitor(overflow Overflow) *Monitor { | |||
| 19 | + | m := newMonitor("", "") | |||
| 20 | + | m.SetOutput(100, 80) | |||
| 21 | + | m.SetOverflow(overflow) | |||
| 22 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 23 | + | m.AddWindow(id) | |||
| 24 | + | } | |||
| 25 | + | w := m.Current() | |||
| 26 | + | for _, id := range []WindowID{2, 3} { | |||
| 27 | + | w.FocusID(id) | |||
| 28 | + | w.ToggleWindowStash() | |||
| 29 | + | } | |||
| 30 | + | return m | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // A stashed window moved to another workspace joins the end of its stash, | |||
| 34 | + | // selected, and returns there as a last column. | |||
| 35 | + | func TestStashMoveToWorkspace(t *testing.T) { | |||
| 36 | + | for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} { | |||
| 37 | + | m := stashMonitor(overflow) | |||
| 38 | + | w := m.Current() | |||
| 39 | + | m.MoveToWorkspace(1, false) | |||
| 40 | + | to := m.Workspaces[1] | |||
| 41 | + | if !slices.Equal(stashIDs(w), []WindowID{2}) || !slices.Equal(stashIDs(to), []WindowID{3}) || to.stashAt != 0 || to.stashHidden { | |||
| 42 | + | t.Fatalf("%v: from %v to %v", overflow, stashIDs(w), stashIDs(to)) | |||
| 43 | + | } | |||
| 44 | + | if id, _ := w.Focused(); id != 2 { | |||
| 45 | + | t.Fatalf("%v: source focus %d", overflow, id) | |||
| 46 | + | } | |||
| 47 | + | to.AddWindow(9) | |||
| 48 | + | to.FocusID(3) | |||
| 49 | + | to.ToggleWindowStash() | |||
| 50 | + | if len(to.Columns) != 2 || to.Columns[1].Windows[0] != 3 { | |||
| 51 | + | t.Fatalf("%v: unstash elsewhere %+v", overflow, to.Columns) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // A workspace moved to another monitor keeps its stash, selection and | |||
| 57 | + | // hidden state. | |||
| 58 | + | func TestStashFollowsWorkspace(t *testing.T) { | |||
| 59 | + | m := stashMonitor(OverflowScroll) | |||
| 60 | + | w := m.Current() | |||
| 61 | + | w.FocusID(2) | |||
| 62 | + | w.ToggleStashVisible() | |||
| 63 | + | m.take(w) | |||
| 64 | + | dst := newMonitor("", "") | |||
| 65 | + | dst.SetOutput(200, 100) | |||
| 66 | + | dst.adopt(w, false, 0) | |||
| 67 | + | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.stashAt != 0 || !w.stashHidden { | |||
| 68 | + | t.Fatalf("stash %v at %d hidden %v", stashIDs(w), w.stashAt, w.stashHidden) | |||
| 69 | + | } | |||
| 70 | + | w.ToggleStashVisible() | |||
| 71 | + | if p := placement(w, 2); p.Rect != (Rect{X: 20, Y: 10, W: 160, H: 80}) { | |||
| 72 | + | t.Fatalf("placement on the new output %+v", p) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // Fixed overflow gives a fullscreen window its own workspace: a stashed | |||
| 77 | + | // one returns to its place in the stash, selected when the user was on it. | |||
| 78 | + | func TestStashFullscreenRoundTrip(t *testing.T) { | |||
| 79 | + | m := stashMonitor(OverflowFixed) | |||
| 80 | + | w := m.Current() | |||
| 81 | + | w.FocusID(2) | |||
| 82 | + | m.ToggleFullscreen() | |||
| 83 | + | if m.Current() == w || !slices.Equal(stashIDs(w), []WindowID{3}) { | |||
| 84 | + | t.Fatalf("fullscreen stayed: %v", stashIDs(w)) | |||
| 85 | + | } | |||
| 86 | + | m.ToggleFullscreen() | |||
| 87 | + | if m.Current() != w || !slices.Equal(stashIDs(w), []WindowID{2, 3}) { | |||
| 88 | + | t.Fatalf("back %v", stashIDs(w)) | |||
| 89 | + | } | |||
| 90 | + | if id, _ := w.Focused(); id != 2 || w.stashAt != 0 { | |||
| 91 | + | t.Fatalf("focused %d at %d", id, w.stashAt) | |||
| 92 | + | } | |||
| 93 | + | // Left by a client request while the user is elsewhere: the selection | |||
| 94 | + | // stays on the same window. | |||
| 95 | + | w.FocusID(2) | |||
| 96 | + | m.ToggleFullscreen() | |||
| 97 | + | fs := m.Current() | |||
| 98 | + | m.Focus(indexOf(m.Workspaces, w)) | |||
| 99 | + | w.FocusID(3) | |||
| 100 | + | m.SetFullscreen(2, false) | |||
| 101 | + | if !slices.Equal(stashIDs(w), []WindowID{2, 3}) || w.Stash[w.stashAt].ID != 3 || m.has(fs) { | |||
| 102 | + | t.Fatalf("request: %v at %d", stashIDs(w), w.stashAt) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // A shown stash without tiles has the focus, however it got there: a | |||
| 107 | + | // named workspace handing its windows over, or a fullscreen window | |||
| 108 | + | // returning while the user is elsewhere. | |||
| 109 | + | func TestStashAloneHasFocus(t *testing.T) { | |||
| 110 | + | m := stashMonitor(OverflowFixed) | |||
| 111 | + | w := m.Current() | |||
| 112 | + | w.FocusID(1) | |||
| 113 | + | w.RemoveWindow(1) // only stashed windows left, the tile had the focus | |||
| 114 | + | if id, _ := w.Focused(); id != 3 { | |||
| 115 | + | t.Fatalf("focused %d, want the selected stashed window", id) | |||
| 116 | + | } | |||
| 117 | + | // Fixed overflow: the stashed window goes fullscreen on its own | |||
| 118 | + | // workspace, its only tile closes, then the client leaves fullscreen | |||
| 119 | + | // while the user is elsewhere. | |||
| 120 | + | m = newMonitor("", "") | |||
| 121 | + | m.SetOutput(100, 80) | |||
| 122 | + | m.SetOverflow(OverflowFixed) | |||
| 123 | + | m.AddWindow(1) | |||
| 124 | + | m.AddWindow(2) | |||
| 125 | + | w = m.Current() | |||
| 126 | + | w.ToggleWindowStash() | |||
| 127 | + | m.ToggleFullscreen() | |||
| 128 | + | fs := m.Current() | |||
| 129 | + | if fs == w || fs.origin != w { | |||
| 130 | + | t.Fatal("no fullscreen workspace") | |||
| 131 | + | } | |||
| 132 | + | m.RemoveWindow(1) | |||
| 133 | + | m.Focus(indexOf(m.Workspaces, fs) + 1) | |||
| 134 | + | m.SetFullscreen(2, false) | |||
| 135 | + | if len(w.Columns) != 0 || !slices.Equal(stashIDs(w), []WindowID{2}) || m.has(fs) { | |||
| 136 | + | t.Fatalf("origin %+v", w) | |||
| 137 | + | } | |||
| 138 | + | if id, ok := w.Focused(); !ok || id != 2 { | |||
| 139 | + | t.Fatalf("focused %d %v", id, ok) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // A fullscreen request made while the stash was hidden applies on show, | |||
| 144 | + | // even over an off-screen fullscreen tile. | |||
| 145 | + | func TestStashPendingFullscreenOverTile(t *testing.T) { | |||
| 146 | + | w := workspace() | |||
| 147 | + | w.SetMaxColumns(1) | |||
| 148 | + | w.AddWindow(1) | |||
| 149 | + | w.AddWindow(2) | |||
| 150 | + | w.AddWindow(3) | |||
| 151 | + | w.ToggleWindowStash() // 3 stashed | |||
| 152 | + | w.ToggleStashVisible() | |||
| 153 | + | w.SetFullscreen(3, true) // waits | |||
| 154 | + | w.FocusID(1) | |||
| 155 | + | w.SetFullscreen(1, true) | |||
| 156 | + | w.FocusID(2) // 1 scrolled off: it no longer covers | |||
| 157 | + | if w.cover() != 0 { | |||
| 158 | + | t.Fatal("tile still covers") | |||
| 159 | + | } | |||
| 160 | + | w.ToggleStashVisible() | |||
| 161 | + | if w.fullscreen != 3 || w.hiddenFullscreen != 0 { | |||
| 162 | + | t.Fatalf("fullscreen %d pending %d", w.fullscreen, w.hiddenFullscreen) | |||
| 163 | + | } | |||
| 164 | + | if id, _ := w.Focused(); id != 3 { | |||
| 165 | + | t.Fatalf("focused %d", id) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // A removed named workspace hands its stash to the active one; with no | |||
| 170 | + | // tiles there, the stash takes the focus. | |||
| 171 | + | func TestStashFromRemovedNamedWorkspace(t *testing.T) { | |||
| 172 | + | m := newMonitor("", "") | |||
| 173 | + | m.SetOutput(100, 80) | |||
| 174 | + | m.SetNamed([]NamedWorkspace{{Name: "notes"}}) | |||
| 175 | + | m.ToggleNamed("notes") | |||
| 176 | + | m.AddWindow(1) | |||
| 177 | + | m.AddWindow(2) | |||
| 178 | + | w := m.Current() | |||
| 179 | + | w.ToggleWindowStash() | |||
| 180 | + | w.FocusID(1) | |||
| 181 | + | w.ToggleWindowStash() | |||
| 182 | + | m.SetNamed(nil) | |||
| 183 | + | to := m.Workspaces[m.Active] | |||
| 184 | + | if !slices.Equal(stashIDs(to), []WindowID{2, 1}) { | |||
| 185 | + | t.Fatalf("stash %v", stashIDs(to)) | |||
| 186 | + | } | |||
| 187 | + | if id, ok := to.Focused(); !ok || id != 2 { | |||
| 188 | + | t.Fatalf("focused %d %v", id, ok) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // stash.width sets the selected window's width; the neighbors sit | |||
| 193 | + | // stash.gap beside it, in what the margins show. | |||
| 194 | + | func TestStashWidth(t *testing.T) { | |||
| 195 | + | m := stashMonitor(OverflowScroll) | |||
| 196 | + | m.SetStash(50, 10) | |||
| 197 | + | w := m.Current() | |||
| 198 | + | if p := placement(w, 3); p.Rect != (Rect{X: 25, Y: 8, W: 50, H: 64}) { | |||
| 199 | + | t.Fatalf("selected %+v", p) | |||
| 200 | + | } | |||
| 201 | + | // 25 margin - 10 gap: 15 of it shows, then the gap, then the window. | |||
| 202 | + | if p := placement(w, 2); !p.Peek || p.Rect != (Rect{X: 15 - 50, Y: 8, W: 50, H: 64}) { | |||
| 203 | + | t.Fatalf("left peek %+v", p) | |||
| 204 | + | } | |||
| 205 | + | // At 90%, a 2% gap leaves 3% of each neighbor. | |||
| 206 | + | m.SetStash(90, 2) | |||
| 207 | + | if p := placement(w, 2); !p.Peek || p.Rect.X+p.Rect.W != 3 { | |||
| 208 | + | t.Fatalf("narrow peek %+v", p) | |||
| 209 | + | } | |||
| 210 | + | // A gap wider than the margin hides the neighbors. | |||
| 211 | + | m.SetStash(90, 10) | |||
| 212 | + | if p := placement(w, 2); !p.Hidden { | |||
| 213 | + | t.Fatalf("peek past the gap %+v", p) | |||
| 214 | + | } | |||
| 215 | + | // 0 is the default, out-of-range values are clamped. | |||
| 216 | + | m.SetStash(0, 3) | |||
| 217 | + | if p := placement(w, 3); p.Rect.W != 80 { | |||
| 218 | + | t.Fatalf("default width %d", p.Rect.W) | |||
| 219 | + | } | |||
| 220 | + | m.SetStash(5, 3) | |||
| 221 | + | if p := placement(w, 3); p.Rect.W != 10 { | |||
| 222 | + | t.Fatalf("clamped low %d", p.Rect.W) | |||
| 223 | + | } | |||
| 224 | + | m.SetStash(100, 3) | |||
| 225 | + | if p := placement(w, 3); p.Rect.W != 90 { | |||
| 226 | + | t.Fatalf("clamped high %d", p.Rect.W) | |||
| 227 | + | } | |||
| 228 | + | } | |||
M internal/core/state.go +5 -1
| @@ -45,7 +45,11 @@ func (c *Core) state() ports.State { | |||
| 45 | 45 | n := indexOf(m.Workspaces, w) + 1 | |
| 46 | 46 | for _, id := range w.windows() { | |
| 47 | 47 | info := c.clients[id] | |
| 48 | - | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: c.idle[id]}) | |
| 48 | + | v := ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: c.idle[id], Floating: w.isFloat(id)} | |
| 49 | + | if i := w.stashIndex(id); i >= 0 { | |
| 50 | + | v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden | |
| 51 | + | } | |
| 52 | + | st.Windows = append(st.Windows, v) | |
| 49 | 53 | } | |
| 50 | 54 | } | |
| 51 | 55 | } | |
| @@ -45,7 +45,11 @@ func (c *Core) state() ports.State { | |||||
| 45 | n := indexOf(m.Workspaces, w) + 1 | 45 | n := indexOf(m.Workspaces, w) + 1 | ||
| 46 | for _, id := range w.windows() { | 46 | for _, id := range w.windows() { | ||
| 47 | info := c.clients[id] | 47 | info := c.clients[id] | ||
| 48 | - | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: c.idle[id]}) | 48 | + | v := ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: shown[id], IdleInhibit: c.idle[id], Floating: w.isFloat(id)} |
| 49 | + | if i := w.stashIndex(id); i >= 0 { | |||
| 50 | + | v.StashIndex, v.StashCount, v.Hidden = i+1, len(w.Stash), w.stashHidden | |||
| 51 | + | } | |||
| 52 | + | st.Windows = append(st.Windows, v) | |||
| 49 | } | 53 | } | ||
| 50 | } | 54 | } | ||
| 51 | } | 55 | } | ||
M internal/core/visibility_test.go +3 -3
| @@ -63,11 +63,11 @@ func TestConfigureVisible(t *testing.T) { | |||
| 63 | 63 | visible(map[WindowID]bool{1: true, 2: false}) | |
| 64 | 64 | // A floated window is visible until the floats are hidden. | |
| 65 | 65 | w.FocusID(2) | |
| 66 | - | w.ToggleWindowFloating() | |
| 66 | + | w.ToggleWindowStash() | |
| 67 | 67 | visible(map[WindowID]bool{1: true, 2: true}) | |
| 68 | - | w.ToggleFloatingVisible() | |
| 68 | + | w.ToggleStashVisible() | |
| 69 | 69 | visible(map[WindowID]bool{1: true, 2: false}) | |
| 70 | - | w.ToggleFloatingVisible() | |
| 70 | + | w.ToggleStashVisible() | |
| 71 | 71 | // Another workspace hides them all; coming back shows them. | |
| 72 | 72 | if len(m.Workspaces) < 2 { | |
| 73 | 73 | t.Fatal("no spare workspace") | |
| @@ -63,11 +63,11 @@ func TestConfigureVisible(t *testing.T) { | |||||
| 63 | visible(map[WindowID]bool{1: true, 2: false}) | 63 | visible(map[WindowID]bool{1: true, 2: false}) | ||
| 64 | // A floated window is visible until the floats are hidden. | 64 | // A floated window is visible until the floats are hidden. | ||
| 65 | w.FocusID(2) | 65 | w.FocusID(2) | ||
| 66 | - | w.ToggleWindowFloating() | 66 | + | w.ToggleWindowStash() |
| 67 | visible(map[WindowID]bool{1: true, 2: true}) | 67 | visible(map[WindowID]bool{1: true, 2: true}) | ||
| 68 | - | w.ToggleFloatingVisible() | 68 | + | w.ToggleStashVisible() |
| 69 | visible(map[WindowID]bool{1: true, 2: false}) | 69 | visible(map[WindowID]bool{1: true, 2: false}) | ||
| 70 | - | w.ToggleFloatingVisible() | 70 | + | w.ToggleStashVisible() |
| 71 | // Another workspace hides them all; coming back shows them. | 71 | // Another workspace hides them all; coming back shows them. | ||
| 72 | if len(m.Workspaces) < 2 { | 72 | if len(m.Workspaces) < 2 { | ||
| 73 | t.Fatal("no spare workspace") | 73 | t.Fatal("no spare workspace") | ||
M internal/ports/config.go +8 -0
| @@ -75,6 +75,14 @@ type Config struct { | |||
| 75 | 75 | Startup [][]string | |
| 76 | 76 | Background struct{ Color string } | |
| 77 | 77 | Floating struct{ Dim float64 } | |
| 78 | + | // Stash is the strip of windows set aside by toggle-window-stash: | |
| 79 | + | // Width is the width of its selected window (10 to 90) and Gap the | |
| 80 | + | // space between it and its neighbors (0 to 10), in percent of the | |
| 81 | + | // usable width; Dim darkens the neighbors, 0 to 1. | |
| 82 | + | Stash struct { | |
| 83 | + | Width, Gap int | |
| 84 | + | Dim float64 | |
| 85 | + | } | |
| 78 | 86 | // Border is drawn inside the window edge; Width 0 disables it. | |
| 79 | 87 | Border struct { | |
| 80 | 88 | Width int | |
| @@ -75,6 +75,14 @@ type Config struct { | |||||
| 75 | Startup [][]string | 75 | Startup [][]string | ||
| 76 | Background struct{ Color string } | 76 | Background struct{ Color string } | ||
| 77 | Floating struct{ Dim float64 } | 77 | Floating struct{ Dim float64 } | ||
| 78 | + | // Stash is the strip of windows set aside by toggle-window-stash: | |||
| 79 | + | // Width is the width of its selected window (10 to 90) and Gap the | |||
| 80 | + | // space between it and its neighbors (0 to 10), in percent of the | |||
| 81 | + | // usable width; Dim darkens the neighbors, 0 to 1. | |||
| 82 | + | Stash struct { | |||
| 83 | + | Width, Gap int | |||
| 84 | + | Dim float64 | |||
| 85 | + | } | |||
| 78 | // Border is drawn inside the window edge; Width 0 disables it. | 86 | // Border is drawn inside the window edge; Width 0 disables it. | ||
| 79 | Border struct { | 87 | Border struct { | ||
| 80 | Width int | 88 | Width int | ||
M internal/ports/messages.go +10 -0
| @@ -1007,6 +1007,9 @@ type SceneWindow struct { | |||
| 1007 | 1007 | Inset Sides | |
| 1008 | 1008 | // Floating windows are drawn over the tiles and their lines. | |
| 1009 | 1009 | Floating bool | |
| 1010 | + | // Dim darkens the window, border included, with black at this | |
| 1011 | + | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |
| 1012 | + | Dim float64 | |
| 1010 | 1013 | // Popups are drawn from their content only: no border, no background. | |
| 1011 | 1014 | Popup bool | |
| 1012 | 1015 | // OverLayers popups hang from a layer surface: drawn over the top and | |
| @@ -1047,4 +1050,11 @@ type WindowState struct { | |||
| 1047 | 1050 | Visible bool | |
| 1048 | 1051 | // IdleInhibit is set while the window keeps the session from idling. | |
| 1049 | 1052 | IdleInhibit bool `json:",omitempty"` | |
| 1053 | + | // Floating is set for native floats and stashed windows. | |
| 1054 | + | Floating bool | |
| 1055 | + | // StashIndex is the 1-based place of a stashed window in its stash of | |
| 1056 | + | // StashCount windows; both are 0 outside the stash. | |
| 1057 | + | StashIndex, StashCount int | |
| 1058 | + | // Hidden is set for a stashed window while its stash is hidden. | |
| 1059 | + | Hidden bool | |
| 1050 | 1060 | } | |
| @@ -1007,6 +1007,9 @@ type SceneWindow struct { | |||||
| 1007 | Inset Sides | 1007 | Inset Sides | ||
| 1008 | // Floating windows are drawn over the tiles and their lines. | 1008 | // Floating windows are drawn over the tiles and their lines. | ||
| 1009 | Floating bool | 1009 | Floating bool | ||
| 1010 | + | // Dim darkens the window, border included, with black at this | |||
| 1011 | + | // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing. | |||
| 1012 | + | Dim float64 | |||
| 1010 | // Popups are drawn from their content only: no border, no background. | 1013 | // Popups are drawn from their content only: no border, no background. | ||
| 1011 | Popup bool | 1014 | Popup bool | ||
| 1012 | // OverLayers popups hang from a layer surface: drawn over the top and | 1015 | // OverLayers popups hang from a layer surface: drawn over the top and | ||
| @@ -1047,4 +1050,11 @@ type WindowState struct { | |||||
| 1047 | Visible bool | 1050 | Visible bool | ||
| 1048 | // IdleInhibit is set while the window keeps the session from idling. | 1051 | // IdleInhibit is set while the window keeps the session from idling. | ||
| 1049 | IdleInhibit bool `json:",omitempty"` | 1052 | IdleInhibit bool `json:",omitempty"` | ||
| 1053 | + | // Floating is set for native floats and stashed windows. | |||
| 1054 | + | Floating bool | |||
| 1055 | + | // StashIndex is the 1-based place of a stashed window in its stash of | |||
| 1056 | + | // StashCount windows; both are 0 outside the stash. | |||
| 1057 | + | StashIndex, StashCount int | |||
| 1058 | + | // Hidden is set for a stashed window while its stash is hidden. | |||
| 1059 | + | Hidden bool | |||
| 1050 | } | 1060 | } | ||